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>
This commit is contained in:
1 parent
f0f6cd7454
commit
fe3febbfd2
13 files changed
+518
-26
No files matched your search
@@ -42,6 +42,24 @@ public struct HookEvent: Codable, Sendable {
|
||||
/// 压缩正是"遗忘"最集中发生的时刻 —— Claude 记不住的东西,
|
||||
/// Hub 的清单记得住。
|
||||
case preCompact
|
||||
|
||||
/// 这类事件的发起方在等一个决策,服务端必须回写。
|
||||
///
|
||||
/// **hubctl 和 HubSocketServer 必须都用这一个属性判断,别各写各的 `if`。**
|
||||
///
|
||||
/// 这条来自一次真实事故:把 Stop 改成阻塞式时只动了 hubctl(让它等应答),
|
||||
/// 服务端那边的 `guard kind == .preToolUse` 原封不动 —— 于是 hubctl 等到的
|
||||
/// 永远是连接关闭,拦截**一次都没生效过**,而且不报错、不超时、
|
||||
/// 表现得和"清单里没有待办"一模一样。查了三轮才找到。
|
||||
///
|
||||
/// 两边共用一个判断之后,这种分歧在结构上就不成立了。
|
||||
public var expectsDecision: Bool {
|
||||
switch self {
|
||||
case .preToolUse, .stop: return true
|
||||
case .notification, .sessionEnd, .userPromptSubmit,
|
||||
.sessionStart, .postToolUse, .subagentStop, .preCompact: return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public let kind: Kind
|
||||
@@ -188,6 +206,24 @@ public struct HookDecision: Codable, Sendable {
|
||||
guard let data = try? JSONSerialization.data(withJSONObject: output) else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
/// Stop hook 应答的 stdout 内容。
|
||||
///
|
||||
/// `{"decision":"block","reason":...}` 会让 Claude **不停下来**,
|
||||
/// 并把 reason 当成新的输入继续跑一轮 —— 验收清单就是靠这个塞回去的。
|
||||
///
|
||||
/// nil = 什么都不输出 = 正常收工。**这是绝大多数情况**,也是所有异常
|
||||
/// 情况的落点:Hub 没运行、桥接超时、清单为空、没上膛,全都走这里。
|
||||
///
|
||||
/// 注意和 `hookOutputJSON()` 的格式**完全不同**(那个是
|
||||
/// hookSpecificOutput + permissionDecision),别想着合并成一个。
|
||||
public func stopOutputJSON() -> String? {
|
||||
guard verdict == .deny, let reason, !reason.isEmpty else { return nil }
|
||||
guard let data = try? JSONSerialization.data(
|
||||
withJSONObject: ["decision": "block", "reason": reason]
|
||||
) else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
}
|
||||
|
||||
/// socket 路径。
|
||||
|
||||
@@ -30,6 +30,11 @@ public final class HubSocketServer: @unchecked Sendable {
|
||||
}
|
||||
|
||||
public func start() throws {
|
||||
// 客户端可能在我们回写决策之前就关掉连接(不等应答的 hook、
|
||||
// 被 Ctrl-C 掉的 hubctl、旧版二进制)。不忽略 SIGPIPE 的话,
|
||||
// 那一次 write 会**直接杀掉 Claude Hub** —— 不是报错,是进程没了。
|
||||
UnixSocket.ignoreSIGPIPEProcessWide()
|
||||
|
||||
let fd = try UnixSocket.listen(at: path)
|
||||
listenFd = fd
|
||||
|
||||
@@ -50,6 +55,9 @@ public final class HubSocketServer: @unchecked Sendable {
|
||||
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) }
|
||||
@@ -63,7 +71,8 @@ public final class HubSocketServer: @unchecked Sendable {
|
||||
|
||||
let decision = handler(event)
|
||||
|
||||
guard event.kind == .preToolUse else { return }
|
||||
// 用 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) {
|
||||
@@ -102,6 +111,10 @@ public enum HubSocketClient {
|
||||
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 }
|
||||
|
||||
@@ -44,4 +44,31 @@ public enum HookTimeouts {
|
||||
/// 必须大于 `clientRead`,否则 hubctl 还没来得及输出拒绝就被杀了,
|
||||
/// 而被杀的 hook 等同于没有输出 —— 也就是放行。
|
||||
public static let hookProcess: Int = 90
|
||||
|
||||
// MARK: - Stop 的阶梯(独立的一组,和上面那条不共用)
|
||||
//
|
||||
// Stop 变成阻塞式之后,**每一次收工都要等 Hub 应答**。挂在上面那条
|
||||
// 75 秒的阶梯上是不能接受的:Hub 卡住时用户每说一句话就要干等 75 秒。
|
||||
//
|
||||
// 更重要的是**方向相反**。审批链路上超时 = 拒绝(安全刹车);
|
||||
// Stop 链路上超时 = **不拦**。写反的话,Hub 一出问题所有会话就再也
|
||||
// 收不了工 —— 那是比"少提醒一次验收"严重得多的故障。
|
||||
//
|
||||
// ```
|
||||
// 3s HookCoordinator 等 MainActor 算出该不该拦 → 到点就不拦
|
||||
// 5s hubctl 读 socket 等服务端应答 → 到点什么都不输出 = 正常收工
|
||||
// 10s settings.json Claude 杀掉 hubctl 的上限
|
||||
// ```
|
||||
//
|
||||
// 三个数都小得多,因为这里不需要等人 —— 该不该拦是查内存里的清单,
|
||||
// 毫秒级就有答案。留 3 秒纯粹是给 MainActor 排队的余量。
|
||||
|
||||
/// 服务端等 MainActor 给出"拦不拦"。到点 = 不拦。
|
||||
public static let stopBridge: TimeInterval = 3
|
||||
|
||||
/// hubctl 等服务端应答。到点 = 什么都不输出 = 正常收工。
|
||||
public static let stopRead: TimeInterval = 5
|
||||
|
||||
/// 写进 settings.json 的 Stop hook 超时。
|
||||
public static let stopHookProcess: Int = 10
|
||||
}
|
||||
@@ -88,6 +88,9 @@ enum UnixSocket {
|
||||
|
||||
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 {
|
||||
@@ -131,6 +134,40 @@ enum UnixSocket {
|
||||
|
||||
/// 写一行。
|
||||
@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
|
||||
|
||||
@@ -108,8 +108,7 @@ public final class HookCoordinator {
|
||||
private nonisolated func dispatch(_ event: HookEvent) -> HookDecision {
|
||||
switch event.kind {
|
||||
case .stop:
|
||||
Task { @MainActor in self.handleStop(event) }
|
||||
return .allow
|
||||
return handleStopSynchronously(event)
|
||||
|
||||
case .notification:
|
||||
Task { @MainActor in self.handleNotification(event) }
|
||||
@@ -142,10 +141,47 @@ public final class HookCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
/// 收工。**阻塞式** —— hubctl 在等"要不要把验收清单塞回去"。
|
||||
///
|
||||
/// ## 兜底方向和审批链路相反,这是本方法最要紧的一条
|
||||
///
|
||||
/// `handlePreToolUseSynchronously` 超时兜底是 **deny**(安全刹车)。
|
||||
/// 这里超时兜底必须是 **allow = 不拦**。写反的话 Hub 一出问题,
|
||||
/// 所有会话就再也收不了工 —— 比"少提醒一次验收"严重得多。
|
||||
///
|
||||
/// 3 秒足够:该不该拦是查内存里的清单,毫秒级就有答案,
|
||||
/// 这 3 秒纯粹是给 MainActor 排队留的余量。
|
||||
private nonisolated func handleStopSynchronously(_ event: HookEvent) -> HookDecision {
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
// 同 handlePreToolUseSynchronously:只有下面的 Task 写、wait 之后才读。
|
||||
nonisolated(unsafe) var result = HookDecision.allow
|
||||
|
||||
Task { @MainActor in
|
||||
result = self.handleStop(event)
|
||||
semaphore.signal()
|
||||
}
|
||||
|
||||
if semaphore.wait(timeout: .now() + HookTimeouts.stopBridge) == .timedOut {
|
||||
HubLog.ipc.error("收工桥接超时 —— 放行(绝不能因为 Hub 慢就让会话停不下来)")
|
||||
return .allow
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func handleStop(_ event: HookEvent) {
|
||||
private func handleStop(_ event: HookEvent) -> HookDecision {
|
||||
store.refresh()
|
||||
|
||||
let path = AcceptanceStore.projectPath(forCWD: event.cwd, projects: projects)
|
||||
|
||||
// 先收自查回答,再决定拦不拦。
|
||||
//
|
||||
// 顺序不能反:上一轮拦下来之后 Claude 的回答就在这条事件的
|
||||
// lastAssistantMessage 里,先收下来,这一轮的清单才是最新的。
|
||||
collectClaims(from: event, projectPath: path)
|
||||
|
||||
let decision = interceptDecision(for: event, projectPath: path)
|
||||
|
||||
let project = projects.projectName(forPath: event.cwd) ?? event.fallbackProjectName
|
||||
let session = store.sessions.first { $0.sessionId == event.sessionId }
|
||||
let title = session?.name ?? project
|
||||
@@ -154,6 +190,13 @@ public final class HookCoordinator {
|
||||
// ("搞定了,来看看" 之类,信息量为零,用户还得切过去才知道干了什么)。
|
||||
let body = Self.summarize(event.lastAssistantMessage) ?? "完成了一轮回复"
|
||||
|
||||
// 被拦下来的这一轮不是"收工",别发完成通知 —— Claude 马上还要再跑一轮,
|
||||
// 现在弹「✅ 完成了」是在骗用户。
|
||||
guard decision.verdict != .deny else {
|
||||
HubLog.app.notice("验收守望:拦下 \(title, privacy: .public) 的收工,要求逐条核对")
|
||||
return decision
|
||||
}
|
||||
|
||||
notifications.post(title: "✅ \(title)", body: body, sessionId: event.sessionId)
|
||||
|
||||
if allowIntrusion(for: event.sessionId) {
|
||||
@@ -164,6 +207,74 @@ public final class HookCoordinator {
|
||||
)
|
||||
)
|
||||
}
|
||||
return decision
|
||||
}
|
||||
|
||||
/// 该不该拦这一次收工。
|
||||
@MainActor
|
||||
private func interceptDecision(for event: HookEvent, projectPath: String) -> HookDecision {
|
||||
// Claude 已经因为 Stop hook 在续跑了 —— 绝不能再拦。
|
||||
//
|
||||
// 这是**第二道**。真正的保证是下面 disarmAndShouldIntercept 里的上膛机制,
|
||||
// 因为这个字段在本机没验证过,不同 CLI 版本给不给都不确定。
|
||||
guard event.stopHookActive != true else { return .allow }
|
||||
|
||||
guard acceptance.disarmAndShouldIntercept(
|
||||
sessionId: event.sessionId, projectPath: projectPath
|
||||
) else { return .allow }
|
||||
|
||||
guard let text = acceptance.injectionText(for: projectPath) else { return .allow }
|
||||
|
||||
return HookDecision(verdict: .deny, reason: text)
|
||||
}
|
||||
|
||||
/// 收下 Claude 上一轮的逐条自查回答。
|
||||
///
|
||||
/// 解析不出来就什么都不做 —— 它很可能是直接去补做遗漏项了(那其实是好事),
|
||||
/// 或者干脆没按格式答。这两种情况都退回旁路复核,不该报错也不该清状态。
|
||||
@MainActor
|
||||
private func collectClaims(from event: HookEvent, projectPath: String) {
|
||||
guard let message = event.lastAssistantMessage,
|
||||
let claims = Self.parseClaims(message), !claims.isEmpty
|
||||
else { return }
|
||||
acceptance.applyClaims(claims, in: projectPath)
|
||||
HubLog.app.notice("验收守望:收下 \(claims.count, privacy: .public) 条自查回答")
|
||||
}
|
||||
|
||||
/// 从 Claude 的回复里挖出自查 JSON。
|
||||
///
|
||||
/// 走 `ModelOutput.extractJSONObject` 而不是直接解析:模型在 JSON 前后
|
||||
/// 各写一段话是常态("我核对了一遍:{…} 需要我继续吗?"),
|
||||
/// 而这里它是在一段长对话的末尾被要求输出 JSON 的,更容易加客套话。
|
||||
nonisolated static func parseClaims(_ message: String) -> [AcceptanceClaim]? {
|
||||
guard let object = ModelOutput.extractJSONObject(message),
|
||||
let raw = object["items"] as? [[String: Any]]
|
||||
else { return nil }
|
||||
|
||||
return raw.compactMap { item in
|
||||
guard let id = item["id"] as? String, !id.isEmpty else { return nil }
|
||||
return AcceptanceClaim(
|
||||
id: id,
|
||||
// done 缺失或不是**真正的布尔**时按没做算。方向是刻意的:
|
||||
// 把含糊不清当成"做完了",等于给虚报开了个免检通道。
|
||||
done: Self.strictlyTrue(item["done"]),
|
||||
evidence: (item["evidence"] as? String) ?? ""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 只有 JSON 里字面写的 `true` 才算数。
|
||||
///
|
||||
/// **不能用 `as? Bool`。** JSONSerialization 把 `true` 和 `1` 都解成 NSNumber,
|
||||
/// 而 NSNumber 到 Bool 的桥接对 `1` 是成立的 —— 于是 `{"done":1}` 会被判成
|
||||
/// 做完了。测试抓到过:模型只要把 done 写成数字就能绕过"含糊按没做算"。
|
||||
///
|
||||
/// CFBoolean 和 CFNumber 是不同的 CF 类型,按类型 ID 判才分得开。
|
||||
nonisolated static func strictlyTrue(_ value: Any?) -> Bool {
|
||||
guard let number = value as? NSNumber,
|
||||
CFGetTypeID(number) == CFBooleanGetTypeID()
|
||||
else { return false }
|
||||
return number.boolValue
|
||||
}
|
||||
|
||||
/// 用户提交了一句话。
|
||||
|
||||
@@ -58,7 +58,20 @@ final class HookDedup: @unchecked Sendable {
|
||||
// 空输入退化成空串是安全的:5 秒内两条空输入吞掉一条没有任何损失。
|
||||
discriminator = event.promptText ?? ""
|
||||
case .stop:
|
||||
discriminator = event.lastAssistantMessage ?? event.stopReason ?? ""
|
||||
// **刻意不去重。**
|
||||
//
|
||||
// 缓存决策只有在决策是事件的**纯函数**时才成立。preToolUse 满足
|
||||
// (同一次工具调用永远同一个结论),stop 不满足 —— 它取决于会变的
|
||||
// 上膛状态:同样内容的两条 stop,一条在用户说话前、一条在之后,
|
||||
// 结论截然相反。
|
||||
//
|
||||
// 实测踩到过:先发一条未上膛的 stop(不拦),再上膛、再发一条内容
|
||||
// 一模一样的 stop —— 第二条被当成副本,复用了"不拦",拦截静默失效。
|
||||
//
|
||||
// 不去重的代价只是重复 stop 会多发一条通知,而重复 stop 本来就
|
||||
// 几乎不会发生(相同命令 Claude Code 自己会去重)。上膛机制自身
|
||||
// 也是安全的:第一条消费掉膛,第二条自然看到未上膛。
|
||||
discriminator = nil
|
||||
case .notification:
|
||||
discriminator = event.message ?? event.notificationType ?? ""
|
||||
case .sessionStart, .sessionEnd, .subagentStop, .preCompact:
|
||||
|
||||
@@ -146,10 +146,25 @@ private func runHook(_ name: String) -> Int32 {
|
||||
|
||||
let result = HubSocketClient.send(
|
||||
event,
|
||||
waitForDecision: kind == .preToolUse,
|
||||
timeout: approvalTimeout
|
||||
// 和服务端共用同一个判断,见 Kind.expectsDecision 上的事故说明。
|
||||
waitForDecision: kind.expectsDecision,
|
||||
// Stop 走独立的短阶梯:它挡在**每一次收工**前面,
|
||||
// 用 75 秒会让 Hub 一卡住用户就永远收不了工。
|
||||
timeout: kind == .stop ? HookTimeouts.stopRead : approvalTimeout
|
||||
)
|
||||
|
||||
// Stop 的应答和 PreToolUse **方向相反**,单独处理。
|
||||
//
|
||||
// PreToolUse 的 .noResponse 要保守拒绝(服务端认为该拦,只是没答上来);
|
||||
// Stop 的一切异常都必须什么都不输出 = 正常收工。写反的话 Hub 一出问题
|
||||
// 所有会话都再也停不下来 —— 比"少提醒一次验收"严重得多。
|
||||
if kind == .stop {
|
||||
if case .decided(let decision) = result, let text = decision.stopOutputJSON() {
|
||||
print(text)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
guard kind == .preToolUse else { return 0 }
|
||||
|
||||
// 排障开关:HUB_DEBUG=1 时把收到的结果打到 stderr。
|
||||
|
||||
@@ -80,6 +80,14 @@ final class SocketRoundTripTests: XCTestCase {
|
||||
}
|
||||
}
|
||||
|
||||
/// 顺带也是 **SIGPIPE 的回归护栏**,别删。
|
||||
///
|
||||
/// 它模拟的是"客户端不等应答就关连接"——旧版 hubctl、被 Ctrl-C 掉的 hook
|
||||
/// 都是这个形态。Stop 改成需要应答之后,服务端会往这个已关闭的连接写决策,
|
||||
/// 而没有忽略 SIGPIPE 的话那一次 write 会**直接杀掉整个进程**。
|
||||
///
|
||||
/// 当时的表现就是这条测试崩在 signal 13 上(不是断言失败,是进程没了)。
|
||||
/// 生产环境里对应的是"新 Hub + 旧 hubctl → 每次收工 Claude Hub 都被杀掉"。
|
||||
func testFireAndForgetEventReachesServer() throws {
|
||||
let received = XCTestExpectation(description: "服务端收到事件")
|
||||
nonisolated(unsafe) var seen: HookEvent?
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import XCTest
|
||||
@testable import HubIPC
|
||||
|
||||
/// Stop hook 的应答格式和超时阶梯。
|
||||
///
|
||||
/// Stop 从「非阻塞、输出被忽略」改成了「阻塞、输出决定 Claude 停不停」,
|
||||
/// 这是全案侵入性最大的改动 —— 它挡在**每一次收工**前面。
|
||||
final class StopHookOutputTests: XCTestCase {
|
||||
|
||||
// MARK: - 输出格式
|
||||
|
||||
/// 绝大多数情况都走这条:什么都不输出 = 正常收工。
|
||||
func testAllowPrintsNothing() {
|
||||
XCTAssertNil(HookDecision.allow.stopOutputJSON())
|
||||
}
|
||||
|
||||
func testDenyBlocksWithTheReason() throws {
|
||||
let decision = HookDecision(verdict: .deny, reason: "还有 3 项没核对")
|
||||
|
||||
let json = try XCTUnwrap(decision.stopOutputJSON())
|
||||
let object = try XCTUnwrap(
|
||||
JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any]
|
||||
)
|
||||
|
||||
XCTAssertEqual(object["decision"] as? String, "block")
|
||||
XCTAssertEqual(object["reason"] as? String, "还有 3 项没核对")
|
||||
}
|
||||
|
||||
/// 没有理由的拦截等于让 Claude 空转一轮 —— 它收不到任何指示,
|
||||
/// 只能再说一遍"做完了",然后又被拦。宁可不拦。
|
||||
func testDenyWithoutAReasonDoesNotBlock() {
|
||||
XCTAssertNil(HookDecision(verdict: .deny, reason: nil).stopOutputJSON())
|
||||
XCTAssertNil(HookDecision(verdict: .deny, reason: "").stopOutputJSON())
|
||||
}
|
||||
|
||||
/// `ask` 是 PreToolUse 的语义,Stop 这条链路上没有对应概念 —— 不该输出。
|
||||
func testAskPrintsNothingOnTheStopChannel() {
|
||||
XCTAssertNil(HookDecision(verdict: .ask, reason: "随便").stopOutputJSON())
|
||||
}
|
||||
|
||||
/// Stop 和 PreToolUse 的输出格式**完全不同**,别想着合并成一个方法。
|
||||
///
|
||||
/// PreToolUse 是 `hookSpecificOutput.permissionDecision`,
|
||||
/// Stop 是顶层的 `decision` / `reason`。用错格式的后果是静默失效:
|
||||
/// Claude 认不出来就当没有输出,也就是照常收工。
|
||||
func testTheTwoChannelsUseDifferentShapes() throws {
|
||||
let decision = HookDecision(verdict: .deny, reason: "拦一下")
|
||||
|
||||
let stop = try XCTUnwrap(decision.stopOutputJSON())
|
||||
let pre = try XCTUnwrap(decision.hookOutputJSON())
|
||||
|
||||
XCTAssertTrue(stop.contains("\"decision\""))
|
||||
XCTAssertFalse(stop.contains("hookSpecificOutput"))
|
||||
XCTAssertTrue(pre.contains("hookSpecificOutput"))
|
||||
XCTAssertFalse(pre.contains("\"decision\":\"block\""))
|
||||
}
|
||||
|
||||
// MARK: - 客户端和服务端必须对「谁在等应答」有共识
|
||||
|
||||
/// **阻塞类事件的集合只能有一个定义。**
|
||||
///
|
||||
/// 这条来自一次真实事故:把 Stop 改成阻塞式时只动了 hubctl(让它等应答),
|
||||
/// 服务端 `HubSocketServer` 里那句 `guard kind == .preToolUse` 原封不动 ——
|
||||
/// 于是 hubctl 等到的永远是连接关闭。
|
||||
///
|
||||
/// 最难受的是它的**症状**:不报错、不超时、hook 10ms 就返回,
|
||||
/// 表现得和"清单里没有待办所以不用拦"一模一样。端到端测了三轮才找到。
|
||||
///
|
||||
/// 现在两边都读 `Kind.expectsDecision`,分歧在结构上不成立。
|
||||
/// 这条测试钉住的是这个集合本身。
|
||||
func testOnlyBlockingKindsExpectADecision() {
|
||||
XCTAssertTrue(HookEvent.Kind.preToolUse.expectsDecision)
|
||||
XCTAssertTrue(HookEvent.Kind.stop.expectsDecision, "Stop 是阻塞的,服务端必须回写")
|
||||
|
||||
for kind in [HookEvent.Kind.notification, .sessionEnd, .userPromptSubmit,
|
||||
.sessionStart, .postToolUse, .subagentStop, .preCompact] {
|
||||
XCTAssertFalse(
|
||||
kind.expectsDecision,
|
||||
"\(kind.rawValue) 不该等应答 —— 等了会让 hubctl 白白挂在 socket 上"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 超时阶梯
|
||||
|
||||
/// **内层必须先到期。**
|
||||
///
|
||||
/// 这条守的是一次真实事故的镜像:审批那边曾经把服务端和客户端的超时设成
|
||||
/// 一样大,结果 hubctl 先判"没收到应答"按 fail-open 放行了一条
|
||||
/// `git push --force`,而服务端其实已经决定拒绝。
|
||||
///
|
||||
/// Stop 这条链路方向相反(超时 = 不拦),但阶梯必须同样严格 ——
|
||||
/// 顺序错了会出现"服务端说要拦、客户端已经超时放过"的窗口,
|
||||
/// 表现为拦截时灵时不灵,极难排查。
|
||||
func testTheStopLadderIsStrictlyIncreasing() {
|
||||
XCTAssertLessThan(HookTimeouts.stopBridge, HookTimeouts.stopRead)
|
||||
XCTAssertLessThan(HookTimeouts.stopRead, TimeInterval(HookTimeouts.stopHookProcess))
|
||||
}
|
||||
|
||||
/// Stop 的阶梯必须**远短于**审批阶梯。
|
||||
///
|
||||
/// 它挡在每一次收工前面。挂到审批那条 75 秒的阶梯上,Hub 一卡住
|
||||
/// 用户每说一句话就要干等 75 秒 —— 那比这个功能本身值钱得多。
|
||||
func testTheStopLadderIsMuchShorterThanTheApprovalLadder() {
|
||||
XCTAssertLessThan(HookTimeouts.stopRead, HookTimeouts.clientRead / 5)
|
||||
XCTAssertLessThanOrEqual(HookTimeouts.stopHookProcess, 15)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import XCTest
|
||||
@testable import HubUI
|
||||
|
||||
/// 解析 Claude 的逐条自查回答。
|
||||
///
|
||||
/// 这段解析的输入是**一段长对话末尾的自由文本**,不是干净的 API 返回。
|
||||
/// 模型在这个位置加客套话、改格式、只答一半都是常态。解析失败是静默的
|
||||
/// (表现为"清单一直没进展"),所以每种畸形都要单独钉住。
|
||||
final class AcceptanceClaimParsingTests: XCTestCase {
|
||||
|
||||
private func parse(_ text: String) -> [AcceptanceClaim]? {
|
||||
HookCoordinator.parseClaims(text)
|
||||
}
|
||||
|
||||
// MARK: - 正常与包装
|
||||
|
||||
func testParsesBareJSON() {
|
||||
let claims = parse(#"{"items":[{"id":"a1","done":true,"evidence":"改了 x.swift"}]}"#)
|
||||
|
||||
XCTAssertEqual(claims?.count, 1)
|
||||
XCTAssertEqual(claims?.first?.id, "a1")
|
||||
XCTAssertEqual(claims?.first?.done, true)
|
||||
XCTAssertEqual(claims?.first?.evidence, "改了 x.swift")
|
||||
}
|
||||
|
||||
func testStripsCodeFence() {
|
||||
let text = """
|
||||
```json
|
||||
{"items":[{"id":"a1","done":true,"evidence":"改了 x.swift"}]}
|
||||
```
|
||||
"""
|
||||
XCTAssertEqual(parse(text)?.count, 1)
|
||||
}
|
||||
|
||||
/// 模型在 JSON 前后写一段话是常态,尤其是在长对话末尾。
|
||||
func testToleratesProseAroundTheJSON() {
|
||||
let text = """
|
||||
我逐条核对了一遍:
|
||||
|
||||
```json
|
||||
{"items":[{"id":"a1","done":false,"evidence":"这条没动"}]}
|
||||
```
|
||||
|
||||
要我现在把没做的补上吗?
|
||||
"""
|
||||
XCTAssertEqual(parse(text)?.first?.done, false)
|
||||
}
|
||||
|
||||
// MARK: - 含糊一律按「没做」算
|
||||
|
||||
/// **`done` 缺失时必须按没做算。**
|
||||
///
|
||||
/// 方向是刻意的:把含糊不清当成"做完了",等于给虚报开了个免检通道 ——
|
||||
/// 模型只要省略这个字段就能让一条要点被标记成已完成。
|
||||
/// 而这个功能存在的全部理由就是不给虚报留口子。
|
||||
func testMissingDoneCountsAsNotDone() {
|
||||
XCTAssertEqual(parse(#"{"items":[{"id":"a1","evidence":"呃"}]}"#)?.first?.done, false)
|
||||
}
|
||||
|
||||
/// `done` 不是布尔(字符串 "true"、数字 1)同样按没做算。
|
||||
func testNonBooleanDoneCountsAsNotDone() {
|
||||
XCTAssertEqual(parse(#"{"items":[{"id":"a1","done":"true"}]}"#)?.first?.done, false)
|
||||
XCTAssertEqual(parse(#"{"items":[{"id":"a1","done":1}]}"#)?.first?.done, false)
|
||||
}
|
||||
|
||||
// MARK: - 畸形输入不能崩也不能误判
|
||||
|
||||
/// 没有 id 的条目对不上号,只能丢掉 —— 但不能连累同一批里正常的那些。
|
||||
func testSkipsEntriesWithoutAnID() {
|
||||
let claims = parse(#"{"items":[{"done":true},{"id":"","done":true},{"id":"ok","done":true}]}"#)
|
||||
|
||||
XCTAssertEqual(claims?.map(\.id), ["ok"])
|
||||
}
|
||||
|
||||
/// 完全不是 JSON —— 它多半是直接去补做遗漏项了(那其实是好事)。
|
||||
/// 返回 nil 让调用方什么都别做,退回旁路复核。
|
||||
func testProseOnlyReplyYieldsNil() {
|
||||
XCTAssertNil(parse("好的,我把漏掉的两条补上了,现在都跑通了。"))
|
||||
}
|
||||
|
||||
func testMissingItemsKeyYieldsNil() {
|
||||
XCTAssertNil(parse(#"{"points":[{"id":"a1"}]}"#))
|
||||
}
|
||||
|
||||
func testEmptyReplyYieldsNil() {
|
||||
XCTAssertNil(parse(""))
|
||||
}
|
||||
|
||||
/// 空数组是"跑通了但一条都没答" —— 和解析失败不一样,
|
||||
/// 但调用方对两者都是什么都不做,所以这里只要求不崩。
|
||||
func testEmptyItemsArrayIsHandled() {
|
||||
XCTAssertEqual(parse(#"{"items":[]}"#)?.isEmpty, true)
|
||||
}
|
||||
|
||||
func testMissingEvidenceBecomesEmptyString() {
|
||||
XCTAssertEqual(parse(#"{"items":[{"id":"a1","done":true}]}"#)?.first?.evidence, "")
|
||||
}
|
||||
}
|
||||
@@ -62,8 +62,8 @@ final class HookDedupTests: XCTestCase {
|
||||
|
||||
func testDifferentSessionsAreNotDuplicates() throws {
|
||||
let dedup = HookDedup()
|
||||
let a = try XCTUnwrap(HookDedup.key(for: event(.stop, session: "a", lastMessage: "好了")))
|
||||
let b = try XCTUnwrap(HookDedup.key(for: event(.stop, session: "b", lastMessage: "好了")))
|
||||
let a = try XCTUnwrap(HookDedup.key(for: event(.sessionEnd, session: "a")))
|
||||
let b = try XCTUnwrap(HookDedup.key(for: event(.sessionEnd, session: "b")))
|
||||
|
||||
XCTAssertNotEqual(a, b)
|
||||
guard case .first = dedup.begin(a, waitForDecision: false) else {
|
||||
@@ -74,6 +74,23 @@ final class HookDedupTests: XCTestCase {
|
||||
}
|
||||
}
|
||||
|
||||
/// **收工事件刻意不去重。**
|
||||
///
|
||||
/// 缓存决策只有在决策是事件的**纯函数**时才成立。preToolUse 满足
|
||||
/// (同一次工具调用永远同一个结论);stop 不满足 —— 它取决于会变的上膛状态。
|
||||
///
|
||||
/// 这条来自一次真实的端到端失败:先发一条未上膛的 stop(不拦),
|
||||
/// 再上膛、再发一条内容一模一样的 stop —— 第二条被当成副本,
|
||||
/// 复用了"不拦"的结论,**拦截静默失效**。
|
||||
///
|
||||
/// 把 discriminator 改回 `lastAssistantMessage`,这条必须变红。
|
||||
func testStopIsDeliberatelyNotDeduplicated() {
|
||||
XCTAssertNil(
|
||||
HookDedup.key(for: event(.stop, lastMessage: "做完了")),
|
||||
"stop 的结论依赖上膛状态,不是事件的纯函数,不能缓存"
|
||||
)
|
||||
}
|
||||
|
||||
/// 时间窗外的同样内容不算重复。
|
||||
///
|
||||
/// 用户隔一会儿又发一遍「继续」是正常的,那是**两次**真实输入,
|
||||
@@ -165,8 +182,8 @@ final class HookDedupTests: XCTestCase {
|
||||
/// 算不出来(返回 nil)意味着那一类完全不去重 —— 加新 kind 时最容易漏的
|
||||
/// 就是这里,而漏了不会报错,只会在某天变成"通知弹两遍"。
|
||||
func testEveryNonToolKindProducesAKey() {
|
||||
let toolKinds: Set<HookEvent.Kind> = [.preToolUse, .postToolUse]
|
||||
for kind in HookChannelMonitor.expected where !toolKinds.contains(kind) {
|
||||
let exempt: Set<HookEvent.Kind> = [.preToolUse, .postToolUse, .stop]
|
||||
for kind in HookChannelMonitor.expected where !exempt.contains(kind) {
|
||||
XCTAssertNotNil(
|
||||
HookDedup.key(for: event(kind)),
|
||||
"\(kind.rawValue) 算不出去重 key —— 加新 kind 时漏了 HookDedup.key"
|
||||
|
||||
@@ -82,30 +82,34 @@ if settings.get("autoMemoryDirectory") != "./.claude/memory":
|
||||
changed.append("补上 autoMemoryDirectory")
|
||||
|
||||
# ── hook ──────────────────────────────────────────────────────
|
||||
# (事件, hubctl 子命令, 阻塞时的 timeout;None = 非阻塞)
|
||||
#
|
||||
# Stop 的 10 秒和 PreToolUse 的 90 秒必须和 HubIPC/Timeouts.swift 里的阶梯一致。
|
||||
# Stop 挡在每一次收工前面,用 90 秒会让 Hub 一卡住用户就干等。
|
||||
HOOKS = [
|
||||
("SessionStart", "sessionstart", False),
|
||||
("UserPromptSubmit", "userprompt", False),
|
||||
("PreToolUse", "pretool", True),
|
||||
("PostToolUse", "posttool", False),
|
||||
("Notification", "notification", False),
|
||||
("Stop", "stop", False),
|
||||
("SubagentStop", "subagentstop", False),
|
||||
("PreCompact", "precompact", False),
|
||||
("SessionEnd", "sessionend", False),
|
||||
("SessionStart", "sessionstart", None),
|
||||
("UserPromptSubmit", "userprompt", None),
|
||||
("PreToolUse", "pretool", 90),
|
||||
("PostToolUse", "posttool", None),
|
||||
("Notification", "notification", None),
|
||||
("Stop", "stop", 10),
|
||||
("SubagentStop", "subagentstop", None),
|
||||
("PreCompact", "precompact", None),
|
||||
("SessionEnd", "sessionend", None),
|
||||
]
|
||||
|
||||
if with_hooks:
|
||||
hooks = settings.setdefault("hooks", {})
|
||||
fixed = 0
|
||||
for event, argument, blocking in HOOKS:
|
||||
for event, argument, timeout in HOOKS:
|
||||
command = f"{hubctl} hook {argument}"
|
||||
entries = [
|
||||
entry for entry in (hooks.get(event) or [])
|
||||
if not any("hubctl hook" in h.get("command", "") for h in entry.get("hooks", []))
|
||||
]
|
||||
hook = {"type": "command", "command": command}
|
||||
if blocking:
|
||||
hook["timeout"] = 90
|
||||
if timeout is not None:
|
||||
hook["timeout"] = timeout
|
||||
else:
|
||||
hook["async"] = True
|
||||
entries.append({"hooks": [hook]})
|
||||
|
||||
@@ -79,7 +79,7 @@ def strip_legacy(entries):
|
||||
kept.append(entry)
|
||||
return kept
|
||||
|
||||
def install(event, argument, *, blocking):
|
||||
def install(event, argument, *, blocking, timeout=90):
|
||||
entries = strip_legacy(hooks.get(event))
|
||||
hook = {"type": "command", "command": f"{hubctl} hook {argument}"}
|
||||
if blocking:
|
||||
@@ -88,7 +88,7 @@ def install(event, argument, *, blocking):
|
||||
# 90 秒是超时阶梯(HookTimeouts)的最外层,必须大于 hubctl 的 75 秒读超时:
|
||||
# 如果 Claude 在 hubctl 输出拒绝之前就把它杀掉,那等同于没有输出,
|
||||
# 也就是**放行** —— 正好是安全默认值的反面。
|
||||
hook["timeout"] = 90
|
||||
hook["timeout"] = timeout
|
||||
else:
|
||||
# async: true 很关键 —— 旧配置是 false 且 hook 里同步 afplay,
|
||||
# 导致每轮回复结束都被阻塞。
|
||||
@@ -110,7 +110,12 @@ install("UserPromptSubmit", "userprompt", blocking=False)
|
||||
install("PreToolUse", "pretool", blocking=True)
|
||||
install("PostToolUse", "posttool", blocking=False)
|
||||
install("Notification", "notification", blocking=False)
|
||||
install("Stop", "stop", blocking=False)
|
||||
# Stop 是阻塞的:Hub 要在这里判断该不该把验收清单塞回去。
|
||||
#
|
||||
# 10 秒而不是 90 —— 它挡在**每一次收工**前面,Hub 一卡住用户就干等。
|
||||
# 判断本身是查内存,毫秒级;这 10 秒纯粹是异常时的上界。
|
||||
# 超时被杀掉等同于没有输出,也就是正常收工,方向是安全的。
|
||||
install("Stop", "stop", blocking=True, timeout=10)
|
||||
install("SubagentStop", "subagentstop", blocking=False)
|
||||
install("PreCompact", "precompact", blocking=False)
|
||||
install("SessionEnd", "sessionend", blocking=False)
|
||||
|
||||
Reference in new issue
Block a user