Files
vibe-foreman/HubKit/Sources/HubIPC/HubSocketServer.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

144 lines
5.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
import HubCore
/// Hub 侧的 socket 服务端。
///
/// 每条连接的生命周期:hubctl 连上 → 发一行事件 JSON → 服务端处理
/// →(仅 preToolUse)回一行决策 JSON → 关闭。
///
/// 连接短、并发低,所以每条连接用一个阻塞的处理块跑在并发队列上就够了,
/// 不需要引入 async 事件循环。
public final class HubSocketServer: @unchecked Sendable {
/// 事件处理器。返回值只有 `preToolUse` 会被用到。
///
/// 处理器可能被长时间阻塞(审批要等用户点击),所以它跑在自己的连接线程上,
/// 不会挡住其他连接。
public typealias Handler = @Sendable (HookEvent) -> HookDecision?
private let path: String
private let handler: Handler
private var listenFd: Int32 = -1
private var acceptSource: DispatchSourceRead?
private let queue = DispatchQueue(label: "hub.ipc.accept")
private let workQueue = DispatchQueue(label: "hub.ipc.work", attributes: .concurrent)
public init(path: String = HubSocket.path, handler: @escaping Handler) {
self.path = path
self.handler = handler
}
public func start() throws {
// 客户端可能在我们回写决策之前就关掉连接(不等应答的 hook、
// 被 Ctrl-C 掉的 hubctl、旧版二进制)。不忽略 SIGPIPE 的话,
// 那一次 write 会**直接杀掉 Claude Hub** —— 不是报错,是进程没了。
UnixSocket.ignoreSIGPIPEProcessWide()
let fd = try UnixSocket.listen(at: path)
listenFd = fd
let source = DispatchSource.makeReadSource(fileDescriptor: fd, queue: queue)
source.setEventHandler { [weak self] in self?.acceptOne() }
source.setCancelHandler { close(fd) }
source.resume()
acceptSource = source
}
public func stop() {
acceptSource?.cancel()
acceptSource = nil
listenFd = -1
unlink(path)
}
private func acceptOne() {
let clientFd = accept(listenFd, nil, nil)
guard clientFd >= 0 else { return }
// 客户端可能在我们回写之前就关掉连接(不等应答的 hook、被 Ctrl-C 掉的
// hubctl)。不关掉 SIGPIPE 的话那一次 write 会**直接杀掉 Claude Hub**。
UnixSocket.suppressSIGPIPE(fd: clientFd)
workQueue.async { [handler] in
defer { close(clientFd) }
// 读事件本身要有上界:hubctl 连上却不发数据(被 SIGSTOP 了之类)
// 不该永久占住一个工作线程。
guard let line = UnixSocket.readLine(fd: clientFd, timeout: 10),
let data = line.data(using: .utf8),
let event = try? JSONDecoder().decode(HookEvent.self, from: data)
else { return }
let decision = handler(event)
// 用 Kind.expectsDecision,别在这里写死类型 —— 见那个属性上的事故说明。
guard event.kind.expectsDecision else { return }
let payload = decision ?? .allow
if let encoded = try? JSONEncoder().encode(payload),
let text = String(data: encoded, encoding: .utf8) {
HubLog.ipc.notice("回写决策 \(text, privacy: .public)")
UnixSocket.writeLine(fd: clientFd, text)
}
}
}
}
/// 发送结果。
///
/// 「连不上」和「连上了但没回应」必须区分开,因为两者的安全默认值是相反的:
/// - 连不上 = Hub 没运行 = 这次调用压根没被审查过 → **放行**(fail-open,
/// 不能因为 Hub 没开就让用户的每个命令都失败);
/// - 连上了但没回应 = Hub 认为这次操作值得拦截,但决策环节出了问题
/// → **拒绝**(拒绝可恢复,放行不可逆)。
public enum HookSendResult: Sendable {
/// 服务端明确给了结论。
case decided(HookDecision)
/// 服务端收下了但不需要应答(非 preToolUse 事件)。
case acknowledged
/// 连不上 —— Hub 没运行。
case unreachable
/// 连上了、发出去了,但超时没等到应答。
case noResponse
}
/// hubctl 侧的客户端。
public enum HubSocketClient {
/// 发事件;`preToolUse` 会等决策。
public static func send(
_ event: HookEvent,
path: String = HubSocket.path,
waitForDecision: Bool,
timeout: TimeInterval
) -> HookSendResult {
// Hub 可能正在退出,服务端先关了。被 SIGPIPE 杀掉的 hook 等同于
// 没有输出 —— 对审批链路来说那就是放行,正是 fail-open 的反面。
UnixSocket.ignoreSIGPIPEProcessWide()
guard let data = try? JSONEncoder().encode(event),
let line = String(data: data, encoding: .utf8)
else { return .unreachable }
guard let fd = try? UnixSocket.connect(to: path) else { return .unreachable }
defer { close(fd) }
guard UnixSocket.writeLine(fd: fd, line) else { return .unreachable }
guard waitForDecision else { return .acknowledged }
guard let response = UnixSocket.readLine(fd: fd, timeout: timeout),
let responseData = response.data(using: .utf8),
let decision = try? JSONDecoder().decode(HookDecision.self, from: responseData)
else { return .noResponse }
return .decided(decision)
}
/// Hub 是否在运行。用连接探测而不是查进程 —— 进程活着但 socket 没起来
/// (比如刚启动还没 bind)同样应该算"不可用"。
public static func isHubRunning(path: String = HubSocket.path) -> Bool {
guard let fd = try? UnixSocket.connect(to: path) else { return false }
close(fd)
return true
}
}