fix(probe): tell a starting Codex session its Agent Relay route

Merged
KJ
8d ago
+612-56

Problem

A fresh Codex 0.163 session set up by relay init --tools codex didn't know the local Agent Relay route until a relayed message arrived. Asked to "send a DM over Agent Relay to ", it called the account-level ChatGPT connector codex_apps.agent_relay_in_chatgpt.send_relay_message. That connector sends as the person's Cloud account into another workspace, and it asks for approval. After one injected message (with its [reply: …] and [route: …] lines), Codex used the local route correctly (#2019's rules file).

Mechanism: a SessionStart entry in the hook the core already owns

Codex 0.163 supports SessionStart command hooks that return hookSpecificOutput.additionalContext. I confirmed this three ways:

  • the binary's session-start.command.input/output schemas
  • codex-rs/core/src/hook_runtime.rs at rust-v0.163.0
  • hooks/list, which names the event sessionStart

Codex runs it once each time a session starts (startup, resume, clear, compact, fork), at the start of that session's first turn, and records the context for the model. It never runs per prompt, so there's nothing to spam. For that reason I chose it over UserPromptSubmit, and over editing the user's ~/.codex/AGENTS.md.

The SessionStart entry is added to the hook the core already installs, trusts and removes (codex_stop_hook: EVENTS is now Stop, PostToolUse, SessionStart):

  • an older install is brought up to date at core start
  • turn-off and relay init --uninstall take all three entries out the same way
  • trust treats sessionStart as optional, so a Codex that doesn't list it still has the other two trusted

At SessionStart, POST /codex/stop-hook answers with hosted_mcp::codex_preamble. It contains:

  • the session's name and address on the relay

  • the exact allowed command shapes, using the probe's own client on this core's socket (the space-free ~/.agentworkforce/desktop/bin/agent-relay-probe link from #2024 while the core keeps one, via fetch::probe_executable). These are the commands Agent Relay's Codex sandbox rules allow without a prompt:

    • list agents: … relay agents --socket <sock>
    • send a DM: … relay send --socket <sock> --to '<name>' --body 'your message', with --address and --reply-to as relay send takes them
    • while the session has no name yet: … relay whoami --socket <sock>, and the instruction to retry a send refused as "not on the relay" the same way, never another way

    Since #2031 these are the probe's fixed-route session commands. #2031's Codex rules allow only those; socket-request has no rule. every_codex_start_command_is_allowed_by_the_codex_rules checks each command the preamble hands a session against the SandboxRules patterns.

  • the route rule: never use any other Agent Relay connector or app (it names Agent Relay in ChatGPT), and if the route fails, say so instead

  • the messaging norm

Only a hook below the Codex that holds the thread's writer lock is answered, as for Stop. Sessions that aren't on the relay and that activation wouldn't admit get {}.

A session usually starts before the core's next activation pass has registered it. So for a session that activation admits, the hook runs that pass now and waits up to 5 s for the name. If no name arrives in time, it still answers, with a "joining" variant that says how to read the name. The hook's client waits 8 s at SessionStart, within the entry's 10 s timeout. The hook now forwards Codex's cwd, so activation's folder check works before the directory has indexed the new thread. Without that, the live run got {} here.

Tests

  • relay_outbound.rs: a starting Codex session gets {} before it's on the relay, then a preamble naming it, with the exact probe commands, no curl and the route rule. A process outside the session, or one naming another thread, is refused. PostToolUse is unchanged.
  • relay_outbound.rs: a renamed session is told its current alias and the /whoami address.
  • relay_outbound.rs, a_codex_session_starting_before_activation_is_put_on_the_relay:
    • with no folder, an unregistered thread gets {}
    • with Codex's cwd and registration held past the wait, it gets the joining preamble
    • once registered, the next start names it
  • stop_hook.rs:
    • install adds SessionStart after the person's own SessionStart hook
    • an older Stop-only copy gains it
    • new: an install from before SessionStart is updated at start and still leaves the Codex home byte-for-byte as before when turned off
    • the fresh-install byte-for-byte test now checks all three events
    • {"hooks": {"SessionStart": []}} comes back byte for byte
  • hosted_mcp.rs: the preamble text, and the joining variant.
  • trust.rs: the ignored real_codex_untrust… test against a real Codex 0.163 app-server now trusts three hooks and still restores config.toml byte for byte (run locally: pass).
  • Test runs (empty HOME):
    • cargo test --locked --no-default-features --features production-binaries: all pass
    • cargo test --locked --no-fail-fast: everything passes except history_flush's two wall-clock deadline tests. Those fail locally under load average ~16 and touch nothing in this PR. relay_outbound and relay_agents pass in both configs.
  • The CLI's Codex delivery line now reads "PostToolUse, Stop and SessionStart hooks"; its test is updated.

Live e2e after #2031 (fixed-route commands)

  • Setup: isolated homes; projects under ~ with git init and .agentworkforce/; probe and CLI built locally. Hosted workspace khaliqgant-rq9a-project; A ran relay init --hosted, B ran relay init --key '…'.
  • The Codex rules held only relay send/agents/whoami/read/fetch-attachment/connect-send/subscribe-channel patterns, and no socket-request.
  • First session on A's fresh core. It got the joining preamble, checked relay whoami until it was named, then ran relay send --socket … --to … with no prompt. That send was refused only because I gave it the wrong agent name.
  • Fresh session codex-rq9a-project-session-3. Its tool calls were a read of the account plugin's SKILL.md, then relay send --socket '<A sock>' --to 'codex-rq9b-project-reply-just' --body 'hello-from-A' → message 242090654709260288. No prompt, and no codex_apps / agent_relay_in_chatgpt call.
  • B received it. B replied with relay send … --address … → message 242090980683530240, which was injected into A.
  • Uninstall left no hooks.json, rules or trust entries in either Codex home. Nothing was written to the real home.

Live two-user e2e, before #2031 (macOS, Codex 0.163, isolated HOME/AGENT_RELAY_HOME/CODEX_HOME/XDG, built probe and CLI)

  • Hosted workspace rq7-e2e-1030 on cast.agentrelay.com. It is left there.
  • User A: relay init --hosted --name rq7-e2e-1030 --tools codex. User B ran A's printed relay init --url https://cast.agentrelay.com --key … --name rq7-e2e-1030 (companion CLI PR #2028).
  • B's Codex session codex-rq7b-project-session: its first prompt got the preamble at SessionStart. Its rollout carries "This session is on Agent Relay as @codex-rq7b-project-session …".
  • A fresh A session with no relayed message (0 [via Agent Relay lines in its rollout). Its first prompt was "Send a DM over Agent Relay to codex-rq7b-project-session saying exactly hello-from-A". A wasn't registered at SessionStart; the hook's activation pass registered it as codex-rq7a-project-session within 2.6 s, and the preamble named it.
    • Codex ran agent-relay-probe relay socket-request --socket '<A sock>' --method POST --path '/send?to=codex-rq7b-pr…' with no approval prompt. It made no codex_apps / agent_relay_in_chatgpt call (none in its rollout).
    • Result: message_id 242041658258460672, from codex-rq7a-project-session.
  • B's session received it injected ([via Agent Relay — from @codex-rq7a-project-session … · message 242041658258460672 …]).
  • B replied over the local route, message_id 242042011210366976. The reply was injected into A ([via Agent Relay — from @codex-rq7b-project-session to @codex-rq7a-project-session · message 242042011210366976 · ref 56fef8328807]).
  • relay init --uninstall in both:
    • hooks.json and rules/agent-relay.rules are gone, and every hooks.state trust entry is removed
    • the only config.toml change is Codex's own [tui] keys
    • Codex's own runtime files (sessions, sqlite) remain, as they would for any Codex run
  • Every process, tmux server, isolated home, project and the copied auth.json were removed afterwards.

🤖 Generated with Claude Code


Agent Relay sessions

  • claude session 8ab900cc · contributor · ran gh pr commands · last active 2026-10-31