The codex harness
crucible --harness codex (or [agent].harness = "codex") runs the turn with OpenAI's Codex CLI
instead of Claude Code. Everything downstream of the decoder is unchanged: the turn still emits
AgentEvent NDJSON, and keep/discard still reads the same Result.
[agent]
harness = "codex"
[agent.codex]
# The shared `[agent].model` names a Claude model, so a codex domain overrides it here.
model = "gpt-5.6-sol"
# auto (default), api, or chatgpt
auth = "api"
# Which of the deployment's OpenAI keys to use; unset = the unnamed default key.
api_key = "WORK"
What differs from a claude turn:
- No session resume.
codex exec resumeis not wired; a logical session's second turn errors rather than silently starting fresh. - No OTEL.
codex execexports no metrics, so cost is the pricing-table estimate over the token usage the live--jsonstream reports, not anotel_summary. - Egress. The codex arm adds
chatgpt.com,auth.openai.com,api.openai.com, andab.chatgpt.comto the sandbox allowlist. Those hosts are per-harness: a claude turn's allowlist is byte-identical to what it was before codex existed.
Auth selection
Crucible supports both Codex login methods. [agent.codex].auth controls selection:
auto(default) uses the selected non-empty API key and otherwise falls back to ChatGPT OAuth.apirequires the selected API key and never silently falls back.chatgptuses the OAuth flow even when API keys are present.
[agent.codex].api_key names one of the deployment's OpenAI keys (uppercase letters, digits, and
underscore); unset selects the unnamed default. The manifest names a key, never the variable
carrying it: the host resolves api_key = "WORK" to OPENAI_API_KEY_WORK and an unset api_key
to OPENAI_API_KEY, so a manifest cannot reach any other host credential, and an openshell
provider that can deliver the key later replaces the resolution without a manifest change.
A Kubernetes deploy profile injects independently rotatable keys under those names:
[[secret_env]]
name = "OPENAI_API_KEY_WORK"
secret = "crucible-openai-work"
key = "OPENAI_API_KEY"
[[secret_env]]
name = "OPENAI_API_KEY_PERSONAL"
secret = "crucible-openai-personal"
key = "OPENAI_API_KEY"
Switch api_key in the manifest (or select a deployment profile carrying that manifest) to
choose a key for newly created turns. Use dedicated project-scoped keys and rotate their
Kubernetes Secrets independently. The selected key is not exported into the sandbox environment,
but Codex can read it from its seeded auth file.
ChatGPT OAuth: CODEX_CREDENTIALS and the single-refresher rule
Codex authenticates against the ChatGPT backend with a personal subscription, not Vertex. The
credential is an OAuth pair produced by codex login on a host with a browser, stored at
~/.codex/auth.json.
Setup:
-
codex loginon your machine, then confirm~/.codex/auth.jsonexists. -
Ship its verbatim contents to the loop process as the
CODEX_CREDENTIALSenv var. In cluster that is asecretKeyRef, exactly howGCLOUD_CREDENTIALSis delivered:oc create secret generic codex-auth \ --from-file=auth.json="$HOME/.codex/auth.json" -
Reference it from the deploy profile's
[[secret_env]].
At the top of every turn the loop process performs the OAuth refresh_token grant against
https://auth.openai.com/oauth/token and seeds the result into the sandbox as
$CODEX_HOME/auth.json: access token, account id, id token, and a placeholder refresh token.
Provider-delivered env cannot carry it, because the sandbox sees only an openshell:resolve:env:
placeholder that the L7 egress proxy would have to resolve, and codex reaches the backend over a
WebSocket through an L4 tunnel. All four tokens fields have to be present, or codex drops the
object and runs unauthenticated into a 401 loop.
The single-refresher rule: the refresh token never leaves the loop process. Exactly one thing
performs the grant, so there is no rotation race between the host's copy and a sandbox's copy of
auth.json (OpenAI rotates the refresh token on each grant, and a stale copy is dead). The
consequence is that a sandbox holds a fixed short-lived access token: the seeded auth.json is
read at exec, so a turn that outlives the access token fails loudly rather than silently
reauthenticating. That is accepted for now.
Each grant's rotated refresh token is persisted to
$HOME/.config/crucible/codex-credentials.json, and that file takes precedence over
CODEX_CREDENTIALS on the next mint. The env secret is seed material for the first mint only:
the first rotation spends it, so a fresh process with a fresh $HOME needs a freshly minted
secret (re-run codex login and replace it as part of any pod restart). Two loop processes must
never share one credential; each needs its own codex login.
Independently, an unused refresh token goes stale after roughly a week. When mints fail the grant
with refresh_token_expired, re-run codex login on the host, replace the secret, and delete
the state file if the process persists a home directory.
In auto mode this OAuth machinery remains the fallback when the selected API key is absent or
empty; chatgpt selects it explicitly.