feat(agents): launch Codex for ChatGPT-plan agents
P21 of the rollout in "Bringing Codex or Claude Code Max plans into Luke" (https://tryluke.dev/stage/plan/89f19bfc-88b5-471a-886a-84b00180aa09). It adds the codex implementation of AgentSessions. Nothing routes to it yet (that's P24), so nothing reaches users.
What the brief asked
The same launch as P08 (#4255), with these differences:
- the runner runs in P20's
codexmode; ACCESS_TOKENcomes from P18'schatgpt_oauthrow, refreshed first when under 15 minutes remain;- a
tokenline goes ahead of a message when the token changed; - the session id is
luke-codex-<uuid>; native_sessionholds the Codex thread id.
Secrets go only in the environment or on stdin. An invalid or signed-out row fails cleanly and never falls back to another payer.
What changed
Shared core, factored out of P08: coder-host/runner-sessions.ts now holds the launch machinery that claude-code-sessions.ts had: open, checkout, bearer, bundle upload, the root launch dropped to agent, tags, send/relaunch, cancel, reset and runnerEnded. A harness supplies a RunnerHarness, which gives:
launch: the credential env, plus a non-secret stamp;config: the config line;ahead: the lines written before a message to a live runner.
claude-code-sessions.ts is now the Claude Code harness. Its behaviour is unchanged, and all 11 P08 tests pass as they are. It re-exports runnerEnded, RUNNER_ENDED and runnerBearerDigest, so P09's import keeps working. The Sandbox API fake moved from P08's test into testing/runner-sandbox-fake.ts so both suites can use it.
coder-host/codex-sessions.ts, codexSessions(options):
- Config line:
harness: "codex",executable: /usr/local/bin/codex, the OpenAI model id, the Codex effort when the agent's effort is one Codex accepts,developerInstructions,eventsUrl,mcpUrlandfirstSequence. On a relaunch it addsthreadId=native_session, which the runner uses forthread/resume.- The provider config (
model_provider,env_key = ACCESS_TOKEN, …) is written into argv by P20's runner and holds no secret. - A model from another provider is refused.
- The provider config (
- Env:
ACCESS_TOKEN,CODEX_HOME=/home/agent/.codex(P17), and the bearer. Nothing secret goes in argv, the config line, a tag or a log. - Token:
refreshChatGptAccess(userId, ring, 15 min)runs under P18's row lock. It returns the stored token, or renews it first when under 15 minutes remain. - Token line: before each message to a running runner, the token is read the same way. If the token differs from the one the runner holds,
{type: "token", accessToken}goes first, then themodelline, then the message. Otherwise onlymodeland the message go. The runner restarts app-server and callsthread/resumebetween turns (P20). - Session id:
luke-codex-<uuid>, on the samesessionId:tag. - Plan refusals: no row, an
invalidrow (signed out), an unreadable seal, or OpenAI unreachable each failopen/sendasfailedwith a new optionalplanRefusal(none|signed_out|unreadable|unreachable).- Status is 422, or 503 when OpenAI is unreachable.
- The sandbox is deleted and no runner starts. It never falls back to another payer.
- Test mode:
testApiKey, an OpenAI API key, replaces the plan token. It is never refreshed and never sent as a token line. This mirrors P08's Anthropic test key and is meant for Gate B2.
Choices made here
- How the runner's current token is tracked: Vercel allows at most 5 tags, and P08 already uses all of them. So the launch tag (renamed
lukeRunnerFirst→lukeRunnerLaunch) now carries<firstSequence>-<token expiry ms>. The token itself is never in a tag. Nothing runs onclaude_codeyet, so the rename strands no live sandbox. refreshChatGptAccessgains an optionalmargin: it is never shorter than the store's 10 minutes, and P18's callers are unchanged. The plan says 15 minutes for a launch, while P18 renews at 10.planRefusalon the sharedAgentSessionsfailed outcome: it is additive and optional, and eve never sets it. This carries "fail cleanly with that status" to the caller, so P25 can show it without parsing logs.- Recording the thread id in
native_sessionbelongs to the events route: P09's projection, with P22's Codex reader. This PR reads it on relaunch. The test records it the way the route will.
Checks
- New
codex-sessions.test.ts(7 tests) runs against the fake Sandbox REST API plus a fake OpenAI token endpoint, over PGlite and the TestClock. It covers:- the launch env and config, with no access/refresh/ID token or bearer in argv, stdin config, tags or the upload;
- a token with 12 minutes left renewed before the launch (past P18's 10-minute margin, so this fails without the 15-minute margin; checked);
- a token line sent only after the stored token was renewed, and once (fails if it is always sent; checked);
- send/cancel/reset;
- relaunch after a sandbox stop, with
threadId, the nextfirstSequence, a fresh bearer and a renewed token; - no row →
planRefusal: none, and an invalid row →signed_out, both with the sandbox deleted and no command; - test mode.
claude-code-sessions.test.ts: all 11 pass unchanged except for the fake's new import.- Locally:
- Service, web, dev and luke typecheck one at a time;
pnpm check:affected's combined typecheck was OOM-killed (137) in this 8 GB sandbox, as on P17/P18. - Lint, oxlint, discarded-effect, knip and
repository-checks.shpass. vitestovercoder-host,accountsandbrain-hostpasses: 51 files, 461 tests.
- Service, web, dev and luke typecheck one at a time;
- Preview: the developer waived per-PR Preview runs for this rollout until the cohort, so P21's Preview acceptance (launch, message, stop, resume, delete, token unreadable from a Terminal) is owed to the Gate B2 / live ChatGPT-plan run.
Out of scope: routing GPT agents to Codex (P24), projecting Codex's notifications (P22), and P08b's sandbox template.
Made with Luke