Codex wrapper: one cmux hook group per event; Codex appends session-flag hooks to user layers

Merged
AU
2mo ago
+558-4

Closes https://github.com/manaflow-ai/cmux/issues/12081

Summary

The issue reports that cmux's per-event -c hooks.<event>=[...] injection replaces the user's configured hook arrays, so ~/.codex/hooks.json handlers stop running inside cmux. I reproduced the argv exactly as reported with the shipping 0.64.22 CLI, then measured what Codex actually does with it using the real installed binaries (codex-cli 0.153.4 from the ChatGPT app and 0.146.0 from npm) against a hermetic fake model provider, so a full codex exec turn runs with no credentials and no network.

Codex does not replace the user's hooks. Codex discovers hooks per configuration layer and appends them from lowest to highest precedence (codex-rs/hooks/src/engine/discovery.rs at rust-v0.153.4: for layer in config_layer_stack.layers_low_to_high() loads each layer's hooks.json and TOML [hooks] events and appends them; the first hooks engine from 2026-03-10 already iterated layers LowestPrecedenceFirst). cmux's -c hooks.<event>= values only define the session-flags layer. In every experiment the user handler and the cmux-style handler both fired:

Runcodexuser hook sourcecmux-style -c hooks.SessionStart=user hook firedoverride fired
A0.153.4$CODEX_HOME/hooks.jsonnone1×–
B0.153.4$CODEX_HOME/hooks.jsoncmux group only (today's shape)1×1×
C0.153.4$CODEX_HOME/hooks.jsonuser group copied + cmux group (this branch's inherited fix)2×1×
146-A/B/C0.146.0same as A/B/Csame1× / 1× / 2×– / 1× / 1×
D1/D20.153.4[hooks] in config.tomlnone / cmux group only1× / 1×– / 1×
E1/E20.153.4trusted project .codex/hooks.jsonnone / cmux group only1× / 1×– / 1×

So the inherited fix on this branch (20aab5c0ce, which copied every user group from hooks.json into cmux's value) would have made Codex run each user handler twice, and it also changed the injected shape that AgentLaunchSanitizerCodexLaunch strips from saved resume argv. This PR replaces it. Codex dispatches an event's handlers together (FuturesUnordered in dispatcher.rs) and only orders the results, so the docs describe registration order, not execution order.

What changed

  • CLI/CMUXCLI+CodexFireAndForgetHooks.swift: back to exactly one cmux group per injected event (removes the user-hook reader, the JSON→TOML re-encoder, and the combined branch; the file is 334 lines). The emitter's doc comment states the verified layering contract. Relative to main this is a comment-only change; runtime behavior is unchanged.
  • docs/agent-hooks.md, new "Codex wrapper precedence" section next to the environment-override table: the flags are added only when at least one cmux event is not covered by a persistent handler (a complete cmux hooks codex install makes the wrapper add nothing, keeping features.hooks = false intact); per-layer append with user and project handlers registered before cmux's and dispatched together; nothing in hooks.json/config.toml is replaced or rewritten; cmux deliberately does not copy handlers (double execution); the trust-bypass and same-layer -c trade-offs; the CMUX_CODEX_HOOKS_DISABLED=1 opt-out and its cost.
  • tests/test_codex_wrapper_hook_append.py (behavior tests against the built CLI):
    • test_injected_hooks_do_not_redeclare_user_hooks: user hooks.json handlers on all twelve Codex hook events, the six issue events with awkward commands (quotes, backslashes, ''', newline); asserts the activation flags are followed only by -c hooks.<event>= pairs, the injected event set equals the CLI's own baseline, each event carries exactly one cmux group, no user command leaks into the args, and hooks.json is byte-identical afterwards.
    • test_persistent_cmux_hook_is_not_duplicated: a persistently installed cmux SessionStart handler plus a user Stop handler; asserts SessionStart is skipped, Stop still carries only cmux's group, and hooks.json is untouched.
    • test_live_codex_runs_user_hooks_and_cmux_hook_once_each: runs the real codex through Resources/bin/cmux-codex-wrapper with a throwaway CODEX_HOME/HOME, CMUX_AGENT_HOOK_STATE_DIR, a fake cmux socket, an argv-capturing codex shim, a logging shim in front of the cmux CLI, and an in-process fake Responses API server. User handlers live in every documented user layer: hooks.json (SessionStart/UserPromptSubmit/Stop), the [hooks] table in config.toml, and a trusted project's .codex/hooks.json (the temp path is canonicalized because Codex trusts a project by its resolved path). Asserts the live argv shape, that each of the five user handlers ran exactly once, and that cmux's inject-args, session-start, prompt-submit, and stop CLI calls each happened exactly once, plus socket delivery and the hook ledger. Skips with an explicit message when no real codex is installed; CMUX_TEST_REQUIRE_REAL_CODEX=1 turns that into a failure where Codex is provisioned. Python 3.9 compatible.

Before

Shipping 0.64.22 CLI, throwaway CODEX_HOME with user SessionStart/UserPromptSubmit/Stop hooks:

$ /Applications/cmux.app/Contents/Resources/bin/cmux version
cmux 0.64.22 (102) [ddd4a01bc]
$ env -i PATH="$PATH" HOME=$R/home CODEX_HOME=$R/codex-home /Applications/cmux.app/Contents/Resources/bin/cmux hooks codex inject-args | tr '\0' '\n'
--enable
hooks
--dangerously-bypass-hook-trust
-c
hooks.SessionStart=[{hooks=[{type="command",command='''/Users/austinwang/.cmux/hooks/cmux-codex-hook-e06e72bed0843fff-session-start.sh''',timeout=10000}]}]
-c
hooks.UserPromptSubmit=[{hooks=[{type="command",command='''/Users/austinwang/.cmux/hooks/cmux-codex-hook-8928b8abed1344dd-prompt-submit.sh''',timeout=10000}]}]
-c
hooks.Stop=[{hooks=[{type="command",command='''/Users/austinwang/.cmux/hooks/cmux-codex-hook-0e69914d13c969d6-stop.sh''',timeout=10000}]}]
... (PreToolUse, PostToolUse, PermissionRequest)

That argv is what the issue calls a replacement. Feeding the same shape to the real codex (Run B) shows the user's hooks.json SessionStart handler still runs; the inherited copy approach (Run C) runs it twice:

--- B: user-hooks.log ---                       --- C: user-hooks.log ---
03:31:39 user-hook session-start pid=13882      03:31:43 user-hook session-start pid=15424
03:31:39 user-hook user-prompt-submit pid=14120 03:31:43 user-hook session-start pid=15414
03:31:40 user-hook stop pid=14205               03:31:44 user-hook user-prompt-submit pid=15712
--- B: override-hook.log ---                    03:31:45 user-hook stop pid=16093
03:31:39 override-hook SessionStart pid=13884   --- C: override-hook.log ---
                                                03:31:43 override-hook SessionStart pid=15427

Verification (tagged dev build issue-12081-codex-hook-append, HEAD 8ca951b4e8)

Build: CMUX_SKIP_ZIG_BUILD=1 /Users/austinwang/manaflow/cmuxterm-hq/scripts/reload-cloud.sh --tag issue-12081-codex-hook-append --no-dev-backend --launch (run id issue-12081-codex-hook-append-43628368be81, BUILD_OK).

1. Which binary was tested

$ ".../cmux DEV issue-12081-codex-hook-append.app/Contents/Resources/bin/cmux" version
cmux 0.64.22 (102) [8ca951b4e]

2. Headless: real codex 0.153.4 through the built app's own Resources/bin/cmux-codex-wrapper (throwaway CODEX_HOME with user SessionStart/UserPromptSubmit/Stop marker hooks, fake cmux socket, CMUX_AGENT_HOOK_STATE_DIR, argv captured by a CMUX_CUSTOM_CODEX_PATH shim):

== live codex argv ==
--enable / hooks / --dangerously-bypass-hook-trust
-c hooks.SessionStart=[{hooks=[{type="command",command='''/Users/austinwang/.cmux/hooks/cmux-codex-hook-db803fdff268bdd9-session-start.sh''',timeout=10000}]}]
-c hooks.UserPromptSubmit=[{hooks=[{type="command",command='''/Users/austinwang/.cmux/hooks/cmux-codex-hook-051e29da752a9030-prompt-submit.sh''',timeout=10000}]}]
-c hooks.Stop=[{hooks=[{type="command",command='''/Users/austinwang/.cmux/hooks/cmux-codex-hook-e3e89644cfb90c4f-stop.sh''',timeout=10000}]}]
-c hooks.PreToolUse=... -c hooks.PostToolUse=... -c hooks.PermissionRequest=... -c hooks.SubagentStart=... -c hooks.SubagentStop=...
exec --skip-git-repo-check -s read-only "reply with the word ok"
== per-event check: exactly one cmux group, no user handler copied: OK for all 8 events
== user hook markers (each exactly once) ==
SessionStart
UserPromptSubmit
Stop
== fake cmux socket requests from cmux's hooks == ['surface.list', 'surface.list', 'surface.list', 'agent.resolve_delivery_target', ...]

(With the fake socket, target resolution cannot succeed, so the throwaway ledger records the failure timestamps rather than a session; the real-app run below shows the full record.)

3. Before evidence: see "Before" above (0.64.22 argv, and Runs B/C against the real codex).

4. Full integration: real codex inside a terminal surface of the tagged dev app (tag-bound socket /tmp/cmux-debug-issue-12081-codex-hook-append.sock, CMUX_TAG=... scripts/cmux-debug-cli.sh):

$ identify        -> bundle_identifier com.cmuxterm.app.debug.issue.12081.codex.hook.append
$ version         -> cmux 0.64.22 (102) [8ca951b4e]
$ new-surface --workspace workspace:1 --working-directory $R/work --focus true   -> OK surface:2 pane:1 workspace:1
$ send --workspace workspace:1 --surface surface:2 "env CODEX_HOME=$R/codex-home CMUX_AGENT_HOOK_STATE_DIR=$R/app-state CMUX_CUSTOM_CODEX_PATH=$R/app-argv-shim codex exec --skip-git-repo-check -s read-only 'reply with the word ok'; echo CODEX_EXIT=\$?\n"
$ capture-pane    -> ... hook: Stop Completed / tokens used / 2 / CODEX_EXIT=0
== live argv inside the surface: same eight -c values as step 2 (one cmux group each), then exec ...
== user hook markers ==
05:07:46 user-hook session-start pid=37577 payload_bytes=535
05:07:46 user-hook user-prompt-submit pid=37589 payload_bytes=603
05:07:46 user-hook stop pid=37652 payload_bytes=642
== ledger (tagged CLI: sessions list --agent codex --state-dir $R/app-state --json) ==
session_id 01a080ea-c86c-7750-b4b0-3b41d88da429, workspace_id B3F20890-…, surface_id 2A192015-…, cwd $R/work, codex_transcript_found true
== tagged app debug log ==
agentHook.start agent=codex subcommand=session-start session=01a080ea-c86 … envWorkspace=1 envSurface=1
agentHook.target.resolved agent=codex subcommand=session-start … workspace=B3F20890-D6D surface=2A192015-F55
agentHook.start agent=codex subcommand=prompt-submit …   agentHook.target.resolved … prompt-submit …
agentChat.hook session=01a080ea event=SessionStart source=codex
agentHook.start agent=codex subcommand=stop …            agentHook.target.resolved … stop …
agentHook.stop.notify.sent agent=codex session=01a080ea-c86 response=OK
agentChat.hook session=01a080ea event=Stop source=codex   agentChat.hook session=01a080ea event=UserPromptSubmit source=codex

The user's hooks ran, and cmux's lifecycle registration still bound the session to the dev app's workspace and surface, sent the Stop notification, and fed the agent chat events.

5. Python wrapper tests against the built CLI

$ cd tests && CMUX_CLI_BIN=".../cmux DEV issue-12081-codex-hook-append.app/Contents/Resources/bin/cmux" python3 test_codex_wrapper_hook_append.py
PASS: injected Codex hooks carry one cmux group per event and no user handlers
PASS: persistent cmux hooks are not duplicated and user hooks are not copied
PASS: real codex ran user hooks and cmux's hook exactly once each
$ ... python3 test_codex_wrapper_resume_hooks.py     -> PASS: every cmux-owned Codex session entrypoint retains SessionStart and Stop hooks
$ ... python3 test_codex_wrapper_computer_use_mcp.py -> PASS: codex wrapper injects cmux-cua MCP

The same three tests were also run against the shipped 0.64.22 CLI as a harness check (argv and live tests pass; the persistent-dedup test fails there as expected, since that release predates main's persistent-producer skip from #10838).

CI

ci.yml is path-gated for pull requests, so on the final head the required ci-status comes from the fallback workflow, and Web complexity and the CLA checks pass. The full lane ran on 3bd392c137 (which touched ci.yml): its "Build for runtime regressions" step passed (compile evidence for the Swift change), then "Validate Swift warning budget" failed on main's own unbudgeted warnings in files this PR does not touch, exactly as on main's dispatched run (#12159). Earlier runs failed web-typecheck on main's import.meta.main error (#12147), fixed by #12154 and merged back here.

Trade-offs (stated, not absorbed)

  1. No merge for a user's own -c hooks.<event>= argument. That is the one real replacement in this design: cmux prepends its flags, so a later user -c hooks.<event>= wins within the session-flags layer and cmux loses that event. Documented rather than implemented; merging would require the wrapper to parse arbitrary user TOML on the command line, and cmux hooks codex install already covers users who need both.
  2. --dangerously-bypass-hook-trust stays. Pre-existing and required for cmux's session-flag handlers to run at all; the docs now say plainly that it also skips review for user and project handlers.
  3. CI wiring deferred. Running the new test in the Run CLI no-socket regressions step requires touching ci.yml, which routes every CI area and currently turns the required ci-status red on main's warning-budget breakage (#12159). The one-line wiring is a follow-up once that lane is green; until then the test runs locally against the built CLI (outputs above).
  4. The live test needs a real codex. It skips with an explicit message on runners without one; CMUX_TEST_REQUIRE_REAL_CODEX=1 makes the prerequisite mandatory where Codex is provisioned. The argv-level tests always run.
  5. Branch history is additive. The inherited commits (337b2c5198 test, 20aab5c0ce fix) were already on the shared branch, so they are kept and superseded rather than rewritten. 84a03c67d5 (new test) is red against 20aab5c0ce by construction and green from fa909576a4 on. The inherited fix was never built; its emitted value shape was reproduced by hand in Run C.
  6. Sibling PR consolidated. #12141 (another agent session, opened a minute after this one) reached the same conclusion; its author converted it to a draft in favor of this PR, and its review points are folded in.
  7. Dev build ran with --no-dev-backend. The default per-tag dev web backend host (cmux-dev-backend-1) does not resolve from this Mac; the backend serves the app's cloud features, which this verification does not touch. I also cleared an orphaned build-queue lock (created 03:05 by an xctest run that had exited; 97 minutes old) before re-queueing.
  8. Follow-up, not fixed here (found on #12141): cmux writes timeout=10000/120000 in its -c values, but Codex parses timeout as seconds (timeout_sec in hook_config.rs). cmux's hook scripts return immediately, so the wrong unit is latent; changing the value also changes the shape AgentLaunchSanitizerCodexLaunch strips from saved argv, so it needs its own schema step.
  9. Localization audit: no user-facing strings changed (docs and code comments only); nothing to localize. No keyboard shortcuts involved. No iOS paths touched.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Qiy38q5XFYQU3CeccDENty