agents/codex.ts
Rama main · réplica de lectura del repositorio original.
/**
* Codex agent — secure harness around the `codex` CLI (`codex exec --json`).
*
* Security model, mirroring the opencode and claude harnesses:
* - native exec surfaces (`shell_tool`, `unified_exec`, code-mode, browser /
* computer use, plugins, lifecycle hooks) are turned off in `config.toml`'s
* `[features]` table. shell goes through the MCP `shell` tool, which runs
* with a filtered, secret-free env.
* - subagent fan-out (`multi_agent*`) is off: codex exposes no pre-tool hook
* carrying a subagent marker, so there is no way to reproduce the
* mutating-MCP-tool gate the other two harnesses enforce on subagents. an
* ungated subagent could call `checkout_pr` / `push_branch` behind the
* orchestrator's back — see action/agents/subagentToolGates.ts.
* - the sandbox stays ON (`approval_policy = "never"` rather than
* `--dangerously-bypass-approvals-and-sandbox`), so the native `apply_patch`
* tool is confined to the workspace even if the agent ignores instructions.
* - `CODEX_HOME` holds auth.json under PULLFROG_DATA_DIR, which the MCP shell's
* mount namespace tmpfs-overlays. NOTE codex exposes no path-deny surface of
* its own, so unlike claude this harness does NOT consume
* `ctx.secretDenyPaths` — the namespace overlay is the only layer here.
*
* Feature toggles never use `--enable` / `--disable`: an unrecognized name is a
* HARD arg-parse error on those (measured: `Error: Unknown feature flag`) but an
* ignored key in the config table, so a codex version that drops a flag would
* otherwise brick every run at startup.
*
* The boundary itself is held by `writeCodexConfig` marking the checkout
* `trust_level = "untrusted"`, which stops codex loading the repo's own
* `.codex/config.toml` — a layer that OUTRANKS ours and could otherwise
* re-enable the native shell from a file in the untrusted tree. The same
* settings also ride as `-c` flags (`securityOverrideFlags`) as defence in
* depth, since those outrank every layer and cost nothing.
*/
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { performance } from "node:perf_hooks";
import type { ThreadEvent, ThreadItem } from "@openai/codex-sdk";
import { pullfrogMcpName } from "../external.ts";
import {
getModelProvider,
getProviderGatewayUrl,
PROVIDER_GATEWAY_HEADERS_ENV,
stripProviderPrefix,
} from "../models.ts";
import { AGENT_ACTIVITY_TIMEOUT_MS, getIdleMs, markActivity } from "../utils/activity.ts";
import { log } from "../utils/cli.ts";
import { installCodexHome } from "../utils/codexHome.ts";
import type { OAuthWriteback } from "../utils/codexRefreshDetect.ts";
import { installFromNpmTarball } from "../utils/install.ts";
import { readCustomHeaders } from "../utils/normalizeEnv.ts";
import { OAUTH_WRITEBACK_STATE } from "../utils/oauthWriteback.ts";
import { findProviderErrorMatch } from "../utils/providerErrors.ts";
import { resolveRunEffort } from "../utils/runEffort.ts";
import { saveSecretState } from "../utils/secretCommands.ts";
import { filterEnv } from "../utils/secrets.ts";
import {
DEFAULT_MAX_RETAINED_BYTES,
SPAWN_ACTIVITY_TIMEOUT_CODE,
SpawnTimeoutError,
spawn,
TailBuffer,
} from "../utils/subprocess.ts";
import { ThinkingTimer } from "../utils/timer.ts";
import type { TodoTracker } from "../utils/todoTracking.ts";
import { getDevDependencyVersion } from "../utils/version.ts";
import { runPostRunRetryLoop } from "./postRun.ts";
import {
type AgentResult,
type AgentRunContext,
type AgentUsage,
agent,
logTokenTable,
MAX_STDERR_LINES,
} from "./shared.ts";
/** rust target triples keyed `${process.platform}-${process.arch}`, mirroring
* `PLATFORM_PACKAGE_BY_TARGET` in the package's own `bin/codex.js`. */
const CODEX_TARGETS: Record<string, string> = {
"linux-x64": "x86_64-unknown-linux-musl",
"linux-arm64": "aarch64-unknown-linux-musl",
"darwin-x64": "x86_64-apple-darwin",
"darwin-arm64": "aarch64-apple-darwin",
"win32-x64": "x86_64-pc-windows-msvc",
"win32-arm64": "aarch64-pc-windows-msvc",
};
/**
* Install the codex CLI and return the path to the NATIVE binary, not to the
* `bin/codex.js` shim that would otherwise front it.
*
* Spawning the shim means spawning `node`, which inherits `NODE_OPTIONS` and
* `PATH` from the run env — and a toolchain that injects a `--require` preload
* there (nub does exactly this locally) makes the shim exit 1 before codex ever
* starts, with a diagnostic about the WRAPPER's project rather than ours. The
* binary is what we actually want to run, and the other two harnesses spawn
* theirs directly for the same reason.
*/
async function installCodexCli(): Promise<string> {
const target = CODEX_TARGETS[`${process.platform}-${process.arch}`];
if (!target) {
throw new Error(`the codex CLI ships no binary for ${process.platform}-${process.arch}`);
}
const binary = process.platform === "win32" ? "codex.exe" : "codex";
return await installFromNpmTarball({
packageName: "@openai/codex",
version: getDevDependencyVersion("@openai/codex"),
executablePath: `node_modules/@openai/codex-${process.platform}-${process.arch}/vendor/${target}/bin/${binary}`,
installDependencies: true,
});
}
// ── config ─────────────────────────────────────────────────────────────────────
/**
* `[features]` entries written on every run: every ON-BY-DEFAULT surface that
* executes code or reaches outside the run, plus the subagent fan-out we have
* no gate for. Keys codex no longer recognizes are ignored (verified against
* 0.147.0), so this list is safe to carry forward across CLI bumps — but
* REVALIDATE IT ON EVERY BUMP: a NEW exec-capable feature defaulting to `true`
* is a hole this list can't know about. `codex features list` prints the live
* set with its stage and default.
*
* Only defaults-on flags belong here. `code_mode` is already off by default and
* turning off its `code_mode_host` companion makes codex emit a loud
* "Code Mode is unavailable… enable `features.code_mode_host`" error item into
* the agent's own transcript, so the exec surface stays closed by leaving both
* alone.
*/
const CODEX_DISABLED_FEATURES = [
// browser / desktop control — network reach and arbitrary JS
"browser_use",
"browser_use_external",
"browser_use_full_cdp_access",
"computer_use",
"in_app_browser",
// third-party code loaded into the agent process
"plugins",
"remote_plugin",
"plugin_sharing",
"skill_mcp_dependency_install",
// lifecycle hooks run commands sourced from repo config, which a PR can edit
"hooks",
// connectors
"apps",
// ungated subagent fan-out (see the file header)
"multi_agent",
"multi_agent_v2",
] as const;
/**
* exec surfaces gated on `payload.shell` rather than always-off. `shell_snapshot` sources the user's
* rc files in a real shell every time codex starts, outside the sandbox, and a follow-up turn starts
* it again after the agent's shell could have written them.
*/
const CODEX_SHELL_FEATURES = ["shell_tool", "unified_exec", "shell_snapshot"] as const;
function tomlBool(entries: readonly string[], value: boolean): string {
return entries.map((name) => `${name} = ${value}`).join("\n");
}
/**
* The security boundary, as `-c` overrides rather than config-file entries.
*
* `$CODEX_HOME/config.toml` is NOT the last word. Codex ranks its config layers
* by an explicit integer ladder (`config/src/config_layer_source.rs`): our user
* layer is **20**, the repo's own `.codex/config.toml` is **25**, and `-c`
* session flags are **30**. So the checkout outranks us — and
* `sanitize_project_config`'s denylist strips only base-URL / provider / notify
* / profile / otel keys, NOT `sandbox_mode`, `approval_policy` or `features`.
*
* Which means a fork PR shipping a `.codex/config.toml` could re-enable
* `shell_tool` and hand itself a native shell with the full run env — the exact
* boundary this harness exists to hold, defeated by a file in the untrusted
* tree. Measured at 0.147.0: with `model` set in both layers the REPO's value
* won; with the same key passed as `-c`, ours won.
*
* These keys therefore ride at precedence 30 on every invocation, `exec` and
* `exec resume` alike — belt to the braces of the untrusted checkout, which is
* what actually stops the repo layer loading.
*
* SINCE `writeCodexConfig` MARKS THE CHECKOUT UNTRUSTED, the project layer no
* longer loads at all, and these flags are defence in depth rather than the
* boundary itself — they survive a change in trust semantics, and cost nothing.
* The notes below describe why enumeration alone was never going to be enough,
* which is what motivated finding the structural fix.
*
* THIS PIN IS NOT COMPLETE, and the shape of the gap matters more than the
* length of the list. `RawMcpServerConfig` alone carries ~26 fields and none is
* in the denylist, so enumerating keys can only ever chase the exploitable
* ones. What is pinned is what was measured to ESCALATE — take over the
* sandbox, widen its extent, redirect our server, or silently remove it. The
* residue is denial-of-service on the repo's own run (`enabled_tools`,
* per-tool approval), which costs that repo its own agent and gains it nothing.
*
* THE ENTRY IS WRITTEN EXPLICITLY rather than omitted, and the reason is not
* the one an earlier version of this comment gave. Absent and `untrusted` are
* equivalent for layer ADMISSION — `is_trusted()` is `matches!(.., Some(Trusted))`
* and `disabled_reason_for_decision` returns a reason for every other value, so
* a missing entry disables the layer just as well. What omitting it loses is
* INHERITANCE: `decision_for_dir` falls back from the cwd keys to the project
* root and then the repo root, so a grant on an ancestor would decide for us.
* Our exact-cwd key is consulted first and settles it.
*
* Verify with the probe in wiki/codex-agent.md on every codex bump — a layer
* ladder is exactly the kind of thing a major version reorders.
*/
function securityOverrideFlags(params: {
ctx: AgentRunContext;
sandboxMode: string;
repoDir: string;
mcpServerUrl: string;
model: string | undefined;
effortRung: string | undefined;
}): string[] {
const shellEnabled = params.ctx.payload.shell === "enabled";
const features = [
...CODEX_DISABLED_FEATURES.map((name) => `${name}=false`),
...CODEX_SHELL_FEATURES.map((name) => `${name}=${shellEnabled}`),
];
return [
"-c",
`approval_policy=${JSON.stringify("never")}`,
"-c",
`sandbox_mode=${JSON.stringify(params.sandboxMode)}`,
// the sandbox's EXTENT has to be locked too, not just its mode: a repo
// setting `writable_roots = ["/"]` walked straight out of the checkout with
// `sandbox_mode` already flag-locked (measured — apply_patch wrote a file
// under $HOME).
"-c",
`sandbox_workspace_write.writable_roots=[${JSON.stringify(params.repoDir)}]`,
// on by default, and the run's own tools live there
"-c",
`sandbox_workspace_write.exclude_slash_tmp=${!shellEnabled}`,
"-c",
`sandbox_workspace_write.exclude_tmpdir_env_var=${!shellEnabled}`,
"-c",
"sandbox_workspace_write.network_access=true",
// our own MCP server, for a third time in the same class: a repo pointing
// `mcp_servers.pullfrog.url` at its own endpoint (measured — `codex mcp get`
// reported the repo's URL) turns every pullfrog tool call into an
// exfiltration channel, carrying diffs, file contents and cross-repo
// learnings to whoever it names.
"-c",
`mcp_servers.${pullfrogMcpName}.url=${JSON.stringify(params.mcpServerUrl)}`,
"-c",
`mcp_servers.${pullfrogMcpName}.${APPROVAL_MODE_KEY}=${JSON.stringify("approve")}`,
"-c",
`mcp_servers.${pullfrogMcpName}.required=true`,
"-c",
`mcp_servers.${pullfrogMcpName}.tool_timeout_sec=660`,
// `enabled` closes the silent-DoS variant: a repo setting it false would
// otherwise strip every GitHub tool and leave the agent narrating failure.
"-c",
`mcp_servers.${pullfrogMcpName}.enabled=true`,
// not a boundary, but the same override: a repo silently re-pointing the
// model spends the account's money on a pick it never made, and makes the
// "Using `…`" run footer a lie about what actually ran.
...(params.model ? ["-c", `model=${JSON.stringify(params.model)}`] : []),
...(params.effortRung
? ["-c", `model_reasoning_effort=${JSON.stringify(params.effortRung)}`]
: []),
...features.flatMap((entry) => ["-c", `features.${entry}`]),
];
}
/**
* levels `model_reasoning_effort` accepts, verbatim from the pinned CLI's
* `ModelReasoningEffort` (codex-sdk 0.147.0). rungs come from models.dev, a
* different source, so the two are free to drift — anything outside this set is
* dropped rather than sent. REVALIDATE ON EVERY codex BUMP.
*/
const CODEX_EFFORTS: readonly string[] = ["minimal", "low", "medium", "high", "xhigh"];
/**
* The bare model id to pin, or undefined to let codex pick its own default.
*
* Only an OpenAI specifier is passed through. `resolveAgent` already routes on
* provider, so a non-OpenAI model reaches this harness exactly one way — the
* `PULLFROG_AGENT=codex` escape hatch, on a repo whose stored model is
* something else. Forwarding it there is a guaranteed
* `model_not_found` 400 before the first turn; falling back to codex's default
* makes the override do the thing the operator asked for.
*/
function resolveCodexModel(ctx: AgentRunContext): string | undefined {
const specifier = ctx.payload.proxyModel ?? ctx.resolvedModel;
if (!specifier) return undefined;
try {
if (getModelProvider(specifier) === "openai") return stripProviderPrefix(specifier);
} catch {
// unparseable specifier — treat it the same as a foreign one.
}
log.warning(`» ${specifier} isn't an OpenAI model — running codex on its own default instead`);
return undefined;
}
const APPROVAL_MODE_KEY = "default_tools_approval_mode";
const APPROVE_TOOLS = `${APPROVAL_MODE_KEY} = "approve"`;
/**
* Re-emit every `[mcp_servers.*]` section from the repo's own
* `.codex/config.toml` with tool approval forced on.
*
* This copying is the ONLY way a repo's own MCP servers get registered, because
* `writeCodexConfig` marks the checkout untrusted and codex therefore never
* loads that file itself. It was not written for that reason: codex DID load
* it, and this existed only to fix approval — those servers keep codex's
* PROMPTING default, and `codex exec` has nobody to answer, so every call to a
* repo-provided tool came back `user cancelled MCP tool call`. The trust fix
* later disabled the layer wholesale and quietly promoted this function from an
* ergonomic fix to the feature itself. The other two
* harnesses merge repo MCP config without adding a second confirmation, and a
* prompt in a non-interactive run isn't a security boundary, it's a guaranteed
* failure — the boundary is which servers the repo owner configured.
*
* THAT BOUNDARY RESTS ENTIRELY ON WHEN THIS RUNS, so do not move the call.
* A copied `[mcp_servers.x]` is unsandboxed native code execution holding named
* run credentials: codex connects every enabled server eagerly at session
* startup (`connection_manager.rs` filters on `enabled()` and spawns, nothing
* waits for the model), the spawn is a plain `Command::new` with NO sandbox
* wrapper of any kind (`stdio_server_launcher.rs`), and `create_env_for_mcp_server`
* resolves each name in `env_vars` straight out of OUR process env.
* What keeps that the repo OWNER's choice and not a fork author's is that the
* read happens before the agent's first turn: runs are dispatched at
* `ref: defaultBranch` (`utils/github/triggerWorkflow.ts`) and `writeCodexConfig`
* is called once at harness start, so the tree here is always the default
* branch. Fork content arrives later, via `checkout_pr`, and is never re-read —
* codex cannot load it itself either, because the checkout is untrusted.
* Re-reading this file after a checkout, or rewriting the config per turn,
* turns a fork PR into arbitrary code execution with our credentials.
*
* The approval mode has to travel WITH a transport: an `[mcp_servers.<name>]`
* table carrying only `default_tools_approval_mode` fails bootstrap outright
* (`invalid transport in mcp_servers.<name>`), and a `-c` dotted override fails
* the same way, because each config layer is validated on its own before the
* layers merge. Copying the section verbatim into our layer is what works — the
* duplicate definitions merge, transport and all.
*
* Sections are copied by line rather than parsed: the transport shape varies
* (`command`/`args`/`env`, `url`/`bearer_token_env_var`, sub-tables) and none of
* it needs interpreting. Our key goes immediately after the server's own header
* so a following sub-table can't capture it, and is skipped entirely when the
* repo already set one — TOML forbids a duplicate key in a table, and a repo
* that made an explicit choice should keep it.
*/
function repoMcpApprovals(repoDir: string): string[] {
const path = join(repoDir, ".codex", "config.toml");
if (!existsSync(path)) return [];
const out: string[] = [];
let section: string[] = [];
let isServerTable = false;
function flush(): void {
if (section.length === 0) return;
const declared = section.some((line) => line.trimStart().startsWith(APPROVAL_MODE_KEY));
if (isServerTable && !declared) section.splice(1, 0, APPROVE_TOOLS);
out.push(...section, "");
section = [];
}
for (const line of readFileSync(path, "utf8").split("\n")) {
const header = line.trim().match(/^\[(?<key>[^\]]+)\]$/);
if (!header?.groups) {
if (section.length > 0) section.push(line);
continue;
}
flush();
const key = header.groups.key.trim();
if (!key.startsWith("mcp_servers.")) continue;
// `mcp_servers.name` is the server's own table; `mcp_servers.name.env` and
// strip surrounding quotes off the first segment: TOML treats
// `[mcp_servers."pullfrog"]` as identical to the bare form, so a quoted
// spelling would slip past the skip below and reproduce the duplicate-table
// parse error it exists to prevent.
const name = key.slice("mcp_servers.".length).replace(/^"([^"]*)"/, "$1");
// our own `[mcp_servers.pullfrog]` is already in this document, and the
// cross-LAYER merge that makes the rest of this work does not apply within
// one file: TOML forbids defining a table twice, and codex refuses the
// whole config with `duplicate key` rather than merging. skip the name and
// its sub-tables, which would otherwise graft an `env` onto our server.
if (name === pullfrogMcpName || name.startsWith(`${pullfrogMcpName}.`)) continue;
// `mcp_servers.name` is the server's own table; `mcp_servers.name.env` and
// deeper are sub-tables, which must be copied but must not take our key.
isServerTable = !name.includes(".");
section = [line.trim()];
}
flush();
return out;
}
/**
* Warn about the one configuration where codex has no way to change a file.
*
* On Linux the native write path does not work at all: enforcement goes through
* the bundled bubblewrap, which cannot create its namespace on Ubuntu 24.04
* (`kernel.apparmor_restrict_unprivileged_userns=1`) — the OS every
* GitHub-hosted runner now uses. `apply_patch` answers `Failed to write file`
* for ANY path, including a plain file at the repo root. Upstream:
* https://github.com/openai/codex/issues/29908, open and unfixed. Both
* plausible remedies were tried on a real runner and measured NOT to help
* (`writable_roots`, `use_legacy_landlock`).
*
* With a shell the agent still works — it edits through the MCP `shell` tool,
* which is how every green e2e on this harness has actually been writing files.
* With `shell: disabled` it has no way to change a file at all.
*
* This WARNS rather than refuses, because plenty of work needs no file write: a
* review that only comments, a question answered through `report_progress`, any
* read-only task. The first cut threw, and took out the cross-agent deny tests
* (`nobash`, `gitwrite`, `mcpmerge`) — which run `shell: disabled` precisely
* because that is the configuration under test. Whether a run needs to write is
* also not knowable here: the mode is chosen mid-run by `select_mode`. So the
* run proceeds, and a write that does come will fail with codex's own message
* on top of this line in the log.
*/
function warnIfNativeEditsUnavailable(ctx: AgentRunContext): void {
if (process.platform !== "linux" || ctx.payload.shell !== "disabled") return;
log.warning(
"» this run cannot change files: codex's native write tool depends on bubblewrap, " +
"which Ubuntu 24.04 blocks (https://github.com/openai/codex/issues/29908), and the " +
"MCP shell is disabled for this repo. read-only work is unaffected — set " +
"`shell: restricted` if the agent needs to edit anything."
);
}
/**
* A customer gateway, declared as a provider of its own rather than the
* built-in `openai` one re-pointed. codex 0.153.4 never reads `OPENAI_BASE_URL`,
* and its `openai_base_url` key keeps `supports_websockets = true`, which config
* cannot turn off on a built-in id (`merge_configured_model_providers` only
* `or_insert`s). A gateway refusing the Responses WebSocket upgrade — nearly all
* of them — then costs five reconnects the harness reports as a failed run. A
* declared provider defaults it to false and goes straight to HTTPS (measured
* 2026-09-11).
*/
const CODEX_GATEWAY_PROVIDER = "pullfrog-gateway";
function gatewayProvider(gatewayUrl: string | undefined): string[] {
if (!gatewayUrl) return [];
const headers = Object.entries(readCustomHeaders(PROVIDER_GATEWAY_HEADERS_ENV.openai))
.map(([name, value]) => `${JSON.stringify(name)} = ${JSON.stringify(value)}`)
.join(", ");
return [
`[model_providers.${CODEX_GATEWAY_PROVIDER}]`,
'name = "OpenAI gateway"',
`base_url = ${JSON.stringify(gatewayUrl)}`,
'wire_api = "responses"',
// where the harness puts the run's OPENAI_API_KEY
'env_key = "CODEX_API_KEY"',
`http_headers = { ${headers} }`,
"",
];
}
/**
* Write `$CODEX_HOME/config.toml`. `auth.json` already sits in this directory
* (installCodexHome), so codex discovers both from the one env var.
*
* Everything the run needs lives here rather than in argv — including model,
* sandbox and effort, which all have flags. `codex exec resume` is a SUBCOMMAND
* with its own narrower option set (no `--sandbox` at all), so a config-only
* shape is the one that provably applies identically to the first turn and to
* every post-run gate retry.
*/
function writeCodexConfig(params: {
ctx: AgentRunContext;
codexHome: string;
model: string | undefined;
effortRung: string | undefined;
gatewayUrl: string | undefined;
}): void {
const shellEnabled = params.ctx.payload.shell === "enabled";
const sandboxMode = params.ctx.payload.push === "disabled" ? "read-only" : "workspace-write";
const repoDir = process.cwd();
const config = [
"# written by pullfrog",
// "never" keeps the run non-interactive WITHOUT
// --dangerously-bypass-approvals-and-sandbox, which would also switch the
// sandbox off and let the native apply_patch tool write anywhere.
'approval_policy = "never"',
`sandbox_mode = ${JSON.stringify(sandboxMode)}`,
// matches opencode's `webfetch: "allow"` — research is part of the job and
// neither harness gates it on a permission.
'web_search = "live"',
...(params.model ? [`model = ${JSON.stringify(params.model)}`] : []),
...(params.effortRung ? [`model_reasoning_effort = ${JSON.stringify(params.effortRung)}`] : []),
...(params.gatewayUrl ? [`model_provider = ${JSON.stringify(CODEX_GATEWAY_PROVIDER)}`] : []),
"",
"[features]",
tomlBool(CODEX_DISABLED_FEATURES, false),
tomlBool(CODEX_SHELL_FEATURES, shellEnabled),
"",
"[sandbox_workspace_write]",
"network_access = true",
// the checkout, named explicitly rather than left to `workspace-write` to
// imply. does NOT fix the Linux native-write failure — nothing here does;
// see wiki/codex-agent.md "The Linux native-write blocker".
`writable_roots = [${JSON.stringify(repoDir)}]`,
`exclude_slash_tmp = ${!shellEnabled}`,
`exclude_tmpdir_env_var = ${!shellEnabled}`,
"",
`[mcp_servers.${pullfrogMcpName}]`,
`url = ${JSON.stringify(params.ctx.mcpServerUrl)}`,
// codex's default MCP approval mode PROMPTS for anything it can't read as
// read-only, and a `codex exec` run has nobody to answer — the call comes
// back "user cancelled MCP tool call" and the agent silently accomplishes
// nothing. our own server is the trusted one; the boundary is what we
// register, not a second confirmation.
APPROVE_TOOLS,
// a run whose GitHub tools never registered can't do its job, so fail loudly
// rather than let the agent improvise without them.
"required = true",
// mirrors the opencode harness's 660s: `checkout_pr` runs a multi-minute
// fetch on large repos and enforces its own 600s deadline, so a shorter
// client abort turns a working checkout into a spurious tool error.
"tool_timeout_sec = 660",
"",
...repoMcpApprovals(repoDir),
...gatewayProvider(params.gatewayUrl),
// UNTRUSTED, deliberately, and this one line is the whole config-precedence
// boundary. Codex loads `<repo>/.codex/config.toml` as a layer that
// OUTRANKS ours (project 25 vs user 20), and its denylist strips none of
// `sandbox_mode`, `approval_policy`, `features` or `mcp_servers` — so a
// fork PR could ship a config re-enabling the native shell. Marking the
// checkout untrusted makes that layer load `disabled`, closing the class
// outright rather than key by key: `RawMcpServerConfig` alone has ~26
// fields and enumeration can never finish.
//
// It costs nothing. `codex exec`'s only startup gate is a GIT-REPO check
// (`exec/src/lib.rs:796` — `get_git_repo_root(...).is_none()`, whose error
// text unhelpfully says "Not inside a trusted directory"), so an untrusted
// checkout still runs. And the repo's own MCP servers survive, because
// `repoMcpApprovals` above copies them into OUR layer — which is why that
// copying exists at all.
//
// Measured both ways at 0.147.0: `trusted` → the repo's `model` won;
// `untrusted` → it did not, and the run proceeded normally.
`[projects.${JSON.stringify(repoDir)}]`,
'trust_level = "untrusted"',
"",
].join("\n");
writeFileSync(join(params.codexHome, "config.toml"), config, { mode: 0o600 });
log.info(`» codex config: sandbox=${sandboxMode}, nativeShell=${shellEnabled}`);
}
// ── runner ─────────────────────────────────────────────────────────────────────
type RunParams = {
cliPath: string;
args: string[];
cwd: string;
env: NodeJS.ProcessEnv;
/** bare OpenAI model id, used only to price the run — the model itself reaches
* the CLI through `args`/config, not from here. */
model?: string | undefined;
todoTracker?: TodoTracker | undefined;
onActivityTimeout?: (() => void) | undefined;
onToolUse?: ((event: { toolName: string; input: unknown }) => void) | undefined;
};
type CodexRunResult = AgentResult & { threadId?: string | undefined };
/** codex emits its running plan as a `todo_list` item; the tracker speaks
* claude's TodoWrite shape, so translate rather than teach it a second one. */
function toTodoWriteInput(item: Extract<ThreadItem, { type: "todo_list" }>): unknown {
return {
todos: item.items.map((todo, index) => ({
id: String(index),
content: todo.text,
status: todo.completed ? "completed" : "pending",
})),
};
}
/**
* Per-million-token USD list prices for the OpenAI models codex can run,
* mirrored from models.dev — the same catalog the `reasoning_options` effort
* ladders come from — rather than fetched, so a run still costs no network call.
*
* This table exists because codex is the one harness that cannot report its own
* cost: claude-code gets `total_cost_usd` from Anthropic and opencode prices each
* part itself, but the Codex SDK's `Usage` carries TOKENS ONLY and no cost field.
* Without it every codex run persists a null `costUsd` and contributes $0 to
* every spend surface (`runAnalytics` simply sums the column).
*
* A model absent here yields `undefined`, never 0 — an unknown price must not
* read as free, which would silently understate real spend.
*
* These are LIST prices, and two caveats ride with them. A run on a ChatGPT
* subscription (`CODEX_AUTH_JSON`) spends no per-token money at all, so its
* figure is what the run WOULD have cost on the API — consistent with
* `WorkflowRun.costUsd` being modelled rather than invoiced. And these are the
* base tier: OpenAI bills roughly double above a 200k context, so a
* long-context turn is under-counted here.
*/
const CODEX_MODEL_PRICING: Record<
string,
{ input: number; cacheRead: number; cacheWrite: number; output: number }
> = {
"gpt-6-astra": { input: 10, cacheRead: 1, cacheWrite: 12.5, output: 50 },
"gpt-6.1-sol": { input: 2, cacheRead: 0.1, cacheWrite: 2.5, output: 10 },
"gpt-6-luna": { input: 0.1, cacheRead: 0.01, cacheWrite: 0.125, output: 0.5 },
"gpt-6.1-terra": { input: 1, cacheRead: 0.05, cacheWrite: 1.25, output: 5 },
"gpt-5.6-terra": { input: 2, cacheRead: 0.2, cacheWrite: 2.5, output: 12 },
};
/** modelled USD for a codex run; `undefined` when we hold no price for the model. */
function codexCostUsd(params: {
model: string | undefined;
input: number;
cacheRead: number;
cacheWrite: number;
output: number;
}): number | undefined {
const price = params.model ? CODEX_MODEL_PRICING[params.model] : undefined;
if (!price) return undefined;
// `input_tokens` is INCLUSIVE of BOTH cache figures (see the accumulator
// below), so the three slices PARTITION it and each carries its own rate.
// cache writes bill at 1.25x uncached input, so folding them into `fresh`
// understates every cache-writing run.
const fresh = Math.max(0, params.input - params.cacheRead - params.cacheWrite);
const usd =
(fresh * price.input +
params.cacheRead * price.cacheRead +
params.cacheWrite * price.cacheWrite +
params.output * price.output) /
1_000_000;
return usd > 0 ? usd : undefined;
}
async function runCodex(params: RunParams): Promise<CodexRunResult> {
const startTime = performance.now();
const thinkingTimer = new ThinkingTimer();
const output = new TailBuffer(DEFAULT_MAX_RETAINED_BYTES);
const recentStderr: string[] = [];
let eventCount = 0;
let finalOutput = "";
let threadId: string | undefined;
let turnError: string | null = null;
let lastProviderError: string | null = null;
let stdoutBuffer = "";
// codex fires `turn.completed` once PER TURN, not once at the end, so usage
// accumulates.
//
// OpenAI's `input_tokens` is INCLUSIVE of both cache figures, where
// Anthropic's is disjoint from them: one measured turn reported
// input=106224, cached=83128, cache_write=23081 — cached + the 23096 fresh
// tokens is exactly `input_tokens`, and the write is that same fresh slice
// going into the cache. So `input_tokens` IS the billable input total, and
// adding either cache number to it double-counts. `cacheWriteTokens` is
// deliberately left unset for the same reason — `AgentUsage` and
// `logTokenTable` both define it as an ADDITIVE column.
const tokens = { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 };
function buildUsage(): AgentUsage | undefined {
if (tokens.input === 0 && tokens.output === 0) return undefined;
return {
agent: "codex",
inputTokens: tokens.input,
outputTokens: tokens.output,
cacheReadTokens: tokens.cacheRead || undefined,
costUsd: codexCostUsd({ model: params.model, ...tokens }),
};
}
function onItem(item: ThreadItem, phase: "started" | "completed"): void {
switch (item.type) {
case "agent_message":
if (phase === "completed" && item.text.trim()) {
finalOutput = item.text.trim();
log.box(finalOutput, { title: "Pullfrog" });
}
return;
case "reasoning":
if (phase === "completed" && item.text.trim()) {
log.box(item.text.trim(), { title: "Pullfrog" });
}
return;
case "command_execution":
if (phase === "started") {
thinkingTimer.markToolCall();
log.toolCall({ toolName: "shell", input: { command: item.command } });
return;
}
thinkingTimer.markToolResult();
log.startGroup("shell output");
log.info(item.aggregated_output || "");
log.endGroup();
return;
case "mcp_tool_call":
if (phase === "started") {
thinkingTimer.markToolCall();
params.onToolUse?.({ toolName: item.tool, input: item.arguments });
log.toolCall({ toolName: `${item.server}__${item.tool}`, input: item.arguments });
// an explicit report_progress supersedes plan-derived tracking, same
// rule the other harnesses apply.
if (item.tool.includes("report_progress")) params.todoTracker?.cancel();
return;
}
thinkingTimer.markToolResult();
if (item.error) log.info(`» tool error: ${item.error.message}`);
return;
case "file_change":
if (phase === "completed") {
const paths = item.changes.map((c) => `${c.kind} ${c.path}`).join(", ");
log.info(`» apply_patch (${item.status}): ${paths}`);
}
return;
case "web_search":
if (phase === "started") log.toolCall({ toolName: "web_search", input: item.query });
return;
case "todo_list":
if (params.todoTracker?.enabled) params.todoTracker.update(toTodoWriteInput(item));
return;
case "error":
log.info(`» ${item.message}`);
return;
default:
item satisfies never;
}
}
function onEvent(event: ThreadEvent): void {
switch (event.type) {
case "thread.started":
threadId = event.thread_id;
return;
case "turn.completed":
tokens.input += event.usage.input_tokens ?? 0;
tokens.cacheRead += event.usage.cached_input_tokens ?? 0;
// pricing only. deliberately NOT surfaced as `cacheWriteTokens`, which
// `AgentUsage` and `logTokenTable` both define as an ADDITIVE column.
tokens.cacheWrite += event.usage.cache_write_input_tokens ?? 0;
tokens.output += event.usage.output_tokens ?? 0;
return;
case "turn.failed":
turnError = event.error.message;
log.info(`» turn failed: ${turnError}`);
return;
case "error":
turnError = event.message;
log.info(`» codex error: ${event.message}`);
return;
case "item.started":
onItem(event.item, "started");
return;
case "item.completed":
onItem(event.item, "completed");
return;
case "item.updated":
// in-progress snapshots; the plan is the one worth streaming.
if (event.item.type === "todo_list") onItem(event.item, "started");
return;
case "turn.started":
return;
default:
event satisfies never;
}
}
try {
const result = await spawn({
cmd: params.cliPath,
args: params.args,
cwd: params.cwd,
env: params.env,
activityTimeout: AGENT_ACTIVITY_TIMEOUT_MS,
onActivityTimeout: params.onActivityTimeout,
// stdin MUST be `ignore`: codex reads a piped stdin as an extra `<stdin>`
// prompt block and blocks on EOF, so an inherited pipe hangs the run
// before the first model call.
stdio: ["ignore", "pipe", "pipe"],
killGroup: true,
retain: "none",
onStdout: (chunk) => {
output.append(chunk);
markActivity();
stdoutBuffer += chunk;
const lines = stdoutBuffer.split("\n");
stdoutBuffer = lines.pop() || "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
let event: ThreadEvent;
try {
event = JSON.parse(trimmed) as ThreadEvent;
} catch {
log.debug(`» non-JSON stdout line: ${trimmed.substring(0, 200)}`);
continue;
}
eventCount++;
log.debug(JSON.stringify(event, null, 2));
const idleMs = getIdleMs();
if (idleMs > 10_000) {
log.info(
`» no activity for ${(idleMs / 1000).toFixed(1)}s (${eventCount} events processed so far)`
);
}
markActivity();
onEvent(event);
}
},
onStderr: (chunk) => {
const trimmed = chunk.trim();
if (!trimmed) return;
recentStderr.push(trimmed);
if (recentStderr.length > MAX_STDERR_LINES) recentStderr.shift();
const match = findProviderErrorMatch(trimmed);
if (match) {
lastProviderError = match.label;
log.info(`» provider error detected (${match.label}): ${match.excerpt}`);
} else {
log.debug(trimmed);
}
},
});
const duration = performance.now() - startTime;
log.info(`» codex completed in ${Math.round(duration)}ms with exit code ${result.exitCode}`);
const usage = buildUsage();
if (usage) {
// Cache Write stays 0: the table's Total is the sum of its columns, and
// codex's write figure is a subset of the fresh input already counted in
// the Input column (see the accumulator comment).
logTokenTable({
input: tokens.input - tokens.cacheRead,
cacheRead: tokens.cacheRead,
cacheWrite: 0,
output: tokens.output,
});
}
if (result.exitCode !== 0 || turnError) {
const stderrSnapshot = recentStderr.join("\n");
const error =
turnError ||
stderrSnapshot ||
(lastProviderError && `provider error: ${lastProviderError}`) ||
`unknown error - no output from Codex CLI (exit ${result.exitCode})`;
log.error(`codex exited with code ${result.exitCode}: ${error}`);
return { success: false, output: finalOutput || output.toString(), error, usage, threadId };
}
return { success: true, output: finalOutput || output.toString(), usage, threadId };
} catch (error) {
const duration = performance.now() - startTime;
const message = error instanceof Error ? error.message : String(error);
const isActivityTimeout =
error instanceof SpawnTimeoutError && error.code === SPAWN_ACTIVITY_TIMEOUT_CODE;
const diagnosis = lastProviderError
? `likely cause: ${lastProviderError}`
: eventCount === 0
? "codex produced 0 stdout events - check if the API is reachable"
: `${eventCount} events were processed before the hang`;
log.info(
`» codex ${isActivityTimeout ? "hung" : "failed"} after ${(duration / 1000).toFixed(1)}s: ${message}`
);
log.info(`» diagnosis: ${diagnosis}`);
if (recentStderr.length > 0) log.info(`» recent stderr:\n${recentStderr.join("\n")}`);
return {
success: false,
output: finalOutput || output.toString(),
error: `${message} [${diagnosis}]`,
usage: buildUsage(),
threadId,
};
}
}
// ── agent ──────────────────────────────────────────────────────────────────────
export const codex = agent({
name: "codex",
install: installCodexCli,
run: async (ctx) => {
const cliPath = await installCodexCli();
// the ChatGPT-subscription credential. absent means an OPENAI_API_KEY run,
// which codex reads straight from env — resolveAgent has already checked
// that one of the two is present.
warnIfNativeEditsUnavailable(ctx);
const codexHomeAuth = installCodexHome();
const codexHome = codexHomeAuth?.codexHome ?? join(ctx.tmpdir, ".codex");
mkdirSync(codexHome, { recursive: true });
const effort = resolveRunEffort(ctx);
if (effort.rung && !CODEX_EFFORTS.includes(effort.rung)) {
log.warning(`» effort ${effort.rung} not sent — codex doesn't accept that level`);
}
writeCodexConfig({
ctx,
codexHome,
model: resolveCodexModel(ctx),
effortRung: effort.rung && CODEX_EFFORTS.includes(effort.rung) ? effort.rung : undefined,
// keyed off OpenAI, not the resolved model: codex serves nothing else, an
// auto-selected run has no model, and a ChatGPT-subscription token is
// valid only at ChatGPT's own backend
gatewayUrl: codexHomeAuth ? undefined : getProviderGatewayUrl("openai/"),
});
if (codexHomeAuth) {
// the CLI rewrites auth.json in place when the chain rotates; the post
// hook diffs it and PUTs the new blob back to Pullfrog. see
// wiki/codex-auth.md — a rotation we fail to persist expires in ~1h.
saveSecretState(
OAUTH_WRITEBACK_STATE,
JSON.stringify({
apiToken: ctx.apiToken,
entries: [
{
secretName: "CODEX_AUTH_JSON",
provider: "openai",
authPath: codexHomeAuth.authPath,
originalRefresh: codexHomeAuth.originalRefresh,
},
] satisfies OAuthWriteback[],
})
);
}
// the agent process needs the LLM credential and PATH; the boundary is the
// tool layer, not the process env. when the native shell is off we hand it
// a filtered env anyway — defense in depth for a feature flag we don't own,
// with the OpenAI credential added back because codex needs it.
const baseEnv = ctx.payload.shell === "enabled" ? process.env : filterEnv();
const env: NodeJS.ProcessEnv = {
...baseEnv,
CODEX_HOME: codexHome,
GIT_CONFIG_GLOBAL: process.env.GIT_CONFIG_GLOBAL,
PWD: process.cwd(),
};
// codex authenticates from `CODEX_API_KEY`, NOT `OPENAI_API_KEY` —
// `read_codex_api_key_from_env` is the only env path into `CodexAuth`, and a
// run carrying only `OPENAI_API_KEY` 401s with "Missing bearer or basic
// authentication in header" (measured on 0.147.0). that env key also
// OUTRANKS auth.json, so the subscription run has to clear it rather than
// merely not set it — a workflow `env:` block could have supplied one.
delete env.CODEX_API_KEY;
if (codexHomeAuth) {
delete env.OPENAI_API_KEY;
} else if (process.env.OPENAI_API_KEY) {
env.CODEX_API_KEY = process.env.OPENAI_API_KEY;
}
// the two arms above are mutually exclusive, so which one ran IS the answer
// to "did this run spend per-token money": a ChatGPT subscription spends
// none, and `CODEX_MODEL_PRICING` therefore prices it at what it WOULD have
// cost on the API. recorded rather than re-derived later, because nothing
// downstream can see this env.
if (codexHomeAuth) {
ctx.toolState.credential = "subscription";
} else if (env.CODEX_API_KEY) {
ctx.toolState.credential = "api_key";
}
// defence in depth behind the untrusted checkout: these outrank every config
// layer, so the boundary holds even if trust semantics change. see
// securityOverrideFlags.
const securityFlags = securityOverrideFlags({
ctx,
sandboxMode: ctx.payload.push === "disabled" ? "read-only" : "workspace-write",
repoDir: process.cwd(),
mcpServerUrl: ctx.mcpServerUrl,
model: resolveCodexModel(ctx),
effortRung: effort.rung && CODEX_EFFORTS.includes(effort.rung) ? effort.rung : undefined,
});
const runnerArgs = {
cliPath,
cwd: process.cwd(),
env,
todoTracker: ctx.todoTracker,
model: resolveCodexModel(ctx),
};
const initial = await runCodex({
...runnerArgs,
args: [...securityFlags, "exec", "--json", ctx.instructions.full],
onActivityTimeout: ctx.onActivityTimeout,
onToolUse: ctx.onToolUse,
});
const result = await runPostRunRetryLoop({
ctx,
initialResult: initial,
initialUsage: initial.usage,
// a resume needs the thread codex recorded. without one there is nothing
// to continue, and `resume --last` could pick another run's session off CI,
// where CODEX_HOME is the shared ~/.local/share/codex (in CI it is per run).
canResume: (r) => r.threadId !== undefined,
resume: async (c) =>
runCodex({
...runnerArgs,
args: [
...securityFlags,
"exec",
"resume",
"--json",
c.previousResult.threadId ?? "",
c.prompt,
],
}),
});
if (result.success) {
await ctx.todoTracker?.flush();
} else {
ctx.todoTracker?.cancel();
}
return result;
},
});