Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions CLI/CMUXCLI+AgentHookDefinitions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
}

Expand Down
8 changes: 5 additions & 3 deletions CLI/CMUXCLI+AgentMessages.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
54 changes: 46 additions & 8 deletions CLI/CMUXCLI+CodexFireAndForgetHooks.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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)
}
Expand Down Expand Up @@ -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<String> {
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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand All @@ -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.<event>=[{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: ","))]}]"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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),
Expand Down
Loading
Loading