fix(probe): Codex replies over the local route inside its sandbox; init --uninstall restores ~/.codex

Merged
KJ
9d ago
+1167-128

Fixes two problems the agent-relay@15.0.0-next.4 end-to-end test of relay init --tools codex found.

1. Codex could not reach the session socket from its sandbox, then replied through the wrong route

Root cause. On macOS Codex's default sandbox (workspace-write/read-only, network off) is a Seatbelt profile that denies every Unix-socket connect. The injected reply hint tells Codex to run the probe's own client (agent-relay-probe relay socket-request --socket <session socket> …, piped from printf; relay read <ref> for pointer messages). Both connect to the core's session socket, so they failed inside the sandbox with "Agent Relay's session socket is unavailable". The hint for a session with unknown MCP access also names send_relay_message, and Codex used the account-level codex_apps.agent_relay_in_chatgpt.send_relay_message connector as a fallback. That connector sends as the person's Cloud account into a different workspace.

Mechanism chosen: a Codex exec-policy rules file, not a sandbox widening. I checked this against Codex 0.163.0 (rust-v0.163.0 source, plus the installed binary):

  • [sandbox_workspace_write] only has writable_roots, network_access (all or nothing), exclude_tmpdir_env_var and exclude_slash_tmp. It has no Unix-socket allowance.
  • A path allowlist (network.allow_unix_sockets, sandboxing/src/seatbelt.rs) does exist, but only in [permissions.<name>.network] permission profiles (selected with default_permissions, which replaces the user's whole sandbox setup) or in managed network policy. Both are too invasive for relay init to write into someone's config.
  • In core/src/exec_policy.rs, Decision::Allow gives ExecApprovalRequirement::Skip { bypass_sandbox: true } when every parsed command segment matches an allow rule from $CODEX_HOME/rules/*.rules.

So while codex_stop_hook is on, the core also writes ~/.codex/rules/agent-relay.rules (agent-relay-dev.rules for Dev) with exactly two prefix_rules:

  • [<this probe>, "relay", "socket-request", "--socket", <this core's session socket>]
  • [<this probe>, "relay", "read"]

Nothing else leaves the sandbox and no network access is granted. socket-request only allows its own method/path list and checks that the peer runs as the same uid. The file starts with an Agent Relay header, and a file of that name without the header is never rewritten or removed. The core rewrites it at start, so a moved or updated probe is picked up. codex execpolicy check confirms both rules match and that a different --socket does not.

Tradeoff: the probe's two client commands run unsandboxed rather than inside a sandbox with a socket hole punched in it. They are our own binary, limited to our own socket, so this is far narrower than network_access = true. A command that is not exactly this shape (another path to the probe, extra pipes) still gets Codex's normal escalation prompt.

Other changes:

  • relay socket-request --body <text>. Reply hints (DM, Relay Connect, channel) are now one command with no printf … | pipe, because Codex only bypasses the sandbox when every segment of a command matches a rule.
  • Every reply hint now ends with this line:
    [route: answer only as shown above, through this computer's Agent Relay; never through any other Agent Relay connector or app (such as Agent Relay in ChatGPT), which sends as someone else. If that fails, say so instead of sending another way.]
    The session preamble says the same thing.

2. init --uninstall left Codex leftovers

Root cause. Turning codex_stop_hook off only rewrote hooks.json without our hook. That left {"hooks":{}} behind, and that rewrite also created a .relay-desktop-backup copy of Relay's own file, because the backup was taken on every first write. The trust entries Codex's app-server had recorded in config.toml (hooks.state."<hooks.json>:stop:0:0", …) were never removed.

Fix. Turning it off now puts ~/.codex back:

  • Our hook's keys are listed through Codex's app-server (hooks/list) while the hook is still in hooks.json. The hook is then taken out, and exactly those hooks.state keys are cleared with config/value/write (value null). [hooks.state] or [hooks] is removed too, but only once it is empty. The person's own trust entries stay.
  • The backup is now taken only of a file that does not already hold the hook, so it is always the person's original. If they haven't changed the file since, it is restored byte for byte and the backup is deleted. A hooks.json the core created is deleted once it is empty. An earlier build's self-backup is dropped.
  • A config.toml that trusting the hook created (recorded as codex-config-created in the core's data dir) is deleted once nothing else is in it.
  • The rules file is removed. rules/ is removed only if it is then empty.

Tests

  • New Rust tests in codex/stop_hook.rs:
    • fresh install → off leaves the Codex home tree identical
    • a person's own hooks.json (non-canonical formatting), config.toml with their own trust entry, and rules/default.rules survive byte for byte
    • a hooks.json edited after install keeps the edit
    • an earlier build's self-backup is removed
    • the rules allow only the two commands
    • a foreign agent-relay.rules is left alone
  • An #[ignore]d live test, real_codex_untrust_puts_config_toml_back_byte_for_byte, runs against the real Codex 0.163 app-server and passes.
  • cargo test --no-default-features --features production-binaries: lib 2361 passed / 0 failed on a clean run. Every integration test binary passes, including relay_agents (114) and relay_outbound. On a second run, three timing tests (devin/grok startup and leader) flaked under load. They pass when re-run alone and are untouched here.
  • cargo clippy --all-targets: no warnings in the touched files.
  • init.test.ts: 95/95. tsc --noEmit for packages/cli: clean. eslint on init.ts/init.test.ts: 0 errors (4 warnings, all pre-existing).

End-to-end proof (macOS, Codex 0.163.0, default sandbox, no approval)

Setup was isolated: HOME, AGENT_RELAY_HOME, AGENT_RELAY_INSTALL_DIR and CODEX_HOME under /tmp/arcx, with only auth.json copied in. The project was ~/arcx-project. I used the CLI built from this branch with AGENT_RELAY_PROBE_PATH pointing at the locally built probe, and the local engine on port 8797.

  1. relay init --yes --tools codex wrote the hooks, rules/agent-relay.rules and two hooks.state trust entries.
  2. Interactive codex (default sandbox, project trusted through Codex's own prompt) auto-joined as codex-arcx-project-reply-just.
  3. relay message dm send codex-arcx-project-reply-just "… reply … pong" --as alice sent message 241608806540062720. The injected text ended with the new [route: …] line.
  4. Codex ran agent-relay-probe relay socket-request --socket '…/run/relay.sock' --method POST --path '/send?address=alice@direct&reply_to=241608806540062720' --body 'pong' with no escalation request and no approval prompt. The result was {"ok":true, "message_id":"241608826450423808"}. relay message inbox check --as alice shows pong from the Codex session.
  5. Control: codex sandbox -- <probe> relay socket-request … --path /agents fails with "Agent Relay's session socket is unavailable", so the sandbox still blocks the socket and only the rule lets the command through.
  6. relay init --uninstall: hooks.json, rules/ and every hooks.state entry are gone, with no backup left. config.toml keeps only Codex's own [tui]/[projects] entries, and the marker is removed.
  7. Pre-existing user files: hand-written hooks.json, config.toml (with comments) and rules/default.rules, then init → uninstall. cmp shows all three identical.

All processes, tmux servers, the Codex app-server daemon and the directories were removed afterwards.

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.


Agent Relay sessions

  • claude session 8ab900cc · opened via gh pr create · last active 2026-10-29