/** * codex spawn adapter: runs one subagent as a headless `codex exec` * subprocess. * * Contract notes (LIVE-VERIFIED 2026-07-16 against codex-cli 0.144.5: flags * present, schema-gated triage + text stages round-trip via `--adapter codex`): * - `codex exec ""` is the non-interactive mode. * - The final assistant message is captured via `--output-last-message ` * (a temp file), which is far more drift-tolerant than parsing the * experimental `--json` JSONL event stream. * - `--skip-git-repo-check` keeps non-repo cwds working; `--sandbox * workspace-write` matches workflow-stage expectations (children may edit). * - No per-run cost or session-id surface in this mode → both left undefined. * - No max-turns equivalent → `maxTurns` is accepted and ignored (documented * in the CLI docs page). */ import { randomBytes } from "node:crypto"; import { existsSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { exec } from "../../lib/exec.ts"; import { builtinAdapterProfile, validateAdapterEffort } from "../adapters/profiles.ts"; import type { AdapterInvocation, AdapterRawResult } from "../adapters/types.ts"; import { notFoundError } from "./adapters.ts"; import { buildChildEnv } from "./child-env.ts"; import { resolveSandboxProjection } from "./sandbox-projection.ts"; import { isUpstreamFailureText, vendorFailureText } from "./spawn-failure.ts"; import type { Spawner, SpawnRequest, SpawnResult } from "./types.ts"; export function buildCodexInvocation(req: SpawnRequest, resultFile?: string): AdapterInvocation { validateAdapterEffort("codex", req.effort); if (!resultFile) throw new Error("codex adapter requires a final-message result file"); // Default stays workspace-write so an unprojected request is unchanged. const projection = req.filesystemPolicy ? resolveSandboxProjection( "codex", builtinAdapterProfile("codex")?.sandboxProjection, req.filesystemPolicy, ) : undefined; const argv = [ "codex", "exec", req.prompt, "--output-last-message", resultFile, "--skip-git-repo-check", "--sandbox", projection?.nativeMode ?? "workspace-write", ]; if (projection && projection.writableRoots.length > 0) { argv.push( "-c", `sandbox_workspace_write.writable_roots=${JSON.stringify(projection.writableRoots)}`, ); } if (req.model) argv.push("--model", req.model); if (req.effort) argv.push("-c", `model_reasoning_effort=${JSON.stringify(req.effort)}`); return { argv, resultFile }; } export function normalizeCodexResult(raw: AdapterRawResult): SpawnResult { if (raw.timedOut) { return { ok: false, text: "", durationMs: raw.durationMs, error: `codex timed out after ${raw.durationMs}ms and was killed`, }; } // Structural environment signal: the binary was never there (spawned directly, // so a missing binary surfaces as ENOENT). Uncharged and not retried. if (raw.spawnErrno === "ENOENT") { return { ok: false, text: "", durationMs: raw.durationMs, error: notFoundError("codex"), class: "environment", }; } if (raw.exitCode === 127) { // A bare 127 with no errno is a shell/vendor 127, indistinguishable from a // legitimate one — charged as work rather than classed environment. return { ok: false, text: "", durationMs: raw.durationMs, error: notFoundError("codex") }; } if (raw.exitCode !== 0) { const failureText = vendorFailureText(raw); return { ok: false, text: "", durationMs: raw.durationMs, error: `codex exited ${raw.exitCode}: ${failureText}`, ...(isUpstreamFailureText(failureText) ? { class: "upstream" as const } : {}), }; } return { ok: true, text: (raw.resultFileText ?? raw.stdout).trim(), durationMs: raw.durationMs, }; } export const codexSpawner: Spawner = async (req: SpawnRequest): Promise => { const t0 = Date.now(); const outFile = join( tmpdir(), `harnery-codex-${process.pid}-${randomBytes(4).toString("hex")}.txt`, ); try { let invocation: AdapterInvocation; try { invocation = buildCodexInvocation(req, outFile); } catch (error) { return { ok: false, text: "", durationMs: 0, error: (error as Error).message }; } const r = await exec(invocation.argv, { cwd: req.cwd, env: buildChildEnv(req.runId, { subscriptionOnly: req.subscriptionOnly, agentId: req.agentId, }), timeout: req.timeoutMs, }); return normalizeCodexResult({ ...r, durationMs: Date.now() - t0, resultFileText: existsSync(outFile) ? readFileSync(outFile, "utf8") : undefined, }); } finally { rmSync(outFile, { force: true }); } };