From f3ad09cfa39273fc8f17e070804b5f824e4a9e52 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Sun, 26 Jul 2026 13:52:02 -0700 Subject: [PATCH 01/13] test: reproduce hooks feed hangs for incompatible agents --- cmuxTests/CLIGenericHookPersistenceTests.swift | 18 +++++++----------- cmuxTests/CLIHookNoResponseTests.swift | 2 ++ cmuxTests/FeedEventClassificationTests.swift | 17 +++++++++++++++-- 3 files changed, 24 insertions(+), 13 deletions(-) diff --git a/cmuxTests/CLIGenericHookPersistenceTests.swift b/cmuxTests/CLIGenericHookPersistenceTests.swift index 7aa3f71a830c..dc56f46a622b 100644 --- a/cmuxTests/CLIGenericHookPersistenceTests.swift +++ b/cmuxTests/CLIGenericHookPersistenceTests.swift @@ -905,16 +905,13 @@ extension CLINotifyProcessIntegrationRegressionTests { "Antigravity hooks must still dispatch when agy does not preserve CMUX_SURFACE_ID, saw \(allCommands)" ) - let preToolUse = try XCTUnwrap(cmuxGroup["PreToolUse"] as? [[String: Any]]) - let preToolCommands = preToolUse - .compactMap { $0["hooks"] as? [[String: Any]] } - .flatMap { $0 } - XCTAssertTrue( - preToolCommands.contains { - ($0["command"] as? String)?.contains("hooks feed --source antigravity --event PreToolUse") == true - && ($0["timeout"] as? Int) == 120 - }, - "Expected Antigravity PreToolUse feed hook with second-based timeout, saw \(preToolCommands)" + XCTAssertNil( + cmuxGroup["PreToolUse"], + "Antigravity rejects PreToolUse hook output, so cmux must not install a tool-gating hook" + ) + XCTAssertNil( + cmuxGroup["PostToolUse"], + "Antigravity tool lifecycle hooks must not be installed when they cannot safely fail neutral" ) let stop = try XCTUnwrap(cmuxGroup["Stop"] as? [[String: Any]]) @@ -929,7 +926,6 @@ extension CLINotifyProcessIntegrationRegressionTests { XCTAssertNotNil(cmuxGroup["SessionEnd"]) XCTAssertNotNil(cmuxGroup["turn-completion"]) XCTAssertNotNil(cmuxGroup["Notification"]) - XCTAssertNotNil(cmuxGroup["PostToolUse"]) } func testKiroHookInstallUsesAgentConfigShapeAndPreservesDenyExit() throws { diff --git a/cmuxTests/CLIHookNoResponseTests.swift b/cmuxTests/CLIHookNoResponseTests.swift index 36991c5c1a41..3b417776b4f2 100644 --- a/cmuxTests/CLIHookNoResponseTests.swift +++ b/cmuxTests/CLIHookNoResponseTests.swift @@ -52,7 +52,9 @@ struct CLIHookNoResponseTests { FeedHookCase(source: "gemini", event: "PreToolUse", toolName: "read", pidKey: "CMUX_GEMINI_PID"), FeedHookCase(source: "kiro", event: "postToolUse", toolName: "fs_write", pidKey: "CMUX_KIRO_PID"), FeedHookCase(source: "hermes-agent", event: "pre_tool_call", toolName: "terminal", pidKey: "CMUX_HERMES_AGENT_PID"), + FeedHookCase(source: "antigravity", event: "PreToolUse", toolName: "Bash", pidKey: "CMUX_ANTIGRAVITY_PID"), FeedHookCase(source: "antigravity", event: "PostToolUse", toolName: "run_command", pidKey: "CMUX_ANTIGRAVITY_PID"), + FeedHookCase(source: "cursor", event: "beforeShellExecution", toolName: "Bash", pidKey: "CMUX_CURSOR_PID"), ] for testCase in cases { diff --git a/cmuxTests/FeedEventClassificationTests.swift b/cmuxTests/FeedEventClassificationTests.swift index 281bd887cfb7..c42236fee0d2 100644 --- a/cmuxTests/FeedEventClassificationTests.swift +++ b/cmuxTests/FeedEventClassificationTests.swift @@ -146,11 +146,24 @@ struct FeedEventClassificationTests { } } - /// Unknown source + unknown event is safe by default. - @Test func unknownSourceUnknownEventIsSafe() { + /// Unknown sources must stay non-blocking even when they emit a familiar + /// pre-tool event for a side-effecting tool. A new integration must opt in + /// to decision semantics explicitly before it can stall an agent process. + @Test func unknownSourcePreToolUseIsSafeByDefault() { + let preTool = classify("totally-new-agent", "PreToolUse", tool: "Bash") + #expect(preTool.name == "PreToolUse") + #expect(preTool.actionable == false) + #expect(classify("totally-new-agent", "some_future_event", tool: "Bash").actionable == false) } + /// Antigravity and Cursor tool-start hooks are telemetry, not approval + /// requests. Neither integration has a safe blocking bridge contract. + @Test func incompatibleToolLifecycleHooksStayTelemetry() { + #expect(classify("antigravity", "PreToolUse", tool: "Bash").actionable == false) + #expect(classify("cursor", "beforeShellExecution", tool: "Bash").actionable == false) + } + // MARK: Kiro (camelCase events, no dedicated approval event) /// Kiro has no dedicated approval event, so its `preToolUse` escalates From e19d9169e79b0eb8c79edc74a225a27deb7fa0dc Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Sun, 26 Jul 2026 13:56:54 -0700 Subject: [PATCH 02/13] fix: make unsupported feed hooks fail neutral --- CLI/CMUXCLI+AgentHookCatalog.swift | 3 +- CLI/CMUXCLI+AgentHookDefinitions.swift | 10 ++-- CLI/FeedEventClassifier.swift | 56 ++++++++++++-------- CLI/cmux.swift | 25 +++------ cmuxTests/FeedEventClassificationTests.swift | 13 +++-- 5 files changed, 55 insertions(+), 52 deletions(-) diff --git a/CLI/CMUXCLI+AgentHookCatalog.swift b/CLI/CMUXCLI+AgentHookCatalog.swift index 62024808e9f6..e3a39bf4ff58 100644 --- a/CLI/CMUXCLI+AgentHookCatalog.swift +++ b/CLI/CMUXCLI+AgentHookCatalog.swift @@ -142,8 +142,7 @@ extension CMUXCLI { .init(agentEvent: "SessionEnd", cmuxSubcommand: "session-end"), ], aliases: ["agy"], - sessionEndIsTurnBoundary: true, - feedHookEvents: ["PreToolUse", "PostToolUse"] + sessionEndIsTurnBoundary: true ), AgentHookDef( name: "rovodev", displayName: "Rovo Dev", statusKey: "rovodev", diff --git a/CLI/CMUXCLI+AgentHookDefinitions.swift b/CLI/CMUXCLI+AgentHookDefinitions.swift index e3c314cdc84c..9fd8de4154ff 100644 --- a/CLI/CMUXCLI+AgentHookDefinitions.swift +++ b/CLI/CMUXCLI+AgentHookDefinitions.swift @@ -4,6 +4,10 @@ import Foundation extension CMUXCLI { // MARK: - Generic agent hook system + // The client deadline must fire before the generated agent-hook timeout. + static let feedHookProcessTimeoutMilliseconds = 120_000 + static let feedHookResponseTimeoutSeconds = Double(feedHookProcessTimeoutMilliseconds) / 1_000 - 2 + static let feedHookDecisionWaitSeconds = feedHookResponseTimeoutSeconds - 3 /// Configuration for a hook-based agent integration. struct AgentHookDef { let name: String // CLI name: "cursor", "gemini", etc. @@ -40,11 +44,7 @@ extension CMUXCLI { /// separate `session-finalize` subcommand / ``AgentHookAction/sessionFinalize`` /// action, which performs the destructive cleanup this flag suppresses. let sessionEndIsTurnBoundary: Bool - /// Feed-hook events. Each entry installs a second hook for - /// `agentEvent` that invokes `cmux hooks feed --source ` - /// with a 120s timeout so the socket reply wait doesn't trip the - /// agent's default hook timeout when the user takes time to - /// approve/deny a permission / plan / question. + /// Events that install a `cmux hooks feed --source ` bridge. let feedHookEvents: [String] let postInstallAction: PostInstallAction? /// Optional CLI note printed after a successful install (or diff --git a/CLI/FeedEventClassifier.swift b/CLI/FeedEventClassifier.swift index 12b68a19b18e..db87cc661927 100644 --- a/CLI/FeedEventClassifier.swift +++ b/CLI/FeedEventClassifier.swift @@ -25,7 +25,7 @@ struct FeedEventClassifier { /// /// - Parameters: /// - source: The agent id that emitted the event (`claude`, `codex`, - /// `hermes-agent`, …). Unregistered sources use the generic table. + /// `hermes-agent`, …). Unregistered sources are telemetry-only. /// - event: The agent's raw hook event name. /// - toolName: The tool the event refers to, used only for the two /// tool-dependent semantics. @@ -83,14 +83,14 @@ struct FeedEventClassifier { case unknown } - /// Resolves the semantic for a `(source, event)` pair. A registered - /// source uses its own table (unmatched events fall to ``FeedEventSemantic/unknown``); - /// unregistered sources use the generic table. + /// Resolves the semantic for a `(source, event)` pair. Only registered + /// sources can opt in to blocking decisions. Unregistered sources retain + /// useful lifecycle names but always use telemetry-only semantics. private static func feedEventSemantic( source: String, event: String ) -> FeedEventSemantic { - let table = feedEventSemanticRegistry[source] ?? genericFeedEventSemantics + let table = feedEventSemanticRegistry[source] ?? telemetryOnlyFeedEventSemantics return table[event] ?? .unknown } @@ -160,14 +160,10 @@ struct FeedEventClassifier { /// for that agent's `(event) -> semantic` mapping; events absent here /// resolve to ``FeedEventSemantic/unknown``. /// - /// The key distinction the registry encodes: agents with a *dedicated* - /// approval event (Claude `PermissionRequest`, Codex `PermissionRequest`, - /// Hermes `pre_approval_request`) classify their pre-tool event as - /// ``FeedEventSemantic/toolStart`` (always telemetry). Agents whose only - /// signal is the pre-tool event (gemini, copilot, …, handled by - /// ``genericFeedEventSemantics``) use - /// ``FeedEventSemantic/toolStartMaybeApproval`` so side-effecting tools - /// still escalate. Conflating the two is the bug behind #4985. + /// Blocking is an explicit capability: a source must be registered with + /// ``FeedEventSemantic/toolStartMaybeApproval`` or + /// ``FeedEventSemantic/approvalRequest``. New and incompatible sources + /// fail neutral instead of inheriting a synchronous approval wait. private static let feedEventSemanticRegistry: [String: [String: FeedEventSemantic]] = [ "claude": [ "PermissionRequest": .approvalRequest, @@ -231,14 +227,14 @@ struct FeedEventClassifier { "on_session_end": .sessionEnd, "on_session_finalize": .sessionEnd, ], + // Gemini CLI consumes the generic PreToolUse decision schema and has + // no separate approval event, so it deliberately opts in to blocking. + "gemini": approvalCapableFeedEventSemantics, // Kiro emits camelCase hook events and has no dedicated approval // event, so its pre-tool event escalates side-effecting tools to an // approval (resolved against the kiro tool aliases in // ``isSideEffectingTool``). Registering kiro explicitly is required: - // its lowercase event names are absent from - // ``genericFeedEventSemantics`` and would otherwise resolve to - // ``FeedEventSemantic/unknown`` (non-actionable), silently dropping - // every kiro approval. + // its lowercase event names are absent from the shared tables. "kiro": [ "preToolUse": .toolStartMaybeApproval, "postToolUse": .toolEnd, @@ -248,10 +244,10 @@ struct FeedEventClassifier { ], ] - /// Fallback table for agents without a dedicated entry in - /// ``feedEventSemanticRegistry``. These agents expose only a pre-tool - /// event, so it carries ``FeedEventSemantic/toolStartMaybeApproval``. - private static let genericFeedEventSemantics: [String: FeedEventSemantic] = [ + /// Shared event spellings for sources that have a verified blocking + /// decision contract. Registration is required; this table is never the + /// fallback for an unknown source. + private static let approvalCapableFeedEventSemantics: [String: FeedEventSemantic] = [ "PreToolUse": .toolStartMaybeApproval, "beforeShellExecution": .toolStartMaybeApproval, "PermissionRequest": .approvalRequest, @@ -267,6 +263,24 @@ struct FeedEventClassifier { "Notification": .statusNotification, ] + /// Safe fallback for unregistered sources. Familiar event names preserve + /// Feed telemetry classification, but none can create a blocking request. + private static let telemetryOnlyFeedEventSemantics: [String: FeedEventSemantic] = [ + "PreToolUse": .toolStart, + "beforeShellExecution": .toolStart, + "PermissionRequest": .toolStart, + "PostToolUse": .toolEnd, + "PreCompact": .preCompact, + "PostCompact": .postCompact, + "UserPromptSubmit": .promptSubmit, + "SessionStart": .sessionStart, + "SessionEnd": .sessionEnd, + "Stop": .response, + "SubagentStart": .subagentStart, + "SubagentStop": .subagentResponse, + "Notification": .statusNotification, + ] + /// Tools that mutate state and deserve a user-visible approve/ /// deny prompt in Feed. Keyed on the canonical tool names Claude, /// Codex, and similar agents emit. Read-only tools (Read, Grep, diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 8ab455845efb..3bb753586ecb 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -28189,7 +28189,7 @@ struct CMUXCLI { if def.name == "codex" { return 5_000 } - return 120_000 + return Self.feedHookProcessTimeoutMilliseconds } private static func timeoutSecondsFromMilliseconds(_ timeoutMs: Int) -> Int { @@ -33934,17 +33934,17 @@ export default CMUXSessionRestore; ?? "\(source)-\(sessionId)-\(rawEvent)-\(toolName)-\(Int(Date().timeIntervalSince1970 * 1000))" eventDict["_opencode_request_id"] = requestId - // Sync. For actionable events we block up to 120s waiting - // for the user's Feed click; the hook's stdout is then a + // Sync. For actionable events we wait for the user's Feed click; + // the hook's stdout is then a // proper hookSpecificOutput that Claude honors directly // (no keystroke injection, no guessing the TUI layout). // If the user doesn't click in time the hook emits {} // and Claude falls back to its native TUI prompt. // - // Wait is capped at 120s and the wrapper's hook timeout - // is 125s so the socket always returns before Claude - // would kill the hook subprocess itself. - let waitTimeout: Double = isActionable ? 120 : 0 + // The response deadline stays below the generated 120s process + // timeout, so a stalled daemon still returns neutral output before + // the agent kills (and may deny) the hook subprocess. + let waitTimeout = isActionable ? Self.feedHookDecisionWaitSeconds : 0 let params: [String: Any] = [ "event": eventDict, "wait_timeout_seconds": waitTimeout, @@ -34004,7 +34004,7 @@ export default CMUXSessionRestore; do { response = try activeClient.send( command: line, - responseTimeout: waitTimeout + 5 + responseTimeout: Self.feedHookResponseTimeoutSeconds ) } catch { print("{}") @@ -34366,15 +34366,6 @@ export default CMUXSessionRestore; } return "{}" } - if source == "antigravity" { - let reason = mode == "deny" - ? "User denied permission via cmux Feed." - : "User approved via cmux Feed." - return encode([ - "decision": mode == "deny" ? "deny" : "allow", - "reason": reason, - ]) - } if mode == "deny" { return encode(nonClaudePreToolDecision( permission: "deny", diff --git a/cmuxTests/FeedEventClassificationTests.swift b/cmuxTests/FeedEventClassificationTests.swift index c42236fee0d2..a8df107c6fb8 100644 --- a/cmuxTests/FeedEventClassificationTests.swift +++ b/cmuxTests/FeedEventClassificationTests.swift @@ -90,22 +90,21 @@ struct FeedEventClassificationTests { } } - // MARK: Generic agents without a dedicated approval event + // MARK: Explicit approval-capable agents - /// Agents whose only signal is `PreToolUse` (gemini, copilot, …) still - /// escalate side-effecting tools to an approval — that path is correct - /// and must be preserved. - @Test func genericPreToolUseEscalatesSideEffectingTools() { + /// Gemini has a verified PreToolUse decision contract and explicitly + /// opts in to escalating side-effecting tools. + @Test func geminiPreToolUseEscalatesSideEffectingTools() { #expect(classify("gemini", "PreToolUse", tool: "Bash").name == "PermissionRequest") #expect(classify("gemini", "PreToolUse", tool: "Bash").actionable == true) #expect(classify("gemini", "PreToolUse", tool: "Read").actionable == false) } - /// Even on the maybe-approval (generic pre-tool) path, the two dedicated + /// Even on the maybe-approval pre-tool path, the two dedicated /// approval tool names route to their own wire kinds — they are never /// collapsed into a generic `PermissionRequest`. Guards the shared /// `dedicatedApprovalEvent(for:)` branch inside `.toolStartMaybeApproval`. - @Test func genericPreToolUseRoutesDedicatedApprovalTools() { + @Test func geminiPreToolUseRoutesDedicatedApprovalTools() { #expect(classify("gemini", "PreToolUse", tool: "ExitPlanMode").name == "ExitPlanMode") #expect(classify("gemini", "PreToolUse", tool: "ExitPlanMode").actionable == true) #expect(classify("gemini", "PreToolUse", tool: "AskUserQuestion").name == "AskUserQuestion") From 9375d80d84c24b0e269f8606a738f2982e434b9b Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Sun, 26 Jul 2026 14:20:14 -0700 Subject: [PATCH 03/13] fix: bound feed hook socket setup --- CLI/CMUXCLI+AgentHookDefinitions.swift | 4 ++-- CLI/cmux.swift | 15 +++++++++++++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/CLI/CMUXCLI+AgentHookDefinitions.swift b/CLI/CMUXCLI+AgentHookDefinitions.swift index 9fd8de4154ff..a28a55984673 100644 --- a/CLI/CMUXCLI+AgentHookDefinitions.swift +++ b/CLI/CMUXCLI+AgentHookDefinitions.swift @@ -6,8 +6,8 @@ extension CMUXCLI { // The client deadline must fire before the generated agent-hook timeout. static let feedHookProcessTimeoutMilliseconds = 120_000 - static let feedHookResponseTimeoutSeconds = Double(feedHookProcessTimeoutMilliseconds) / 1_000 - 2 - static let feedHookDecisionWaitSeconds = feedHookResponseTimeoutSeconds - 3 + static let feedHookClientDeadlineSeconds = Double(feedHookProcessTimeoutMilliseconds) / 1_000 - 2 + static let feedHookDecisionWaitSeconds = feedHookClientDeadlineSeconds - 3 /// Configuration for a hook-based agent integration. struct AgentHookDef { let name: String // CLI name: "cursor", "gemini", etc. diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 3bb753586ecb..6e161f6e0a47 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -33976,6 +33976,16 @@ export default CMUXSessionRestore; var ownedClient: SocketClient? defer { ownedClient?.close() } + let clientDeadline = Date().addingTimeInterval(Self.feedHookClientDeadlineSeconds) + + func remainingResponseTime() throws -> TimeInterval { + let remaining = clientDeadline.timeIntervalSinceNow + guard remaining > 0 else { + throw CLIError(message: "Feed hook response deadline exceeded") + } + return remaining + } + let activeClient: SocketClient if let client { activeClient = client @@ -33986,7 +33996,8 @@ export default CMUXSessionRestore; try authenticateClientIfNeeded( feedClient, explicitPassword: socketPassword, - socketPath: socketPath + socketPath: socketPath, + responseTimeout: try remainingResponseTime() ) } catch { feedClient.close() @@ -34004,7 +34015,7 @@ export default CMUXSessionRestore; do { response = try activeClient.send( command: line, - responseTimeout: Self.feedHookResponseTimeoutSeconds + responseTimeout: try remainingResponseTime() ) } catch { print("{}") From 530329f9c2c7ee4daf6b72a36827b417a0cd0ec0 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Sun, 26 Jul 2026 14:24:54 -0700 Subject: [PATCH 04/13] fix: enforce feed hook connection deadline --- CLI/cmux.swift | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 6e161f6e0a47..5d9a672d9854 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -1930,6 +1930,31 @@ final class SocketClient { } } + /// Connects using the remaining absolute deadline for both the socket + /// operation timeout and the existing short retry window. + func connect(deadline: Date) throws { + if socketFD >= 0 { return } + let retryDeadline = min( + deadline, + Date().addingTimeInterval(Self.connectRetryDeadline) + ) + while true { + do { + let remaining = deadline.timeIntervalSinceNow + guard remaining > 0 else { + throw CLIError(message: "Socket connection deadline exceeded") + } + try connectOnce(responseTimeout: remaining) + return + } catch { + guard Self.shouldRetryConnect(error), Date() < retryDeadline else { + throw error + } + usleep(Self.connectRetryIntervalMicros) + } + } + } + func connectWithoutRetry(responseTimeout: TimeInterval? = nil) throws { if socketFD >= 0 { return } try connectOnce(responseTimeout: responseTimeout) @@ -33992,7 +34017,7 @@ export default CMUXSessionRestore; } else if let socketPath { let feedClient = SocketClient(path: socketPath) do { - try feedClient.connect() + try feedClient.connect(deadline: clientDeadline) try authenticateClientIfNeeded( feedClient, explicitPassword: socketPassword, From 5b3c3337fe9c973d7d732cea3cb34c0b0d17e915 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Sun, 26 Jul 2026 14:27:44 -0700 Subject: [PATCH 05/13] fix: bound relay setup by hook deadline --- CLI/cmux.swift | 123 +++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 109 insertions(+), 14 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 5d9a672d9854..7cf86098c5eb 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -1944,7 +1944,7 @@ final class SocketClient { guard remaining > 0 else { throw CLIError(message: "Socket connection deadline exceeded") } - try connectOnce(responseTimeout: remaining) + try connectOnce(responseTimeout: remaining, deadline: deadline) return } catch { guard Self.shouldRetryConnect(error), Date() < retryDeadline else { @@ -2105,9 +2105,16 @@ final class SocketClient { } } - private func connectOnce(responseTimeout: TimeInterval? = nil) throws { + private func connectOnce( + responseTimeout: TimeInterval? = nil, + deadline: Date? = nil + ) throws { if let relayEndpoint { - try connectToRelay(endpoint: relayEndpoint, responseTimeout: responseTimeout) + try connectToRelay( + endpoint: relayEndpoint, + responseTimeout: responseTimeout, + deadline: deadline + ) return } @@ -2237,9 +2244,16 @@ final class SocketClient { data.map { String(format: "%02x", $0) }.joined() } - private func connectToRelay(endpoint: RelayEndpoint, responseTimeout: TimeInterval? = nil) throws { + private func connectToRelay( + endpoint: RelayEndpoint, + responseTimeout: TimeInterval? = nil, + deadline: Date? = nil + ) throws { let credentials = try Self.relayCredentials(for: endpoint) - let timeout = responseTimeout ?? Self.responseTimeoutSeconds + let timeout = try remainingSocketTimeout( + responseTimeout: responseTimeout, + deadline: deadline + ) socketFD = socket(AF_INET, SOCK_STREAM, 0) guard socketFD >= 0 else { @@ -2267,13 +2281,18 @@ final class SocketClient { throw CLIError(message: "Invalid relay endpoint \(endpoint.host):\(endpoint.port)") } - let result = withUnsafePointer(to: &address) { pointer in - pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPointer in - Darwin.connect(socketFD, sockaddrPointer, socklen_t(MemoryLayout.stride)) + let connectErrno: Int32 + if let deadline { + connectErrno = connectRelaySocket(address: &address, deadline: deadline) + } else { + let result = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPointer in + Darwin.connect(socketFD, sockaddrPointer, socklen_t(MemoryLayout.stride)) + } } + connectErrno = result == 0 ? 0 : errno } - if result != 0 { - let connectErrno = errno + if connectErrno != 0 { close() throw CLIError( message: "Failed to connect to relay at \(endpoint.host):\(endpoint.port) (\(String(cString: strerror(connectErrno))), errno \(connectErrno))" @@ -2281,15 +2300,70 @@ final class SocketClient { } do { - try authenticateRelay(credentials: credentials, responseTimeout: timeout) + try authenticateRelay( + credentials: credentials, + responseTimeout: timeout, + deadline: deadline + ) } catch { close() throw error } } - private func authenticateRelay(credentials: RelayCredentials, responseTimeout: TimeInterval) throws { - let challengeLine = try readLine(responseTimeout: responseTimeout) + private func connectRelaySocket(address: inout sockaddr_in, deadline: Date) -> Int32 { + let originalFlags = fcntl(socketFD, F_GETFL, 0) + guard originalFlags >= 0 else { return errno } + guard fcntl(socketFD, F_SETFL, originalFlags | O_NONBLOCK) == 0 else { + return errno + } + defer { + if socketFD >= 0 { + _ = fcntl(socketFD, F_SETFL, originalFlags) + } + } + + let result = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPointer in + Darwin.connect(socketFD, sockaddrPointer, socklen_t(MemoryLayout.stride)) + } + } + if result == 0 { return 0 } + let connectErrno = errno + guard connectErrno == EINPROGRESS || connectErrno == EALREADY else { + return connectErrno + } + + while true { + let remaining = deadline.timeIntervalSinceNow + guard remaining > 0 else { return ETIMEDOUT } + var descriptor = pollfd(fd: socketFD, events: Int16(POLLOUT), revents: 0) + let timeoutMillis = min(max(Int(ceil(remaining * 1_000)), 0), Int(Int32.max)) + let ready = Darwin.poll(&descriptor, 1, Int32(timeoutMillis)) + if ready < 0 { + if errno == EINTR { continue } + return errno + } + guard ready > 0 else { return ETIMEDOUT } + + var socketError: Int32 = 0 + var socketErrorLength = socklen_t(MemoryLayout.size) + let errorResult = withUnsafeMutablePointer(to: &socketError) { pointer in + getsockopt(socketFD, SOL_SOCKET, SO_ERROR, pointer, &socketErrorLength) + } + return errorResult == 0 ? socketError : errno + } + } + + private func authenticateRelay( + credentials: RelayCredentials, + responseTimeout: TimeInterval, + deadline: Date? = nil + ) throws { + let challengeLine = try readLine(responseTimeout: remainingSocketTimeout( + responseTimeout: responseTimeout, + deadline: deadline + )) guard let challengeData = challengeLine.data(using: .utf8), let challenge = try JSONSerialization.jsonObject(with: challengeData) as? [String: Any], (challenge["protocol"] as? String) == "cmux-relay-auth", @@ -2308,13 +2382,20 @@ final class SocketClient { "relay_id": relayID, "mac": Self.hexString(from: mac), ]) + try configureSocketWriteSafety(remainingSocketTimeout( + responseTimeout: responseTimeout, + deadline: deadline + )) try writeAll( authPayload + Data([0x0A]), timeoutMessage: "Relay command timed out", failureMessage: "Failed to write to relay socket" ) - let authResponseLine = try readLine(responseTimeout: responseTimeout) + let authResponseLine = try readLine(responseTimeout: remainingSocketTimeout( + responseTimeout: responseTimeout, + deadline: deadline + )) guard let authResponseData = authResponseLine.data(using: .utf8), let authResponse = try JSONSerialization.jsonObject(with: authResponseData) as? [String: Any], (authResponse["ok"] as? Bool) == true else { @@ -2322,6 +2403,20 @@ final class SocketClient { } } + private func remainingSocketTimeout( + responseTimeout: TimeInterval?, + deadline: Date? + ) throws -> TimeInterval { + guard let deadline else { + return responseTimeout ?? Self.responseTimeoutSeconds + } + let remaining = deadline.timeIntervalSinceNow + guard remaining > 0 else { + throw CLIError(message: "Socket connection deadline exceeded") + } + return remaining + } + private func writeAll( _ data: Data, timeoutMessage: String, From 550685c9adeb0ad2893b54915e61f7e169956210 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Sun, 26 Jul 2026 14:29:26 -0700 Subject: [PATCH 06/13] fix: bound unix hook socket connect --- CLI/cmux.swift | 43 ++++++++++++++++++++++++++++++------------- 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 7cf86098c5eb..6486d2a41029 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -2153,16 +2153,27 @@ final class SocketClient { } } - let result = withUnsafePointer(to: &addr) { ptr in - ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in - Darwin.connect(socketFD, sockaddrPtr, socklen_t(MemoryLayout.size)) + let connectErrno: Int32 + if let deadline { + connectErrno = connectSocket(deadline: deadline) { + withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + Darwin.connect(socketFD, sockaddrPtr, socklen_t(MemoryLayout.size)) + } + } } + } else { + let result = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + Darwin.connect(socketFD, sockaddrPtr, socklen_t(MemoryLayout.size)) + } + } + connectErrno = result == 0 ? 0 : errno } - if result == 0 { + if connectErrno == 0 { return } - let connectErrno = errno Darwin.close(socketFD) socketFD = -1 throw SocketConnectError(path: path, errnoValue: connectErrno) @@ -2283,7 +2294,13 @@ final class SocketClient { let connectErrno: Int32 if let deadline { - connectErrno = connectRelaySocket(address: &address, deadline: deadline) + connectErrno = connectSocket(deadline: deadline) { + withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPointer in + Darwin.connect(socketFD, sockaddrPointer, socklen_t(MemoryLayout.stride)) + } + } + } } else { let result = withUnsafePointer(to: &address) { pointer in pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPointer in @@ -2311,7 +2328,7 @@ final class SocketClient { } } - private func connectRelaySocket(address: inout sockaddr_in, deadline: Date) -> Int32 { + private func connectSocket(deadline: Date, operation: () -> Int32) -> Int32 { let originalFlags = fcntl(socketFD, F_GETFL, 0) guard originalFlags >= 0 else { return errno } guard fcntl(socketFD, F_SETFL, originalFlags | O_NONBLOCK) == 0 else { @@ -2323,14 +2340,14 @@ final class SocketClient { } } - let result = withUnsafePointer(to: &address) { pointer in - pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPointer in - Darwin.connect(socketFD, sockaddrPointer, socklen_t(MemoryLayout.stride)) - } - } + let result = operation() if result == 0 { return 0 } let connectErrno = errno - guard connectErrno == EINPROGRESS || connectErrno == EALREADY else { + guard connectErrno == EINPROGRESS + || connectErrno == EALREADY + || connectErrno == EAGAIN + || connectErrno == EWOULDBLOCK + else { return connectErrno } From 8035e82f5dd07df3998ba5209d79699cfec916bc Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Sun, 26 Jul 2026 14:31:41 -0700 Subject: [PATCH 07/13] fix: preserve deadline across socket reads --- CLI/cmux.swift | 43 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 6486d2a41029..767a7c112192 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -1968,7 +1968,11 @@ final class SocketClient { lastConfiguredReceiveTimeout = nil } - func send(command: String, responseTimeout: TimeInterval? = nil) throws -> String { + func send( + command: String, + responseTimeout: TimeInterval? = nil, + deadline: Date? = nil + ) throws -> String { if relayEndpoint != nil, socketFD < 0 { try connect() } @@ -1980,7 +1984,18 @@ final class SocketClient { } } - let initialResponseTimeout = responseTimeout ?? Self.responseTimeoutSeconds + func boundedTimeout(_ timeout: TimeInterval) throws -> TimeInterval { + guard let deadline else { return timeout } + let remaining = deadline.timeIntervalSinceNow + guard remaining > 0 else { + throw CLIError(message: "Command timed out") + } + return min(timeout, remaining) + } + + let initialResponseTimeout = try boundedTimeout( + responseTimeout ?? Self.responseTimeoutSeconds + ) if lastConfiguredReceiveTimeout != initialResponseTimeout { try configureReceiveTimeout(initialResponseTimeout) } @@ -2005,7 +2020,8 @@ final class SocketClient { var receivedCompleteResponse = false while true { - let currentTimeout = sawNewline ? Self.multilineResponseIdleTimeoutSeconds : initialResponseTimeout + let phaseTimeout = sawNewline ? Self.multilineResponseIdleTimeoutSeconds : initialResponseTimeout + let currentTimeout = try boundedTimeout(phaseTimeout) operation.phase = sawNewline ? .readMultilineResponse : .waitForResponse operation.sawNewline = sawNewline operation.timeout = currentTimeout @@ -6147,13 +6163,15 @@ struct CMUXCLI { _ client: SocketClient, explicitPassword: String?, socketPath: String, - responseTimeout: TimeInterval? = nil + responseTimeout: TimeInterval? = nil, + deadline: Date? = nil ) throws { try Self.authenticateSocketClientIfNeeded( client, explicitPassword: explicitPassword, socketPath: socketPath, - responseTimeout: responseTimeout + responseTimeout: responseTimeout, + deadline: deadline ) } @@ -6161,13 +6179,18 @@ struct CMUXCLI { _ client: SocketClient, explicitPassword: String?, socketPath: String, - responseTimeout: TimeInterval? = nil + responseTimeout: TimeInterval? = nil, + deadline: Date? = nil ) throws { if let socketPassword = SocketPasswordResolver.resolve( explicit: explicitPassword, socketPath: socketPath ) { - let authResponse = try client.send(command: "auth \(socketPassword)", responseTimeout: responseTimeout) + let authResponse = try client.send( + command: "auth \(socketPassword)", + responseTimeout: responseTimeout, + deadline: deadline + ) if authResponse.hasPrefix("ERROR:"), !authResponse.contains("Unknown command 'auth'") { throw CLIError(message: authResponse) @@ -34134,7 +34157,8 @@ export default CMUXSessionRestore; feedClient, explicitPassword: socketPassword, socketPath: socketPath, - responseTimeout: try remainingResponseTime() + responseTimeout: try remainingResponseTime(), + deadline: clientDeadline ) } catch { feedClient.close() @@ -34152,7 +34176,8 @@ export default CMUXSessionRestore; do { response = try activeClient.send( command: line, - responseTimeout: try remainingResponseTime() + responseTimeout: try remainingResponseTime(), + deadline: clientDeadline ) } catch { print("{}") From 254da69120a96010383a4ab794c1b18a08b96bfa Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Sun, 26 Jul 2026 14:32:17 -0700 Subject: [PATCH 08/13] fix: preserve deadline on relay reconnect --- CLI/cmux.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 767a7c112192..6f755acc3c0d 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -1974,7 +1974,11 @@ final class SocketClient { deadline: Date? = nil ) throws -> String { if relayEndpoint != nil, socketFD < 0 { - try connect() + if let deadline { + try connect(deadline: deadline) + } else { + try connect() + } } guard socketFD >= 0 else { throw CLIError(message: "Not connected") } let shouldCloseAfterSend = relayEndpoint != nil From 230cd85863cbcd04aed225f90a4ebecb04568566 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Sun, 26 Jul 2026 14:33:58 -0700 Subject: [PATCH 09/13] fix: bound relay reads by hook deadline --- CLI/cmux.swift | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 6f755acc3c0d..7dda60a0aba8 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -2397,10 +2397,10 @@ final class SocketClient { responseTimeout: TimeInterval, deadline: Date? = nil ) throws { - let challengeLine = try readLine(responseTimeout: remainingSocketTimeout( + let challengeLine = try readLine( responseTimeout: responseTimeout, deadline: deadline - )) + ) guard let challengeData = challengeLine.data(using: .utf8), let challenge = try JSONSerialization.jsonObject(with: challengeData) as? [String: Any], (challenge["protocol"] as? String) == "cmux-relay-auth", @@ -2429,10 +2429,10 @@ final class SocketClient { failureMessage: "Failed to write to relay socket" ) - let authResponseLine = try readLine(responseTimeout: remainingSocketTimeout( + let authResponseLine = try readLine( responseTimeout: responseTimeout, deadline: deadline - )) + ) guard let authResponseData = authResponseLine.data(using: .utf8), let authResponse = try JSONSerialization.jsonObject(with: authResponseData) as? [String: Any], (authResponse["ok"] as? Bool) == true else { @@ -2597,11 +2597,25 @@ final class SocketClient { #endif } - private func readLine(maxBytes: Int = 16 * 1024, responseTimeout: TimeInterval? = nil) throws -> String { + private func readLine( + maxBytes: Int = 16 * 1024, + responseTimeout: TimeInterval? = nil, + deadline: Date? = nil + ) throws -> String { var data = Data() while data.count < maxBytes { - try configureReceiveTimeout(responseTimeout ?? Self.responseTimeoutSeconds) + let timeout: TimeInterval + if let deadline { + let remaining = deadline.timeIntervalSinceNow + guard remaining > 0 else { + throw CLIError(message: "Relay command timed out") + } + timeout = min(responseTimeout ?? Self.responseTimeoutSeconds, remaining) + } else { + timeout = responseTimeout ?? Self.responseTimeoutSeconds + } + try configureReceiveTimeout(timeout) var byte: UInt8 = 0 let count = Darwin.read(socketFD, &byte, 1) From 2489f6286f0e981850719e3f67e1c495b9f62e8d Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Sun, 26 Jul 2026 14:37:03 -0700 Subject: [PATCH 10/13] fix: bound socket writes by hook deadline --- CLI/cmux.swift | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 7dda60a0aba8..50f67634e353 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -2016,7 +2016,8 @@ final class SocketClient { try writeAll( Data(payload.utf8), timeoutMessage: "Command timed out", - failureMessage: "Failed to write to socket" + failureMessage: "Failed to write to socket", + deadline: deadline ) var data = Data() @@ -2426,7 +2427,8 @@ final class SocketClient { try writeAll( authPayload + Data([0x0A]), timeoutMessage: "Relay command timed out", - failureMessage: "Failed to write to relay socket" + failureMessage: "Failed to write to relay socket", + deadline: deadline ) let authResponseLine = try readLine( @@ -2457,7 +2459,8 @@ final class SocketClient { private func writeAll( _ data: Data, timeoutMessage: String, - failureMessage: String + failureMessage: String, + deadline: Date? = nil ) throws { try data.withUnsafeBytes { rawBuffer in guard let baseAddress = rawBuffer.baseAddress?.assumingMemoryBound(to: UInt8.self) else { @@ -2465,6 +2468,14 @@ final class SocketClient { } var offset = 0 while offset < data.count { + if let deadline { + let remaining = deadline.timeIntervalSinceNow + guard remaining > 0 else { + close() + throw CLIError(message: timeoutMessage) + } + try configureSocketWriteSafety(remaining) + } let written = Darwin.write(socketFD, baseAddress.advanced(by: offset), data.count - offset) if written < 0 { let errorCode = errno From e8b4c70fc865ddcc44486654898a6c030775bc4b Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Sun, 26 Jul 2026 14:51:08 -0700 Subject: [PATCH 11/13] test: stabilize hook no-response timing coverage --- cmuxTests/CLIHookNoResponseTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmuxTests/CLIHookNoResponseTests.swift b/cmuxTests/CLIHookNoResponseTests.swift index 3b417776b4f2..d6ed24748ad6 100644 --- a/cmuxTests/CLIHookNoResponseTests.swift +++ b/cmuxTests/CLIHookNoResponseTests.swift @@ -2,7 +2,7 @@ import Darwin import Foundation import Testing -@Suite("CLI hook no-response telemetry") +@Suite("CLI hook no-response telemetry", .serialized) struct CLIHookNoResponseTests { final class BundleProbe {} @@ -220,7 +220,7 @@ struct CLIHookNoResponseTests { } let server = Self.startAcceptedSocketThatDoesNotRead(listenerFD: listenerFD, holdFor: 1.0) - let largeToolInput = String(repeating: "x", count: 8 * 1024 * 1024) + let largeToolInput = String(repeating: "x", count: 512 * 1024) let input = """ {"hook_event_name":"PreToolUse","session_id":"codex-session-no-read","cwd":"\(root.path)","tool_name":"apply_patch","tool_input":{"payload":"\(largeToolInput)"}} """ From 8a257d76a61bffaf4f81dfa1df6ec3c1ef70d3d5 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Sun, 26 Jul 2026 15:03:21 -0700 Subject: [PATCH 12/13] test: allow lifecycle hook launch headroom --- cmuxTests/CLIHookNoResponseTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmuxTests/CLIHookNoResponseTests.swift b/cmuxTests/CLIHookNoResponseTests.swift index d6ed24748ad6..46d9420addab 100644 --- a/cmuxTests/CLIHookNoResponseTests.swift +++ b/cmuxTests/CLIHookNoResponseTests.swift @@ -192,7 +192,7 @@ struct CLIHookNoResponseTests { "CMUX_SOCKET_PASSWORD": "test-password", ], standardInput: #"{"session_id":"kiro-lifecycle-no-response","cwd":"\#(root.path)","hook_event_name":"SessionStart"}"#, - timeout: 0.5 + timeout: 1.0 ) #expect(server.wait(timeout: 5), "socket server did not observe lifecycle feed.push") From 87c247d5150225e12fcde16a83455eb305c188e5 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Mon, 27 Jul 2026 00:19:25 -0700 Subject: [PATCH 13/13] fix: address hook deadline review feedback --- CLI/cmux.swift | 38 ++++++++++++-------------- cmuxTests/CLIHookNoResponseTests.swift | 18 ++++++++++-- 2 files changed, 33 insertions(+), 23 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 50f67634e353..d08b0afa8a54 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -1915,39 +1915,35 @@ final class SocketClient { } func connect() throws { - if socketFD >= 0 { return } - let deadline = Date().addingTimeInterval(Self.connectRetryDeadline) - while true { - do { - try connectOnce() - return - } catch { - guard Self.shouldRetryConnect(error), Date() < deadline else { - throw error - } - usleep(Self.connectRetryIntervalMicros) - } - } + try connectWithRetry(deadline: nil) } /// Connects using the remaining absolute deadline for both the socket /// operation timeout and the existing short retry window. func connect(deadline: Date) throws { + try connectWithRetry(deadline: deadline) + } + + private func connectWithRetry(deadline: Date?) throws { if socketFD >= 0 { return } let retryDeadline = min( - deadline, - Date().addingTimeInterval(Self.connectRetryDeadline) + deadline ?? .distantFuture, + Date.now.addingTimeInterval(Self.connectRetryDeadline) ) while true { do { - let remaining = deadline.timeIntervalSinceNow - guard remaining > 0 else { - throw CLIError(message: "Socket connection deadline exceeded") + if let deadline { + let remaining = deadline.timeIntervalSinceNow + guard remaining > 0 else { + throw CLIError(message: "Socket connection deadline exceeded") + } + try connectOnce(responseTimeout: remaining, deadline: deadline) + } else { + try connectOnce() } - try connectOnce(responseTimeout: remaining, deadline: deadline) return } catch { - guard Self.shouldRetryConnect(error), Date() < retryDeadline else { + guard Self.shouldRetryConnect(error), Date.now < retryDeadline else { throw error } usleep(Self.connectRetryIntervalMicros) @@ -2453,7 +2449,7 @@ final class SocketClient { guard remaining > 0 else { throw CLIError(message: "Socket connection deadline exceeded") } - return remaining + return min(responseTimeout ?? Self.responseTimeoutSeconds, remaining) } private func writeAll( diff --git a/cmuxTests/CLIHookNoResponseTests.swift b/cmuxTests/CLIHookNoResponseTests.swift index 46d9420addab..2f234f63509a 100644 --- a/cmuxTests/CLIHookNoResponseTests.swift +++ b/cmuxTests/CLIHookNoResponseTests.swift @@ -219,7 +219,7 @@ struct CLIHookNoResponseTests { try? FileManager.default.removeItem(at: root) } - let server = Self.startAcceptedSocketThatDoesNotRead(listenerFD: listenerFD, holdFor: 1.0) + let server = try Self.startAcceptedSocketThatDoesNotRead(listenerFD: listenerFD, holdFor: 1.0) let largeToolInput = String(repeating: "x", count: 512 * 1024) let input = """ {"hook_event_name":"PreToolUse","session_id":"codex-session-no-read","cwd":"\(root.path)","tool_name":"apply_patch","tool_input":{"payload":"\(largeToolInput)"}} @@ -419,7 +419,21 @@ struct CLIHookNoResponseTests { return MockSocketServer(handled: handled) } - private static func startAcceptedSocketThatDoesNotRead(listenerFD: Int32, holdFor: TimeInterval) -> MockSocketServer { + private static func startAcceptedSocketThatDoesNotRead( + listenerFD: Int32, + holdFor: TimeInterval + ) throws -> MockSocketServer { + var receiveBufferBytes: Int32 = 4 * 1024 + guard setsockopt( + listenerFD, + SOL_SOCKET, + SO_RCVBUF, + &receiveBufferBytes, + socklen_t(MemoryLayout.size(ofValue: receiveBufferBytes)) + ) == 0 else { + throw posixError("failed to constrain non-reading socket receive buffer") + } + let handled = DispatchSemaphore(value: 0) DispatchQueue.global(qos: .userInitiated).async { var clientAddr = sockaddr_un()