Files
vibe-foreman/HubKit/Sources/HubIPC/UnixSocket.swift
T
wuguanganandClaude Opus 5 fe3febbfd2 feat: Stop 拦截注入 —— 收工时拿清单逼它逐条报证据
Stop 从「非阻塞、输出被忽略」改成阻塞式,有待验收项时输出
{"decision":"block","reason":<清单>} 让 Claude 不停下来,先逐条核对。
下一轮的 lastAssistantMessage 里的自查 JSON 被收回来,把 open 推到 claimed。

Stop 的超时阶梯和审批那条**完全分开**,方向也相反:

  3s HookCoordinator / 5s hubctl / 10s settings.json,超时一律 = 不拦。

审批超时 = 拒绝(安全刹车);这里超时 = 放行。写反的话 Hub 一出问题
所有会话就再也收不了工,比"少提醒一次验收"严重得多。实测 hook 本身
6-11ms,Hub 在不在都一样。

查出来三个真问题,都不是靠读代码发现的:

1. 服务端从不给 stop 回写应答。我改了 hubctl 让它等,却没动
   HubSocketServer 里写死的 guard kind == .preToolUse —— 拦截一次都没生效
   过,而且不报错、不超时、10ms 就返回,表现得和"清单里没有待办"
   一模一样。端到端测了三轮才找到。修法不是补一个 if,是加
   Kind.expectsDecision 让两边共用同一个判断。

   这和上次「grep activeScreen() 漏了一个调用点」是同一类错误:
   改了协议的一端,假设另一端会跟上。

2. SIGPIPE 会杀掉 Hub。服务端开始给 stop 回写之后,遇到不等应答就关连接
   的客户端(旧版 hubctl、被 Ctrl-C 的 hook),那次 write 直接终止进程 ——
   不是报错,是进程没了。测试崩在 signal 13 上才暴露。
   先加了 per-socket 的 SO_NOSIGPIPE(看起来更讲究),结果没生效,
   照样崩;改成进程级 signal(SIGPIPE, SIG_IGN) 才好。
   更讲究的写法如果不生效,就只是更讲究的 bug。

3. stop 事件不能去重。缓存决策只有在决策是事件的纯函数时才成立,
   而 stop 的结论取决于会变的上膛状态:同样内容的两条 stop,
   一条在用户说话前、一条在之后,结论截然相反。实测被这个坑到过。

4. {"done":1} 被判成做完了。JSONSerialization 把数字 1 桥接成 NSNumber,
   as? Bool 对它成立 —— 模型只要把 done 写成数字就能绕过"含糊按没做算"。
   改用 CFBooleanGetTypeID 严格判定。

端到端实测(真实 hubctl 二进制):未上膛不拦、上膛后拦下 13 条要点、
连着两次收工都放行、Hub 存活、自查回答(带散文和围栏)正确回收成
claimed 且 hasProof 仍为 false。

346 个测试。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 18:10:35 +08:00

195 lines
7.8 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Darwin
import Foundation
/// POSIX Unix domain socket 的薄封装。
///
/// 为什么用 socket 而不是文件轮询(旧实现是往 `/tmp/hub-signals/events/` 丢 JSON):
/// - 对端断开可以感知 —— Claude 进程死了,挂起的审批卡能自动撤掉;
/// - 连不上就等于"Hub 没在跑",零歧义,hubctl 可以立刻放行而不是傻等;
/// - 审批需要**双向**通信,文件方案得靠两边轮询,延迟和竞态都难处理。
enum UnixSocket {
enum SocketError: Error, CustomStringConvertible {
case pathTooLong(String)
case createFailed(Int32)
case bindFailed(Int32)
case listenFailed(Int32)
case connectFailed(Int32)
var description: String {
switch self {
case .pathTooLong(let p):
return "socket 路径超过 \(HubSocket.maxPathLength) 字节:\(p)"
case .createFailed(let e): return "socket() 失败:\(String(cString: strerror(e)))"
case .bindFailed(let e): return "bind() 失败:\(String(cString: strerror(e)))"
case .listenFailed(let e): return "listen() 失败:\(String(cString: strerror(e)))"
case .connectFailed(let e): return "connect() 失败:\(String(cString: strerror(e)))"
}
}
}
/// 填 `sockaddr_un`。`sun_path` 是定长 C 数组,Swift 里只能逐字节写。
private static func makeAddress(_ path: String) throws -> sockaddr_un {
let bytes = Array(path.utf8)
guard bytes.count <= HubSocket.maxPathLength else {
throw SocketError.pathTooLong(path)
}
var addr = sockaddr_un()
addr.sun_family = sa_family_t(AF_UNIX)
withUnsafeMutableBytes(of: &addr.sun_path) { raw in
raw.copyBytes(from: bytes)
}
addr.sun_len = UInt8(MemoryLayout<sockaddr_un>.size)
return addr
}
private static func withAddress<T>(
_ addr: inout sockaddr_un,
_ body: (UnsafePointer<sockaddr>, socklen_t) -> T
) -> T {
withUnsafePointer(to: &addr) { pointer in
pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) {
body($0, socklen_t(MemoryLayout<sockaddr_un>.size))
}
}
}
/// 建监听 socket。会先删掉残留的 socket 文件(上次异常退出留下的)。
static func listen(at path: String, backlog: Int32 = 16) throws -> Int32 {
var addr = try makeAddress(path)
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
guard fd >= 0 else { throw SocketError.createFailed(errno) }
// 上次进程被 kill -9 时 socket 文件会留下,不删的话 bind 报 EADDRINUSE。
unlink(path)
let bound = withAddress(&addr) { Darwin.bind(fd, $0, $1) }
guard bound == 0 else {
let e = errno
close(fd)
throw SocketError.bindFailed(e)
}
guard Darwin.listen(fd, backlog) == 0 else {
let e = errno
close(fd)
throw SocketError.listenFailed(e)
}
// 只有当前用户能连。这条链路能否决工具执行,不该对其他用户开放。
chmod(path, 0o600)
return fd
}
/// 连接到监听中的 socket。
static func connect(to path: String) throws -> Int32 {
var addr = try makeAddress(path)
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
guard fd >= 0 else { throw SocketError.createFailed(errno) }
// 服务端也可能先关(Hub 正在退出)。同样不能让写失败变成杀掉 hubctl ——
// 被信号杀掉的 hook 等同于没有输出,而那正是 fail-open 的反面。
suppressSIGPIPE(fd: fd)
let connected = withAddress(&addr) { Darwin.connect(fd, $0, $1) }
guard connected == 0 else {
let e = errno
close(fd)
throw SocketError.connectFailed(e)
}
return fd
}
/// 读一行(以 `\n` 结尾)。返回 nil 表示对端已关闭。
///
/// - Parameter timeout: 秒。nil 表示一直阻塞。
static func readLine(fd: Int32, timeout: TimeInterval?) -> String? {
var buffer = Data()
var byte: UInt8 = 0
let deadline = timeout.map { Date().addingTimeInterval($0) }
while true {
if let deadline {
let remaining = deadline.timeIntervalSinceNow
guard remaining > 0, waitReadable(fd: fd, timeout: remaining) else { return nil }
}
let n = read(fd, &byte, 1)
if n == 0 { return buffer.isEmpty ? nil : String(decoding: buffer, as: UTF8.self) }
if n < 0 {
if errno == EINTR { continue }
return nil
}
if byte == UInt8(ascii: "\n") {
return String(decoding: buffer, as: UTF8.self)
}
buffer.append(byte)
// 防御:正常的一行事件是几百字节到几 KB。超过 1 MB 说明对端行为异常,
// 不能让它把内存吃光。
if buffer.count > 1_048_576 { return nil }
}
}
/// 写一行。
@discardableResult
/// 关掉这个 fd 的 SIGPIPE。
///
/// **不加这一句,往一个对端已关闭的 socket 写数据会直接杀掉整个进程。**
/// 默认行为是发 SIGPIPE,而 SIGPIPE 的默认处置是终止 —— 不是返回错误码,
/// 是进程没了。
///
/// 这不是理论风险,是实测撞到的:Stop 改成需要应答之后,服务端会给 stop
/// 回写决策;而不等应答的客户端(旧版 hubctl、被 Ctrl-C 掉的 hook)
/// 早就把连接关了。于是每一次这样的收工都会**杀掉 Claude Hub**。
/// 测试套件是直接崩在 signal 13 上才暴露的。
///
/// 用 per-socket 的 `SO_NOSIGPIPE` 而不是全局 `signal(SIGPIPE, SIG_IGN)`:
/// 全局改会影响进程里所有别的代码(包括 Swift 运行时和第三方库),
/// 而这里只需要让这几个 socket 上的写失败退化成返回 -1 + EPIPE。
static func suppressSIGPIPE(fd: Int32) {
var on: Int32 = 1
setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &on, socklen_t(MemoryLayout<Int32>.size))
}
/// 进程级忽略 SIGPIPE。**必须调,per-socket 的那个不够。**
///
/// 一开始只加了 `SO_NOSIGPIPE`(per-socket,影响面更小,看起来更讲究),
/// 结果测试照样崩在 signal 13 上 —— 那个 setsockopt 的返回值没人检查,
/// 而它显然没起作用。这是"选了看起来更干净的方案,却没验证它真的有效"
/// 的典型:更讲究的写法如果不生效,就只是更讲究的 bug。
///
/// 忽略之后写失败退化成返回 -1 + EPIPE,由 `writeLine` 正常返回 false。
/// 对网络服务来说这是标准做法,不是权宜之计。
///
/// 幂等,多次调用无害。
static func ignoreSIGPIPEProcessWide() {
signal(SIGPIPE, SIG_IGN)
}
static func writeLine(fd: Int32, _ line: String) -> Bool {
var data = Array((line + "\n").utf8)
var offset = 0
while offset < data.count {
let n = data.withUnsafeBytes { raw -> Int in
write(fd, raw.baseAddress!.advanced(by: offset), data.count - offset)
}
if n <= 0 {
if errno == EINTR { continue }
return false
}
offset += n
}
return true
}
/// 用 `poll` 等 fd 可读。
private static func waitReadable(fd: Int32, timeout: TimeInterval) -> Bool {
var pfd = pollfd(fd: fd, events: Int16(POLLIN), revents: 0)
let millis = Int32(max(0, min(timeout * 1000, Double(Int32.max))))
let result = poll(&pfd, 1, millis)
return result > 0 && (pfd.revents & Int16(POLLIN)) != 0
}
}