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>
144 lines
5.8 KiB
Swift
144 lines
5.8 KiB
Swift
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
|
||
}
|
||
}
|