feat(agents): launch Codex for ChatGPT-plan agents

Merged
CH
5d ago
+1838-934

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 codex mode;
  • ACCESS_TOKEN comes from P18's chatgpt_oauth row, refreshed first when under 15 minutes remain;
  • a token line goes ahead of a message when the token changed;
  • the session id is luke-codex-<uuid>;
  • native_session holds 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, mcpUrl and firstSequence. On a relaunch it adds threadId = native_session, which the runner uses for thread/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.
  • 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 the model line, then the message. Otherwise only model and the message go. The runner restarts app-server and calls thread/resume between turns (P20).
  • Session id: luke-codex-<uuid>, on the same sessionId: tag.
  • Plan refusals: no row, an invalid row (signed out), an unreadable seal, or OpenAI unreachable each fail open/send as failed with a new optional planRefusal (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 on claude_code yet, so the rename strands no live sandbox.
  • refreshChatGptAccess gains an optional margin: 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.
  • planRefusal on the shared AgentSessions failed 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_session belongs 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 next firstSequence, 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.sh pass.
    • vitest over coder-host, accounts and brain-host passes: 51 files, 461 tests.
  • 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


Devin Review