diff --git a/CLI/CMUXCLI+AgentHookDefinitions.swift b/CLI/CMUXCLI+AgentHookDefinitions.swift index e48cc96d87d4..73482e6b654c 100644 --- a/CLI/CMUXCLI+AgentHookDefinitions.swift +++ b/CLI/CMUXCLI+AgentHookDefinitions.swift @@ -360,9 +360,10 @@ extension CMUXCLI { /// inherited `CMUX_CODEX_PID` as its own owner identity. static func codexSynchronousAgentHookShellCommand( _ command: String, - for def: AgentHookDef + for def: AgentHookDef, + failOpen: Bool = false ) -> String { - let dispatch = agentHookShellCommand(command, for: def) + let dispatch = agentHookShellCommand(command, for: def, failOpen: failOpen) return "CMUX_CODEX_HOOK_PID=\"${PPID:-}\"; export CMUX_CODEX_HOOK_PID; \(dispatch)" } diff --git a/CLI/CMUXCLI+AgentMessages.swift b/CLI/CMUXCLI+AgentMessages.swift index de3a9523b1d9..2545635a8260 100644 --- a/CLI/CMUXCLI+AgentMessages.swift +++ b/CLI/CMUXCLI+AgentMessages.swift @@ -228,9 +228,11 @@ extension CMUXCLI { let env = ProcessInfo.processInfo.environment let input = Self.agentInboxHookInput() let disabledKey = agent == "claude" ? "CMUX_CLAUDE_HOOKS_DISABLED" : "CMUX_CODEX_HOOKS_DISABLED" - // Headless `claude -p` runs share the pane's surface id with the - // interactive session there; they must not claim its messages. - let headless = agent == "claude" && env["CMUX_CLAUDE_HEADLESS"] == "1" + // Headless `claude -p` and `codex exec` runs share the pane's surface + // id with the interactive session there; they must not claim its + // messages. + let headlessKey = agent == "claude" ? "CMUX_CLAUDE_HEADLESS" : "CMUX_CODEX_HEADLESS" + let headless = env[headlessKey] == "1" guard let surfaceId = env["CMUX_SURFACE_ID"], !surfaceId.isEmpty, env[disabledKey] != "1", !headless else { if subcommand != "inbox-wait" { print("{}") } return true diff --git a/CLI/CMUXCLI+CodexFireAndForgetHooks.swift b/CLI/CMUXCLI+CodexFireAndForgetHooks.swift index d72111c7cfc2..a1857459c014 100644 --- a/CLI/CMUXCLI+CodexFireAndForgetHooks.swift +++ b/CLI/CMUXCLI+CodexFireAndForgetHooks.swift @@ -189,7 +189,8 @@ extension CMUXCLI { /// value only defines the session-flags layer; it never replaces a lower /// layer, and copying lower layers into it would make Codex discover and /// run every user handler twice. Each value therefore carries exactly one - /// cmux group. + /// cmux group. The UserPromptSubmit and Stop groups hold a second, direct + /// handler that hands queued agent messages to Codex. /// Persistent hooks are inventoried read-only so the wrapper does not add a /// duplicate cmux producer. Only explicit `cmux hooks codex install` or /// `uninstall` commands mutate `CODEX_HOME`. No live socket is required. @@ -217,20 +218,19 @@ extension CMUXCLI { // ~/.codex. Any write failure falls back to the inline snippet. let hooksDir = eventsToInject.isEmpty ? nil : Self.codexHookScriptsDirectory() var args: [String] = ["--enable", "hooks", "--dangerously-bypass-hook-trust"] - for event in eventsToInject { - let hookBody = Self.codexWrapperHookBody(event: event, for: codexDef) + func hookCommand(subcommand: String, body: String) throws -> String { let command: String if let scriptPath = hooksDir.flatMap({ - Self.writeCodexHookScript(subcommand: event.cmuxSubcommand, body: hookBody, in: $0) + Self.writeCodexHookScript(subcommand: subcommand, body: body, in: $0) }) { let shellCommand = CodexHookScriptName.shellCommand(forScriptPath: scriptPath) if !shellCommand.contains("'''") { command = shellCommand } else { - command = hookBody + command = body } } else { - command = hookBody + command = body } // TOML multi-line literal string ('''...''') preserves bytes verbatim // and may contain single quotes, so the embedded `echo '{}'` / `sh -c @@ -240,7 +240,21 @@ extension CMUXCLI { guard !command.contains("'''") else { throw CLIError(message: "Codex hook command contains a triple single quote and cannot be TOML-encoded.") } - let toml = "hooks.\(event.agentEvent)=[{hooks=[{type=\"command\",command='''\(command)''',timeout=\(event.timeoutMs)}]}]" + return command + } + for event in eventsToInject { + let toml = try event.configValue { subcommand in + if let companion = event.companion, subcommand == companion.cmuxSubcommand { + return try hookCommand( + subcommand: subcommand, + body: Self.codexWrapperCompanionHookBody(companion, for: codexDef) + ) + } + return try hookCommand( + subcommand: subcommand, + body: Self.codexWrapperHookBody(event: event, for: codexDef) + ) + } args.append("-c") args.append(toml) } @@ -333,13 +347,37 @@ extension CMUXCLI { /// Names that the current wrapper schema may reference from a live session. static func currentCodexWrapperHookScriptFilenames(for def: AgentHookDef) -> Set { - Set(CodexHookInjectionSchema.current.events.compactMap { event in + var names = Set(CodexHookInjectionSchema.current.events.compactMap { event in let body = codexWrapperHookBody(event: event, for: def) return CodexHookScriptName( contents: "#!/bin/sh\n\(body)\n", subcommand: event.cmuxSubcommand )?.filename }) + for companion in CodexHookInjectionSchema.current.events.compactMap(\.companion) { + let body = codexWrapperCompanionHookBody(companion, for: def) + if let name = CodexHookScriptName( + contents: "#!/bin/sh\n\(body)\n", + subcommand: companion.cmuxSubcommand + )?.filename { + names.insert(name) + } + } + return names + } + + /// A companion handler runs directly: its stdout (agent messages for + /// Codex) must reach Codex. It runs on every prompt and stop, so any + /// failure, including an unreachable app, answers `{}`. + private static func codexWrapperCompanionHookBody( + _ companion: CodexHookCompanion, + for def: AgentHookDef + ) -> String { + codexSynchronousAgentHookShellCommand( + "cmux hooks codex \(companion.cmuxSubcommand)", + for: def, + failOpen: true + ) } private static func codexWrapperHookBody( diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerCodexLaunch.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerCodexLaunch.swift index 420563e249af..5edd1c59cbab 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerCodexLaunch.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerCodexLaunch.swift @@ -229,10 +229,23 @@ private func isCmuxInjectedCodexHookConfigValue( let body = String(value[value.index(after: equals)...]) let prefix = "[{hooks=[{type=\"command\",command='''" - let suffix = "''',timeout=\(event.timeoutMs)}]}]" - guard body.hasPrefix(prefix), body.hasSuffix(suffix) else { return false } - let command = String(body.dropFirst(prefix.count).dropLast(suffix.count)) - return isCmuxCodexHookCommand(command, subcommand: event.cmuxSubcommand) + let lastTimeout = event.companion?.timeoutMs ?? event.timeoutMs + let suffix = "''',timeout=\(lastTimeout)}]}]" + guard body.count >= prefix.count + suffix.count, + body.hasPrefix(prefix), body.hasSuffix(suffix) else { return false } + let inner = String(body.dropFirst(prefix.count).dropLast(suffix.count)) + guard let companion = event.companion else { + return isCmuxCodexHookCommand(inner, subcommand: event.cmuxSubcommand) + } + // Two handlers in one group, split on the exact separator cmux emits. + // Generated commands never contain a triple single quote. A crafted value + // can still pass the inline command check below, which is as loose as it + // is for single-handler values; stripping it only drops it from replay. + let separator = "''',timeout=\(event.timeoutMs)},{type=\"command\",command='''" + let commands = inner.components(separatedBy: separator) + guard commands.count == 2 else { return false } + return isCmuxCodexHookCommand(commands[0], subcommand: event.cmuxSubcommand) + && isCmuxCodexHookCommand(commands[1], subcommand: companion.cmuxSubcommand) } private func isCmuxCodexHookCommand(_ command: String, subcommand: String) -> Bool { diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexHookInjectionEvent.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexHookInjectionEvent.swift index 3a0567c05339..e2cbaa913c7a 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexHookInjectionEvent.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexHookInjectionEvent.swift @@ -13,18 +13,41 @@ public struct CodexHookInjectionEvent: Equatable, Sendable { /// the direct process/stdout contract for an agent decision. public let delivery: CodexHookDelivery + /// A second handler in the same hook group, always run directly so its + /// stdout reaches Codex. Used to hand agent messages to Codex without + /// making the lifecycle handler synchronous. + public let companion: CodexHookCompanion? + /// Creates one schema entry. Queue delivery is the safe default for /// lifecycle and telemetry events; decision events opt into `.direct`. public init( agentEvent: String, cmuxSubcommand: String, timeoutMs: Int, - delivery: CodexHookDelivery = .queued + delivery: CodexHookDelivery = .queued, + companion: CodexHookCompanion? = nil ) { self.agentEvent = agentEvent self.cmuxSubcommand = cmuxSubcommand self.timeoutMs = timeoutMs self.delivery = delivery + self.companion = companion + } +} + +/// A direct handler that shares a cmux hook group with the event's main +/// handler. Its command is rendered after the main one in the same +/// `hooks=[...]` list. +public struct CodexHookCompanion: Equatable, Sendable { + /// The cmux hook subcommand invoked for the event. + public let cmuxSubcommand: String + + /// The timeout Codex applies to the hook command, in milliseconds. + public let timeoutMs: Int + + public init(cmuxSubcommand: String, timeoutMs: Int) { + self.cmuxSubcommand = cmuxSubcommand + self.timeoutMs = timeoutMs } } @@ -33,3 +56,22 @@ public enum CodexHookDelivery: Equatable, Sendable { case queued case direct } + +extension CodexHookInjectionEvent { + /// The `-c` value cmux passes to Codex for this event: + /// `hooks.=[{hooks=[...]}]` with the main handler, then the + /// companion if there is one. `command` maps a cmux subcommand to the + /// shell command that runs it. Generation and the tests share this so the + /// sanitizer's expected shape has one source. + public func configValue(command: (String) throws -> String) rethrows -> String { + var handlers = [ + "{type=\"command\",command='''\(try command(cmuxSubcommand))''',timeout=\(timeoutMs)}", + ] + if let companion { + handlers.append( + "{type=\"command\",command='''\(try command(companion.cmuxSubcommand))''',timeout=\(companion.timeoutMs)}" + ) + } + return "hooks.\(agentEvent)=[{hooks=[\(handlers.joined(separator: ","))]}]" + } +} diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexHookInjectionSchema.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexHookInjectionSchema.swift index bb50cb9908e6..8a2a40aa617a 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexHookInjectionSchema.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexHookInjectionSchema.swift @@ -15,8 +15,18 @@ public struct CodexHookInjectionSchema: Equatable, Sendable { /// one side of the boundary. public static let current = Self(events: [ .init(agentEvent: "SessionStart", cmuxSubcommand: "session-start", timeoutMs: AgentHookDeliveryPolicy.declaredTimeoutMilliseconds), - .init(agentEvent: "UserPromptSubmit", cmuxSubcommand: "prompt-submit", timeoutMs: AgentHookDeliveryPolicy.declaredTimeoutMilliseconds), - .init(agentEvent: "Stop", cmuxSubcommand: "stop", timeoutMs: AgentHookDeliveryPolicy.declaredTimeoutMilliseconds), + .init( + agentEvent: "UserPromptSubmit", + cmuxSubcommand: "prompt-submit", + timeoutMs: AgentHookDeliveryPolicy.declaredTimeoutMilliseconds, + companion: .init(cmuxSubcommand: "inbox-drain", timeoutMs: inboxHookTimeoutMilliseconds) + ), + .init( + agentEvent: "Stop", + cmuxSubcommand: "stop", + timeoutMs: AgentHookDeliveryPolicy.declaredTimeoutMilliseconds, + companion: .init(cmuxSubcommand: "inbox-stop", timeoutMs: inboxHookTimeoutMilliseconds) + ), .init(agentEvent: "PreToolUse", cmuxSubcommand: "pre-tool-use", timeoutMs: AgentHookDeliveryPolicy.declaredTimeoutMilliseconds), .init(agentEvent: "PostToolUse", cmuxSubcommand: "post-tool-use", timeoutMs: AgentHookDeliveryPolicy.declaredTimeoutMilliseconds), .init( @@ -39,12 +49,28 @@ public struct CodexHookInjectionSchema: Equatable, Sendable { ), ]) + /// Timeout for the agent message handlers on UserPromptSubmit and Stop. + /// They make one short socket call and fail open to `{}`. + static let inboxHookTimeoutMilliseconds = 5_000 + /// Exact older shapes accepted by saved-layout and replay sanitization. /// These remain explicit because stored commands can outlive the cmux /// version that captured them. Never broaden this to unordered events or /// arbitrary prefixes: hook config is user-controlled argv. static let recognized = [ current, + // The generation before the agent message companions on + // UserPromptSubmit and Stop. + Self(events: [ + .init(agentEvent: "SessionStart", cmuxSubcommand: "session-start", timeoutMs: AgentHookDeliveryPolicy.declaredTimeoutMilliseconds), + .init(agentEvent: "UserPromptSubmit", cmuxSubcommand: "prompt-submit", timeoutMs: AgentHookDeliveryPolicy.declaredTimeoutMilliseconds), + .init(agentEvent: "Stop", cmuxSubcommand: "stop", timeoutMs: AgentHookDeliveryPolicy.declaredTimeoutMilliseconds), + .init(agentEvent: "PreToolUse", cmuxSubcommand: "pre-tool-use", timeoutMs: AgentHookDeliveryPolicy.declaredTimeoutMilliseconds), + .init(agentEvent: "PostToolUse", cmuxSubcommand: "post-tool-use", timeoutMs: AgentHookDeliveryPolicy.declaredTimeoutMilliseconds), + .init(agentEvent: "PermissionRequest", cmuxSubcommand: "notification", timeoutMs: 120000, delivery: .direct), + .init(agentEvent: "SubagentStart", cmuxSubcommand: "subagent-start", timeoutMs: AgentHookDeliveryPolicy.declaredTimeoutMilliseconds, delivery: .direct), + .init(agentEvent: "SubagentStop", cmuxSubcommand: "subagent-stop", timeoutMs: AgentHookDeliveryPolicy.declaredTimeoutMilliseconds, delivery: .direct), + ]), Self(events: [ .init(agentEvent: "SessionStart", cmuxSubcommand: "session-start", timeoutMs: 10000), .init(agentEvent: "UserPromptSubmit", cmuxSubcommand: "prompt-submit", timeoutMs: 10000), diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/CodexHookInjectionStrippingTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/CodexHookInjectionStrippingTests.swift index 639f5bf3bf0b..16766f947f53 100644 --- a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/CodexHookInjectionStrippingTests.swift +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/CodexHookInjectionStrippingTests.swift @@ -55,6 +55,54 @@ struct CodexHookInjectionStrippingTests { ) } + @Test("The current block pairs the agent message handlers with prompt submit and stop") + func currentBlockHasInboxCompanions() { + let companions = CodexHookInjectionSchema.current.events.compactMap { event in + event.companion.map { "\(event.agentEvent):\($0.cmuxSubcommand)" } + } + #expect(companions == ["UserPromptSubmit:inbox-drain", "Stop:inbox-stop"]) + let value = CodexHookInjectionSchema.current.events[1].configValue { "/h/\($0).sh" } + #expect(value == "hooks.UserPromptSubmit=[{hooks=[{type=\"command\",command='''/h/prompt-submit.sh''',timeout=5000},{type=\"command\",command='''/h/inbox-drain.sh''',timeout=5000}]}]") + } + + @Test("Strips the block from before the agent message handlers") + func stripsBlockWithoutInboxCompanions() { + let events = CodexHookInjectionSchema.current.events.map { + CodexHookInjectionEvent( + agentEvent: $0.agentEvent, + cmuxSubcommand: $0.cmuxSubcommand, + timeoutMs: $0.timeoutMs, + delivery: $0.delivery + ) + } + let arguments = ["codex"] + hookArguments(events: events) { subcommand in + "/Users/u/.cmux/hooks/cmux-codex-hook-\(subcommand).sh" + } + ["--model", "gpt-5.5"] + #expect( + AgentLaunchSanitizer.sanitizedLaunchArguments( + arguments, + launcher: "", + fallbackKind: "codex" + ) == ["codex", "--model", "gpt-5.5"] + ) + } + + @Test("Keeps a hook block whose second handler is not cmux's") + func keepsBlockWithForeignCompanion() { + let arguments = ["codex"] + codexWrapperHookArguments { subcommand in + subcommand == "inbox-drain" + ? "/Users/u/bin/my-prompt-hook.sh" + : legacyNamedScriptPath(subcommand) + } + ["--model", "gpt-5.5"] + #expect( + AgentLaunchSanitizer.sanitizedLaunchArguments( + arguments, + launcher: "", + fallbackKind: "codex" + ) == arguments + ) + } + @Test("Strips the legacy alias Codex hook block") func stripsLegacyAliasCodexHookBlock() { let arguments = ["codex"] + hookArguments( @@ -109,7 +157,7 @@ struct CodexHookInjectionStrippingTests { @Test("Strips joined cmux Codex hook options") func stripsJoinedCmuxCodexHookOptions() { let arguments = ["codex"] + codexWrapperHookArguments(joined: true) { subcommand in - "/Users/u/.cmux/hooks/cmux-codex-hook-\(subcommand).sh" + legacyNamedScriptPath(subcommand) } + ["--model", "gpt-5.5"] #expect( AgentLaunchSanitizer.sanitizedLaunchArguments( @@ -250,7 +298,7 @@ struct CodexHookInjectionStrippingTests { // its marker configs; the user's own enable flag and hook config after // them must survive stripping so the preserved hook stays enabled. let arguments = ["codex"] + codexWrapperHookArguments { subcommand in - "/Users/u/.cmux/hooks/cmux-codex-hook-\(subcommand).sh" + legacyNamedScriptPath(subcommand) } + [ "--enable", "hooks", @@ -564,13 +612,21 @@ struct CodexHookInjectionStrippingTests { ])) } + /// Legacy script names (no content ID) for the subcommands that had + /// them; the agent message handlers only ever had content-addressed names. + private func legacyNamedScriptPath(_ subcommand: String) -> String { + subcommand.hasPrefix("inbox-") + ? "/Users/u/.cmux/hooks/cmux-codex-hook-0123456789abcdef-\(subcommand).sh" + : "/Users/u/.cmux/hooks/cmux-codex-hook-\(subcommand).sh" + } + private var runtimeUnwrapper: JavaScriptRuntimeAgentLaunchUnwrapper { JavaScriptRuntimeAgentLaunchUnwrapper(isKnownAgentExecutableName: isKnownAgentExecutableName) } private func realisticCodexHookArgv() -> [String] { [codexExecutable] + codexWrapperHookArguments { subcommand in - "/Users/u/.cmux/hooks/cmux-codex-hook-\(subcommand).sh" + legacyNamedScriptPath(subcommand) } + [ "--dangerously-bypass-approvals-and-sandbox", "--model", @@ -592,7 +648,7 @@ struct CodexHookInjectionStrippingTests { } private func hookArguments( - events: [(agentEvent: String, cmuxSubcommand: String, timeoutMs: Int)], + events: [CodexHookInjectionEvent], joined: Bool = false, command: (String) -> String ) -> [String] { @@ -600,7 +656,7 @@ struct CodexHookInjectionStrippingTests { ? ["--enable=hooks", "--dangerously-bypass-hook-trust"] : ["--enable", "hooks", "--dangerously-bypass-hook-trust"] for (index, event) in events.enumerated() { - let value = "hooks.\(event.agentEvent)=[{hooks=[{type=\"command\",command='''\(command(event.cmuxSubcommand))''',timeout=\(event.timeoutMs)}]}]" + let value = event.configValue(command: command) let option = index.isMultiple(of: 2) ? "-c" : "--config" if joined { arguments.append("\(option)=\(value)") @@ -611,18 +667,12 @@ struct CodexHookInjectionStrippingTests { return arguments } - private var codexWrapperHookEvents: [ - (agentEvent: String, cmuxSubcommand: String, timeoutMs: Int) - ] { - CodexHookInjectionSchema.current.events.map { - ($0.agentEvent, $0.cmuxSubcommand, $0.timeoutMs) - } + private var codexWrapperHookEvents: [CodexHookInjectionEvent] { + CodexHookInjectionSchema.current.events } - private var legacySavedLayoutHookEvents: [ - (agentEvent: String, cmuxSubcommand: String, timeoutMs: Int) - ] { - [ + private var legacySavedLayoutHookEvents: [CodexHookInjectionEvent] { + let events: [(String, String, Int)] = [ ("SessionStart", "session-start", 10000), ("UserPromptSubmit", "prompt-submit", 10000), ("PreToolUse", "pre-tool-use", 10000), @@ -630,12 +680,11 @@ struct CodexHookInjectionStrippingTests { ("Notification", "notification", 10000), ("Stop", "stop", 10000), ] + return events.map { CodexHookInjectionEvent(agentEvent: $0.0, cmuxSubcommand: $0.1, timeoutMs: $0.2) } } - private var legacySynchronousChildHookEvents: [ - (agentEvent: String, cmuxSubcommand: String, timeoutMs: Int) - ] { - [ + private var legacySynchronousChildHookEvents: [CodexHookInjectionEvent] { + let events: [(String, String, Int)] = [ ("SessionStart", "session-start", 10000), ("UserPromptSubmit", "prompt-submit", 10000), ("Stop", "stop", 10000), @@ -645,16 +694,16 @@ struct CodexHookInjectionStrippingTests { ("SubagentStart", "subagent-start", 10000), ("SubagentStop", "subagent-stop", 10000), ] + return events.map { CodexHookInjectionEvent(agentEvent: $0.0, cmuxSubcommand: $0.1, timeoutMs: $0.2) } } - private var legacyAliasHookEvents: [ - (agentEvent: String, cmuxSubcommand: String, timeoutMs: Int) - ] { - [ + private var legacyAliasHookEvents: [CodexHookInjectionEvent] { + let events: [(String, String, Int)] = [ ("SessionStart", "session-start", 10000), ("SessionStop", "stop", 10000), ("Notification", "notification", 10000), ] + return events.map { CodexHookInjectionEvent(agentEvent: $0.0, cmuxSubcommand: $0.1, timeoutMs: $0.2) } } private func isKnownAgentExecutableName(_ name: String) -> Bool { diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/CodexHookPathSafetyTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/CodexHookPathSafetyTests.swift index d4eaad0f4e3e..70b5949f24c7 100644 --- a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/CodexHookPathSafetyTests.swift +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/CodexHookPathSafetyTests.swift @@ -72,7 +72,7 @@ struct CodexHookPathSafetyTests { ? ["--enable=hooks", "--dangerously-bypass-hook-trust"] : ["--enable", "hooks", "--dangerously-bypass-hook-trust"] for (index, event) in CodexHookInjectionSchema.current.events.enumerated() { - let value = "hooks.\(event.agentEvent)=[{hooks=[{type=\"command\",command='''\(command(event.cmuxSubcommand))''',timeout=\(event.timeoutMs)}]}]" + let value = event.configValue(command: command) let option = index.isMultiple(of: 2) ? "-c" : "--config" if joined { arguments.append("\(option)=\(value)") diff --git a/Resources/bin/cmux-codex-wrapper b/Resources/bin/cmux-codex-wrapper index 38e619e5c5bd..f0d19e84380d 100755 --- a/Resources/bin/cmux-codex-wrapper +++ b/Resources/bin/cmux-codex-wrapper @@ -512,7 +512,8 @@ codex_known_subcommand() { codex_option_consumes_value() { case "$1" in -c|--config|-m|--model|-p|--profile|-C|--cd|--remote|-a|--ask-for-approval|\ - -s|--sandbox|--output-last-message|--enable|--disable) + -s|--sandbox|--output-last-message|--enable|--disable|--add-dir|-i|--image|\ + --local-provider|--remote-auth-token-env) return 0 ;; esac @@ -571,6 +572,31 @@ should_inject_codex_hooks() { return 0 } +# `codex exec` runs headless. It shares the pane's surface with any +# interactive Codex there, so the agent message hooks skip it. +codex_launch_is_headless() { + local arg + local skip_next=false + for arg in "$@"; do + if [[ "$skip_next" == true ]]; then + skip_next=false + continue + fi + case "$arg" in + --) return 1 ;; + -*) + if [[ "$arg" != *=* ]] && codex_option_consumes_value "$arg"; then + skip_next=true + fi + continue + ;; + exec|e) return 0 ;; + *) return 1 ;; + esac + done + return 1 +} + # Codex does not emit its own SessionStart hook for an explicit `resume ` # launch. The wrapper is the only layer that knows both the resumed session id # and the new live process, so replay that lifecycle event after exporting the @@ -705,6 +731,11 @@ else unset CMUX_CODEX_PARENT_INVOCATION_ID fi export CMUX_CODEX_PID=$$ +if codex_launch_is_headless "$@"; then + export CMUX_CODEX_HEADLESS=1 +else + export CMUX_CODEX_HEADLESS=0 +fi export CMUX_CODEX_HOOK_CMUX_BIN="$(resolve_hook_cmux_bin)" export CMUX_AGENT_LAUNCH_KIND="codex" export CMUX_AGENT_LAUNCH_EXECUTABLE="$REAL_CODEX" diff --git a/cmuxCLITests/CLICodexQueuedHookContractTests.swift b/cmuxCLITests/CLICodexQueuedHookContractTests.swift index e8694629b8d4..36523914f8b6 100644 --- a/cmuxCLITests/CLICodexQueuedHookContractTests.swift +++ b/cmuxCLITests/CLICodexQueuedHookContractTests.swift @@ -135,6 +135,78 @@ struct CLICodexQueuedHookContractTests { #expect(!stop.timedOut, Comment(rawValue: stop.stderr)) #expect(stop.status == 0, Comment(rawValue: stop.stderr)) #expect(stop.stdout == "{}\n") + + // The agent message handlers run on every prompt and stop, so a + // missing app must not surface as a failed hook in Codex. + for event in ["UserPromptSubmit", "Stop"] { + let configuration = try injectedConfiguration(event: event, arguments: arguments) + let companionCommand = try #require(injectedCommands(configuration: configuration).last) + #expect(FileManager.default.isExecutableFile(atPath: companionCommand)) + let companion = runCodexHookProcess( + executablePath: companionCommand, + arguments: [], + environment: environment, + standardInput: payload, + timeout: 2 + ) + #expect(!companion.timedOut, Comment(rawValue: companion.stderr)) + #expect(companion.status == 0, Comment(rawValue: "\(event): \(companion.stderr)")) + #expect(companion.stdout == "{}\n", Comment(rawValue: event)) + } + } + + @Test("Codex prompt submit and stop also run the direct agent message handlers") + func wrapperInjectionAddsInboxCompanions() throws { + let cliPath = try BundledCLITestSupport.bundledCLIPath(for: CLITestBundleAnchor.self) + let root = FileManager.default.temporaryDirectory.appendingPathComponent( + "cmux-codex-inbox-companions-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let result = runCodexHookProcess( + executablePath: cliPath, + arguments: ["hooks", "codex", "inject-args"], + environment: [ + "HOME": root.path, + "CODEX_HOME": root.appendingPathComponent(".codex").path, + "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", + "CMUX_CLI_SENTRY_DISABLED": "1", + ], + timeout: 3 + ) + #expect(!result.timedOut, Comment(rawValue: result.stderr)) + #expect(result.status == 0, Comment(rawValue: result.stderr)) + let arguments = result.stdout.split(separator: "\0").map(String.init) + + for (event, main, companion) in [ + ("UserPromptSubmit", "prompt-submit", "inbox-drain"), + ("Stop", "stop", "inbox-stop"), + ] { + let configuration = try injectedConfiguration(event: event, arguments: arguments) + let commands = injectedCommands(configuration: configuration) + #expect(commands.count == 2, Comment(rawValue: configuration)) + let bodies = try commands.map { command -> String in + FileManager.default.fileExists(atPath: command) + ? try String(contentsOfFile: command, encoding: .utf8) + : command + } + #expect(bodies.first?.contains("hooks enqueue codex \(main)") == true) + let companionBody = try #require(bodies.last) + #expect(companionBody.contains("hooks codex \(companion)")) + #expect(!companionBody.contains("hooks enqueue")) + } + for event in ["SessionStart", "PreToolUse", "PostToolUse", "PermissionRequest"] { + let configuration = try injectedConfiguration(event: event, arguments: arguments) + #expect(injectedCommands(configuration: configuration).count == 1, Comment(rawValue: configuration)) + } + } + + private func injectedCommands(configuration: String) -> [String] { + configuration.components(separatedBy: "command='''").dropFirst().compactMap { part in + part.range(of: "'''").map { String(part[..<$0.lowerBound]) } + } } private func injectedConfiguration(event: String, arguments: [String]) throws -> String { diff --git a/cmuxTests/CmuxConfigActionSaverTests.swift b/cmuxTests/CmuxConfigActionSaverTests.swift index bd69c306295a..7289bd2edee9 100644 --- a/cmuxTests/CmuxConfigActionSaverTests.swift +++ b/cmuxTests/CmuxConfigActionSaverTests.swift @@ -498,9 +498,7 @@ struct CmuxConfigActionSaverTests { var arguments = ["--enable", "hooks", "--dangerously-bypass-hook-trust"] for event in CodexHookInjectionSchema.current.events { arguments.append("-c") - arguments.append( - "hooks.\(event.agentEvent)=[{hooks=[{type=\"command\",command='''/Users/u/.cmux/hooks/cmux-codex-hook-\(event.cmuxSubcommand).sh''',timeout=\(event.timeoutMs)}]}]" - ) + arguments.append(event.configValue { "/Users/u/.cmux/hooks/cmux-codex-hook-0123456789abcdef-\($0).sh" }) } return arguments } diff --git a/docs/agent-messages.md b/docs/agent-messages.md index 9996c6f7aba1..512d58e0a355 100644 --- a/docs/agent-messages.md +++ b/docs/agent-messages.md @@ -20,7 +20,7 @@ Each message moves through three states: `queued`, then `delivered`, then `read` | Agent | How a message arrives | | --- | --- | | Claude Code | A background hook (`asyncRewake`) runs after every session start and stop and checks for messages about every 2 seconds, opening a short connection each time. Headless `claude -p` runs neither wait for nor receive messages. An idle session wakes up with the message as a system reminder. A busy one sees it at its next step. If the human submits a prompt first, the message is attached to that prompt as context. The prompt box, and any draft in it, is never touched. | -| Codex | Stays `queued` for now. Codex hooks can't wake an idle session; delivery at the next prompt and before Codex goes idle is a follow-up. | +| Codex | Codex hooks can't wake an idle session, so a message waits for Codex's next hook: when the human submits a prompt it is attached as context, and when Codex is about to stop it continues the turn with the message instead. An idle Codex sees it at its next prompt. Headless `codex exec` runs neither receive nor claim messages. Sessions whose cmux hooks come from `cmux hooks codex install` rather than the launch wrapper don't receive messages yet. | While the recipient is waiting on a human (a question, permission or plan prompt is open), delivery holds until that prompt is answered. diff --git a/tests/test_codex_wrapper_hook_append.py b/tests/test_codex_wrapper_hook_append.py index 434f49cafe9f..d1531d83de68 100644 --- a/tests/test_codex_wrapper_hook_append.py +++ b/tests/test_codex_wrapper_hook_append.py @@ -5,7 +5,7 @@ highest: the user's ``hooks.json`` and ``[hooks]`` table in ``config.toml``, a trusted project's ``.codex/hooks.json``, and finally the session flags. The ``-c hooks.=...`` values cmux injects only define that session-flags -layer, so they must carry exactly one cmux handler per event and must never +layer, so they must carry exactly one cmux group per event and must never re-declare the user's handlers: a copied user handler is discovered twice and runs twice (https://github.com/manaflow-ai/cmux/issues/12081). @@ -77,7 +77,8 @@ # `.codex/hooks.json`). LIVE_USER_MARKERS = LIVE_EVENTS + ["SessionStart:config.toml", "SessionStart:project"] # cmux's own handlers that a one-turn `codex exec` with no tool calls fires. -LIVE_CMUX_SUBCOMMANDS = ["session-start", "prompt-submit", "stop"] +# The agent message handlers run too; in `codex exec` they return `{}` at once. +LIVE_CMUX_SUBCOMMANDS = ["session-start", "prompt-submit", "stop", "inbox-drain", "inbox-stop"] def user_hooks_json(commands: dict[str, str]) -> dict: @@ -135,11 +136,21 @@ def assert_injection_shape(arguments: list[str], context: str) -> None: assert rest[index + 1].startswith("hooks."), f"{context}: non-hook override: {rest[index + 1]!r}" +# Events whose cmux group also carries the direct agent message handler. +INBOX_COMPANIONS = {"UserPromptSubmit": "inbox-drain", "Stop": "inbox-stop"} + + def assert_single_cmux_group(event: str, values: list[str], context: str) -> None: assert len(values) == 1, f"{context}: {event} assigned {len(values)} times: {values}" value = values[0] assert value.count("{hooks=") == 1, f"{context}: {event} carries more than one group: {value}" - assert value.count('type="command"') == 1, f"{context}: {event} carries more than one handler: {value}" + expected_handlers = 2 if event in INBOX_COMPANIONS else 1 + handlers = value.count('type="command"') + assert handlers == expected_handlers, ( + f"{context}: {event} carries {handlers} handlers, expected {expected_handlers}: {value}" + ) + if event in INBOX_COMPANIONS: + assert INBOX_COMPANIONS[event] in value, f"{context}: {event} lacks its agent message handler: {value}" assert value.startswith("[{hooks=[{type=\"command\",command='''"), f"{context}: {event} shape changed: {value}" assert "cmux-codex-hook" in value or "hooks codex" in value, f"{context}: {event} is not cmux's handler: {value}" diff --git a/tests/test_codex_wrapper_resume_hooks.py b/tests/test_codex_wrapper_resume_hooks.py index 1b1520950463..c61e091248ed 100644 --- a/tests/test_codex_wrapper_resume_hooks.py +++ b/tests/test_codex_wrapper_resume_hooks.py @@ -8,7 +8,6 @@ import socket import subprocess import tempfile -import time from pathlib import Path @@ -36,8 +35,6 @@ def run_wrapper( restore_token: str | None = None, inject_args_available: bool = True, subrouter_marker: str | None = None, - fork_parent_session_id: str | None = None, - fork_launch_id: str | None = None, ) -> tuple[int, list[str], list[str], dict[str, str], str]: with tempfile.TemporaryDirectory(prefix="cmux-codex-wrapper-test-") as td: tmp = Path(td) @@ -74,9 +71,7 @@ def run_wrapper( printf 'CMUX_AGENT_LAUNCH_SUBROUTER_CODEX_RESUME_COMMAND=%s\\n' "${CMUX_AGENT_LAUNCH_SUBROUTER_CODEX_RESUME_COMMAND-__UNSET__}" printf 'CMUX_WORKSPACE_ID=%s\\n' "${CMUX_WORKSPACE_ID-__UNSET__}" printf 'CMUX_SURFACE_ID=%s\\n' "${CMUX_SURFACE_ID-__UNSET__}" - printf 'CMUX_AGENT_FORK_PARENT_SESSION_ID=%s\\n' "${CMUX_AGENT_FORK_PARENT_SESSION_ID-__UNSET__}" - printf 'CMUX_AGENT_FORK_LAUNCH_AT=%s\\n' "${CMUX_AGENT_FORK_LAUNCH_AT-__UNSET__}" - printf 'CMUX_AGENT_FORK_LAUNCH_ID=%s\\n' "${CMUX_AGENT_FORK_LAUNCH_ID-__UNSET__}" + printf 'CMUX_CODEX_HEADLESS=%s\\n' "${CMUX_CODEX_HEADLESS-__UNSET__}" } > "$FAKE_REAL_ENV_LOG" """, ) @@ -110,10 +105,6 @@ def run_wrapper( cat >/dev/null exit 0 fi -if [[ "${1:-}" == "hooks" && "${2:-}" == "codex" && "${3:-}" == "monitor" ]]; then - printf 'fork-watch=%s\\n' "$*" >> "$FAKE_CMUX_LOG" - exit 0 -fi exit 1 """, ) @@ -130,13 +121,6 @@ def run_wrapper( env["CMUX_WORKSPACE_ID"] = "22222222-2222-2222-2222-222222222222" env["CMUX_SOCKET_PATH"] = str(socket_path) env["CMUX_BUNDLED_CLI_PATH"] = str(bundled_cli) - for key in ( - "CMUX_CUSTOM_CODEX_PATH", - "CMUX_CODEX_WRAPPER_SHIM", - "CMUX_CODEX_WRAPPER_SHIM_ROOT", - "CMUX_CODEX_HOOK_CMUX_BIN", - ): - env.pop(key, None) env["FAKE_REAL_ARGS_LOG"] = str(real_args_log) env["FAKE_REAL_ENV_LOG"] = str(real_env_log) env["FAKE_CMUX_LOG"] = str(cmux_log) @@ -160,14 +144,6 @@ def run_wrapper( else: env.pop("SUBROUTER_CODEX_RESUME_COMMAND", None) env.pop("CMUX_AGENT_LAUNCH_SUBROUTER_CODEX_RESUME_COMMAND", None) - if fork_parent_session_id is not None: - env["CMUX_AGENT_FORK_PARENT_SESSION_ID"] = fork_parent_session_id - env["CMUX_AGENT_FORK_LAUNCH_AT"] = str(time.time()) - env["CMUX_AGENT_FORK_LAUNCH_ID"] = fork_launch_id or "fork-launch-test" - else: - env.pop("CMUX_AGENT_FORK_PARENT_SESSION_ID", None) - env.pop("CMUX_AGENT_FORK_LAUNCH_AT", None) - env.pop("CMUX_AGENT_FORK_LAUNCH_ID", None) try: proc = subprocess.run( @@ -178,13 +154,6 @@ def run_wrapper( text=True, check=False, ) - if fork_parent_session_id is not None: - deadline = time.monotonic() + 2 - while time.monotonic() < deadline: - lines = read_lines(cmux_log) - if any("fork-watch=" in line for line in lines): - break - time.sleep(0.01) finally: if test_socket is not None: test_socket.close() @@ -286,39 +255,6 @@ def test_direct_fork_is_instrumented(failures: list[str]) -> None: ) -def test_direct_fork_starts_identity_watch(failures: list[str]) -> None: - parent = "0198f073-0a5b-7000-8000-000000000059" - code, _, cmux_log, observed_env, stderr = run_wrapper( - socket_state="live", - argv=["fork", parent], - fork_parent_session_id=parent, - fork_launch_id="fork-launch-test", - ) - expect(code == 0, f"fork-watch: wrapper exited {code}: {stderr}", failures) - for marker in ( - "CMUX_AGENT_FORK_PARENT_SESSION_ID", - "CMUX_AGENT_FORK_LAUNCH_AT", - "CMUX_AGENT_FORK_LAUNCH_ID", - ): - expect( - observed_env.get(marker) == "__UNSET__", - f"fork-watch: {marker} leaked into nested Codex process: {observed_env}", - failures, - ) - expect( - any( - "hooks codex monitor" in line - and "--fork-parent" in line - and parent in line - and "--fork-launch-id fork-launch-test" in line - and "--fork-owner-pid" in line - for line in cmux_log - ), - f"fork-watch: wrapper did not start a parent-correlated watcher: {cmux_log}", - failures, - ) - - def test_explicit_disable_still_bypasses_hooks(failures: list[str]) -> None: code, real_argv, cmux_log, _, stderr = run_wrapper( socket_state="stale", @@ -411,17 +347,42 @@ def test_subrouter_marker_is_bound_to_current_launch_argv(failures: list[str]) - ) +def test_headless_marker_follows_the_subcommand(failures: list[str]) -> None: + # The agent message hooks skip headless runs, so an exec run in the same + # pane never takes messages meant for the interactive session. + cases = [ + (["exec", "hi"], "1"), + (["e", "hi"], "1"), + (["-m", "gpt", "exec", "hi"], "1"), + (["--add-dir", "../lib", "exec", "hi"], "1"), + (["--local-provider", "ollama", "exec", "hi"], "1"), + (["--remote-auth-token-env", "TOKEN", "exec", "hi"], "1"), + (["-i", "shot.png", "exec", "hi"], "1"), + (["--image", "shot.png", "exec", "hi"], "1"), + (["fix this"], "0"), + (["--add-dir", "exec", "fix this"], "0"), + (["--", "exec"], "0"), + ] + for argv, expected in cases: + _, _, _, observed_env, stderr = run_wrapper(socket_state="stale", argv=argv) + expect( + observed_env.get("CMUX_CODEX_HEADLESS") == expected, + f"headless {argv}: expected {expected}, got {observed_env.get('CMUX_CODEX_HEADLESS')} ({stderr})", + failures, + ) + + def main() -> int: failures: list[str] = [] test_every_resume_route_is_instrumented(failures) test_direct_fork_is_instrumented(failures) - test_direct_fork_starts_identity_watch(failures) test_explicit_disable_still_bypasses_hooks(failures) test_stale_socket_fresh_launch_is_instrumented(failures) test_restore_tokens_do_not_gate_instrumentation(failures) test_injection_failure_preserves_cmux_context(failures) test_non_session_command_still_bypasses_hooks(failures) test_subrouter_marker_is_bound_to_current_launch_argv(failures) + test_headless_marker_follows_the_subcommand(failures) if failures: print("FAIL: Codex session-entrypoint wrapper reliability checks failed") for failure in failures: