From ce2466f2d3e780957f88058033de92de456aac39 Mon Sep 17 00:00:00 2001 From: "open-swe[bot]" Date: Sun, 2 Aug 2026 17:23:07 +1000 Subject: [PATCH 01/24] feat: support Codex ChatGPT OAuth auth --- .codex/skills/dev-instance/SKILL.md | 8 +- .env.example | 1 + scripts/dev/lib/envctx.ts | 19 +- scripts/dev/supervisor/main.ts | 4 +- scripts/dev/supervisor/specs.ts | 7 +- src/api/routes/surface.ts | 4 +- src/config.ts | 29 +- src/deployment/secret-schema.ts | 5 +- src/harness/codex-app-server.ts | 143 +++- src/harness/codex-auth.ts | 326 ++++++++ src/harness/codex-harness.ts | 805 ++++++++++++++----- src/model/pi-models.ts | 12 +- test/base-model-serviceability.test.ts | 19 +- test/codex-harness.test.ts | 1003 ++++++++++++++++++++++-- test/dev-cli-lib.test.ts | 55 +- test/model-credential-route.test.ts | 47 +- 16 files changed, 2178 insertions(+), 309 deletions(-) create mode 100644 src/harness/codex-auth.ts diff --git a/.codex/skills/dev-instance/SKILL.md b/.codex/skills/dev-instance/SKILL.md index 6f1c1b655..45879c3e7 100644 --- a/.codex/skills/dev-instance/SKILL.md +++ b/.codex/skills/dev-instance/SKILL.md @@ -119,8 +119,12 @@ The dev instance should exercise the real system: - real LLM: needs a model credential for the harness you run. Core supports several (`HARNESS=pi|opencode|codex|claude`); the launcher picks one from the credentials it - finds and honours an explicit `HARNESS`. Set the key your chosen harness expects, or - pass `DEV_INSTANCE_ALLOW_MOCK=1` for a deliberate no-model wiring check + finds and honours an explicit `HARNESS`. Set the key your chosen harness expects. For + Codex, a ChatGPT OAuth session is also supported: `HARNESS=codex` discovers a valid + `$HOME/.codex/auth.json`, or you can set `CODEX_AUTH_FILE` to another auth file. The + launcher runs Codex in an isolated home and persists refreshed OAuth tokens. Pass + `DEV_INSTANCE_ALLOW_MOCK=1` for a deliberate no-model wiring check. This OAuth path is + for local dev instances; deployed production processes still require an API key. - real durability: uses `DATABASE_URL` when supplied; otherwise starts/reuses a local Docker Postgres container and runs core with `SESSION_STORE=postgres` and `RUN_STORE=postgres` diff --git a/.env.example b/.env.example index 60c039600..549fec930 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,6 @@ HARNESS=pi +CODEX_AUTH_FILE= HARNESS_SECURITY_POSTURE=auto #ANTHROPIC_API_KEY=sk-ant-... diff --git a/scripts/dev/lib/envctx.ts b/scripts/dev/lib/envctx.ts index e22d460ed..cfb169b94 100644 --- a/scripts/dev/lib/envctx.ts +++ b/scripts/dev/lib/envctx.ts @@ -4,11 +4,13 @@ import { dirname, join } from "node:path"; import { liveEnvPath } from "./pool.ts"; import { bestEffort, readEnvFile, sha256Hex } from "./util.ts"; import { run } from "./proc.ts"; +import { codexAuthFileForEnv, readCodexOAuthAuthFile } from "../../../src/harness/codex-auth.ts"; export interface AssembledEnv { env: Record; anthropicKeySource: string; openaiKeySource: string; + codexAuthSource: string; harness: "pi" | "mock" | "opencode" | "codex" | "claude"; liveEnvFile: string; warnings: string[]; @@ -100,6 +102,7 @@ export async function assembleEnv(opts: { for (const [k, v] of Object.entries(readEnvFile(liveEnvFile))) { if (!env[k]) env[k] = v; } + const wtEnv = readEnvFile(join(opts.worktree, ".env")); let anthropicKeySource = ""; if (opts.callerEnv.ANTHROPIC_API_KEY) anthropicKeySource = "your shell export"; @@ -112,7 +115,6 @@ export async function assembleEnv(opts: { anthropicKeySource = liveEnvFile; } } - const wtEnv = readEnvFile(join(opts.worktree, ".env")); if (!env.ANTHROPIC_API_KEY && wtEnv.ANTHROPIC_API_KEY) { env.ANTHROPIC_API_KEY = wtEnv.ANTHROPIC_API_KEY; anthropicKeySource = "the worktree .env"; @@ -125,13 +127,22 @@ export async function assembleEnv(opts: { openaiKeySource = "the worktree .env"; } + if (!env.CODEX_AUTH_FILE && wtEnv.CODEX_AUTH_FILE) env.CODEX_AUTH_FILE = wtEnv.CODEX_AUTH_FILE; + let codexAuthSource = ""; + const codexAuthCandidate = codexAuthFileForEnv({ ...env, ...opts.callerEnv }, true); + const codexOAuthConfigured = Boolean(codexAuthCandidate && readCodexOAuthAuthFile(codexAuthCandidate)); + if (codexOAuthConfigured && codexAuthCandidate) { + env.CODEX_AUTH_FILE = codexAuthCandidate; + codexAuthSource = codexAuthCandidate; + } + let harness: "pi" | "mock" | "opencode" | "codex" | "claude"; if (opts.callerEnv.HARNESS === "codex" || opts.callerEnv.HARNESS === "claude") { harness = opts.callerEnv.HARNESS; env.HARNESS = harness; - if (harness === "codex" && !env.OPENAI_API_KEY) { + if (harness === "codex" && !env.OPENAI_API_KEY && !codexOAuthConfigured) { throw new Error( - "HARNESS=codex needs OPENAI_API_KEY (its CLI cannot do browser OAuth in a container) -- export it, or add it to the live env file or the worktree .env", + "HARNESS=codex needs OPENAI_API_KEY or a readable ChatGPT OAuth auth.json via CODEX_AUTH_FILE (or ~/.codex/auth.json)", ); } } else if (env.ANTHROPIC_API_KEY) { @@ -155,7 +166,7 @@ export async function assembleEnv(opts: { if (!env[k] && wtEnv[k]) env[k] = wtEnv[k]; } - return { env, anthropicKeySource, openaiKeySource, harness, liveEnvFile, warnings }; + return { env, anthropicKeySource, openaiKeySource, codexAuthSource, harness, liveEnvFile, warnings }; } export function envFileGet(path: string, key: string): string { diff --git a/scripts/dev/supervisor/main.ts b/scripts/dev/supervisor/main.ts index 5eedb3076..30a608aec 100644 --- a/scripts/dev/supervisor/main.ts +++ b/scripts/dev/supervisor/main.ts @@ -314,7 +314,9 @@ async function assembleAndPrepare(spec: BootSpec): Promise { let harnessDetail = `live ${assembled.harness} turns (anthropic key from ${assembled.anthropicKeySource})`; if (assembled.harness === "mock") harnessDetail = "mock turns"; else if (assembled.harness === "codex") { - harnessDetail = `live codex turns (openai key from ${assembled.openaiKeySource || "the environment"})`; + harnessDetail = assembled.codexAuthSource + ? "live codex turns (ChatGPT OAuth auth.json)" + : `live codex turns (openai key from ${assembled.openaiKeySource || "the environment"})`; } else if (assembled.harness === "claude") harnessDetail = "live claude turns (native CLI authentication)"; phase("env", "ok", harnessDetail); diff --git a/scripts/dev/supervisor/specs.ts b/scripts/dev/supervisor/specs.ts index c1d308bd6..95b824288 100644 --- a/scripts/dev/supervisor/specs.ts +++ b/scripts/dev/supervisor/specs.ts @@ -21,6 +21,7 @@ export interface SpecInputs { export function buildChildSpecs(i: SpecInputs): ChildSpec[] { const watchArgs = i.watch ? ["--watch"] : []; const base = { ...i.baseEnv, ...i.sandboxEnv }; + const siblingBase = { ...base, CODEX_AUTH_FILE: "" }; const orgId = i.baseEnv.DEV_INSTANCE_ORG_ID || "acme"; const signing: Record = i.coreSigningSecret ? { CORE_SIGNING_SECRET: i.coreSigningSecret } : {}; return [ @@ -54,7 +55,7 @@ export function buildChildSpecs(i: SpecInputs): ChildSpec[] { cwd: join(i.worktree, "plugins/web-ui"), argv: ["node", "--env-file-if-exists=.env", "server/index.ts"], env: { - ...base, + ...siblingBase, ...signing, PORT: String(i.ports.web), CORE_API_URL: `http://localhost:${i.ports.core}`, @@ -74,7 +75,7 @@ export function buildChildSpecs(i: SpecInputs): ChildSpec[] { cwd: join(i.worktree, "plugins/admin"), argv: ["node", `--env-file-if-exists=${join(i.worktree, ".env")}`, ...watchArgs, "src/index.ts"], env: { - ...base, + ...siblingBase, ...signing, PORT: String(i.ports.admin), CORE_API_URL: `http://localhost:${i.ports.core}`, @@ -91,7 +92,7 @@ export function buildChildSpecs(i: SpecInputs): ChildSpec[] { cwd: join(i.worktree, "plugins/portal"), argv: ["node", ...watchArgs, "src/index.ts"], env: { - ...base, + ...siblingBase, ...signing, PORT: String(i.ports.portal), PORTAL_PUBLIC_URL: `http://localhost:${i.ports.portal}`, diff --git a/src/api/routes/surface.ts b/src/api/routes/surface.ts index 397a558d8..da3a9fb43 100644 --- a/src/api/routes/surface.ts +++ b/src/api/routes/surface.ts @@ -856,6 +856,8 @@ async function getSurfaceConfig(ctx: ApiCtx): Promise { ]); const harnessId = deps.harnessId ?? "pi"; const managedKeys = deps.modelCredentials ? await deps.modelCredentials.availability() : null; + const configuredKeys = deps.providerKeys ?? managedKeys; + const providerStatus = harnessId === "pi" && managedKeys ? managedKeys : configuredKeys; const catalog = managedKeys?.openrouter ? await selectableModelCatalog(deps.modelCredentialFetch) : builtInModelCatalog(); @@ -889,7 +891,7 @@ async function getSurfaceConfig(ctx: ApiCtx): Promise { webuiModels: configuredPicker.length ? configuredPicker : allowed, baseModel: resolvedBase, harnessId, - ...(managedKeys ? { modelProviderConfigured: Object.values(managedKeys).some(Boolean) } : {}), + ...(providerStatus && { modelProviderConfigured: Object.values(providerStatus).some(Boolean) }), externalSlackParticipants, ...(Object.keys(resolvedBranding).length ? { branding: resolvedBranding } : {}), }); diff --git a/src/config.ts b/src/config.ts index 534134b09..b020b3d5f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -11,6 +11,7 @@ import { validateCoreSecretEnv } from "./deployment/secret-schema.ts"; import { DEFAULT_CAPTURE_QUIET_MS } from "./memory/strategies/per-turn.ts"; import { parseSecurityPosture, type SecurityPosture } from "./security/security-posture.ts"; import { slackPluginConfigFromEnv, type SlackPluginConfig } from "./slack/config.ts"; +import { codexAuthFileForEnv, readCodexOAuthAuthFile } from "./harness/codex-auth.ts"; import { MODEL_PROVIDERS, defaultModelForProvider, @@ -39,6 +40,7 @@ export interface Config { opencodeModel?: string; codexModel?: string; codexBinPath?: string; + codexAuthFile?: string; codexProcessEnv: NodeJS.ProcessEnv; claudeModel?: string; claudeBinPath?: string; @@ -155,6 +157,7 @@ export function providerKeysPresent(config: Config): ModelProviderAvailability { anthropic: Boolean(config.anthropicApiKey), openai: Boolean(config.openaiApiKey), openrouter: Boolean(config.openrouterApiKey), + ...(config.harness === "codex" && config.codexAuthFile ? { codexOAuth: true } : {}), }; } @@ -551,10 +554,25 @@ function modelProviderEnvStrict(env: NodeJS.ProcessEnv): ModelProvider | undefin } export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { - const missingSecrets = validateCoreSecretEnv(env); + const harness = harnessEnvStrict(env.HARNESS); + const codexAuthCandidate = harness === "codex" ? codexAuthFileForEnv(env, true) : undefined; + const codexOAuthConfigured = Boolean(codexAuthCandidate && readCodexOAuthAuthFile(codexAuthCandidate)); + const secretEnv = + codexOAuthConfigured && codexAuthCandidate + ? { ...env, CODEX_AUTH_FILE: codexAuthCandidate } + : { ...env, CODEX_AUTH_FILE: undefined }; + const missingSecrets = validateCoreSecretEnv(secretEnv); if (missingSecrets.length) { throw new Error(`missing or insecure required core secrets: ${missingSecrets.join(", ")}`); } + if (harness === "codex" && !env.OPENAI_API_KEY?.trim() && !codexOAuthConfigured) { + throw new Error( + "HARNESS=codex needs OPENAI_API_KEY or a readable ChatGPT OAuth auth.json via CODEX_AUTH_FILE (or ~/.codex/auth.json)", + ); + } + if (env.NODE_ENV === "production" && codexOAuthConfigured) { + throw new Error("CODEX_AUTH_FILE is supported for local Codex harnesses only"); + } const modelProvider = modelProviderEnvStrict(env); for (const key of ["SESSION_STORE", "RUN_STORE", "ARTIFACT_STORE"] as const) { if (env[key] === "sqlite") { @@ -636,6 +654,9 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { const deployProvider: "aws" | "docker" = env.DEPLOY_PROVIDER === "aws" ? "aws" : "docker"; let runStore: "memory" | "postgres" = env.SESSION_STORE === "postgres" ? "postgres" : "memory"; if (env.RUN_STORE === "memory" || env.RUN_STORE === "postgres") runStore = env.RUN_STORE; + const codexEnv = { ...env }; + if (codexOAuthConfigured && codexAuthCandidate) codexEnv.CODEX_AUTH_FILE = codexAuthCandidate; + else delete codexEnv.CODEX_AUTH_FILE; const codexProcessEnv = Object.fromEntries( [ "PATH", @@ -654,7 +675,8 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { "CODEX_ACCESS_TOKEN", "HOME", "CODEX_HOME", - ].flatMap((name) => (env[name] === undefined ? [] : [[name, env[name]]])), + "CODEX_AUTH_FILE", + ].flatMap((name) => (codexEnv[name] === undefined ? [] : [[name, codexEnv[name]]])), ) as NodeJS.ProcessEnv; const claudeProcessEnv = Object.fromEntries( [ @@ -689,7 +711,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { orgId: env.ORG_ID ?? DEFAULT_ORG_ID, sessionStore: env.SESSION_STORE === "postgres" ? "postgres" : "memory", ...(env.DATABASE_URL ? { databaseUrl: env.DATABASE_URL } : {}), - harness: harnessEnvStrict(env.HARNESS), + harness, securityPosture: securityPostureEnvStrict(env.HARNESS_SECURITY_POSTURE), securityScreenBackend, ...(securityScreenBackend === "proxy" @@ -717,6 +739,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { ...(env.OPENCODE_MODEL || env.PI_MODEL ? { opencodeModel: env.OPENCODE_MODEL || env.PI_MODEL } : {}), ...(env.CODEX_MODEL ? { codexModel: env.CODEX_MODEL } : {}), ...(env.CODEX_BIN ? { codexBinPath: env.CODEX_BIN } : {}), + ...(codexOAuthConfigured && codexAuthCandidate ? { codexAuthFile: codexAuthCandidate } : {}), codexProcessEnv, ...(env.CLAUDE_MODEL ? { claudeModel: env.CLAUDE_MODEL } : {}), ...(env.CLAUDE_BIN ? { claudeBinPath: env.CLAUDE_BIN } : {}), diff --git a/src/deployment/secret-schema.ts b/src/deployment/secret-schema.ts index 47e2e86fa..7d904e361 100644 --- a/src/deployment/secret-schema.ts +++ b/src/deployment/secret-schema.ts @@ -41,7 +41,7 @@ export const CORE_SECRET_SPECS: readonly RuntimeSecretSpec[] = [ const GATE_PREDICATES: Readonly boolean>> = { production: (env) => env.NODE_ENV === "production", - codex: (env) => env.HARNESS?.trim() === "codex", + codex: (env) => env.HARNESS?.trim() === "codex" && !env.CODEX_AUTH_FILE?.trim(), postgres: (env) => env.SESSION_STORE === "postgres" || env.RUN_STORE === "postgres", sprites: (env) => env.SANDBOX_BACKEND === "sprites" || env.SANDBOX_SECONDARY_BACKEND === "sprites", "fly-sandbox": (env) => env.SANDBOX_BACKEND === "fly", @@ -51,7 +51,8 @@ const GATE_PREDICATES: Readonly b "dropbox-oauth": (env) => Boolean(env.DROPBOX_OAUTH_CLIENT_ID), "linear-oauth": (env) => Boolean(env.LINEAR_OAUTH_CLIENT_ID), "model-anthropic": (env) => env.MODEL_PROVIDER?.trim() === "anthropic", - "model-openai": (env) => env.MODEL_PROVIDER?.trim() === "openai", + "model-openai": (env) => + env.MODEL_PROVIDER?.trim() === "openai" && !(env.HARNESS?.trim() === "codex" && env.CODEX_AUTH_FILE?.trim()), "model-openrouter": (env) => env.MODEL_PROVIDER?.trim() === "openrouter", }; diff --git a/src/harness/codex-app-server.ts b/src/harness/codex-app-server.ts index 6eb7607e6..d3415be40 100644 --- a/src/harness/codex-app-server.ts +++ b/src/harness/codex-app-server.ts @@ -18,6 +18,97 @@ type JsonRpcMessage = { error?: { code?: number; message?: string; data?: unknown }; }; +type JsonRpcResultValidator = (value: unknown) => value is T; + +function isJsonRpcId(value: unknown): value is JsonRpcId { + return (typeof value === "string" && value.length > 0) || (typeof value === "number" && Number.isFinite(value)); +} + +function isJsonRpcMessage(value: unknown): value is JsonRpcMessage { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const message = value as Record; + const hasId = "id" in message && message.id !== undefined; + const hasMethod = "method" in message; + const hasResult = "result" in message; + const hasError = "error" in message; + if (!hasId && !hasMethod) return false; + if (hasId && !isJsonRpcId(message.id)) return false; + if (hasMethod && typeof message.method !== "string") return false; + if (hasMethod && (hasResult || hasError)) return false; + if (!hasMethod && (!hasId || !(hasResult || hasError))) return false; + if (hasResult && hasError) return false; + if (hasError) { + const error = message.error; + if (!error || typeof error !== "object" || Array.isArray(error)) return false; + const errorRecord = error as Record; + if (typeof errorRecord.code !== "number" || typeof errorRecord.message !== "string") return false; + } + return true; +} + +const CODEX_DIAGNOSTIC_SENSITIVE_KEYS = new Set([ + "accesstoken", + "refreshtoken", + "idtoken", + "apikey", + "clientsecret", + "token", + "authorization", + "proxyauthorization", + "cookie", + "setcookie", +]); + +function diagnosticKeyIsSensitive(key: string): boolean { + return CODEX_DIAGNOSTIC_SENSITIVE_KEYS.has(key.toLowerCase().replace(/[^a-z]/g, "")); +} + +function redactStructuredDiagnosticsValue(value: unknown, sensitive = false): unknown { + if (sensitive) return "[redacted]"; + if (Array.isArray(value)) return value.map((item) => redactStructuredDiagnosticsValue(item)); + if (!value || typeof value !== "object") return value; + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [ + key, + redactStructuredDiagnosticsValue(item, diagnosticKeyIsSensitive(key)), + ]), + ); +} + +function redactStructuredDiagnostics(value: string): string { + try { + const parsed: unknown = JSON.parse(value); + if (!parsed || typeof parsed !== "object") return value; + return JSON.stringify(redactStructuredDiagnosticsValue(parsed)); + } catch { + return value; + } +} + +export function redactCodexDiagnostics(value: string): string { + return redactStructuredDiagnostics(value) + .replace( + /(["']?(?:access[_-]?token|refresh[_-]?token|id[_-]?token|api[_-]?key|apikey|client[_-]?secret|token|authorization|proxy-authorization|cookie|set-cookie)["']?\s*[:=]\s*)\[[\s\S]*?(?:\]|$)/gi, + "$1[redacted]", + ) + .replace( + /(["']?(?:access[_-]?token|refresh[_-]?token|id[_-]?token|api[_-]?key|apikey|client[_-]?secret|token|authorization|proxy-authorization|cookie|set-cookie)["']?\s*[:=]\s*)\{[\s\S]*$/gi, + "$1{redacted}", + ) + .replace( + /(["']?(?:access[_-]?token|refresh[_-]?token|id[_-]?token|api[_-]?key|apikey|client[_-]?secret|token|authorization|proxy-authorization|cookie|set-cookie)["']?\s*[:=]\s*)(["'])(?:(?:\\[\s\S])|(?!\2)[\s\S])*(?:\2|$)/gi, + "$1$2[redacted]$2", + ) + .replace( + /(["']?(?:access[_-]?token|refresh[_-]?token|id[_-]?token|api[_-]?key|apikey|client[_-]?secret|token|authorization|proxy-authorization|cookie|set-cookie)["']?\s*[:=]\s*)(?!(?:["']|\[))[^,\r\n}\]]+/gi, + "$1[redacted]", + ) + .replace(/\b(?:Basic|Digest)\s+\S+/gi, "[redacted]") + .replace(/\bBearer\s+\S+/gi, "Bearer [redacted]") + .replace(/\bsk-[A-Za-z0-9._-]{8,}/g, "[redacted]") + .replace(/\b[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, "[redacted]"); +} + export interface CodexAppServerOptions { binaryPath: string; cwd: string; @@ -30,7 +121,10 @@ export class CodexAppServer { readonly process: ChildProcess; private readonly options: CodexAppServerOptions; private nextId = 1; - private readonly pending = new Map(); + private readonly pending = new Map< + JsonRpcId, + { resolve(value: unknown): void; reject(error: Error): void; validate?: JsonRpcResultValidator } + >(); private writeTail = Promise.resolve(); private eventTail = Promise.resolve(); private stderr = ""; @@ -69,8 +163,9 @@ export class CodexAppServer { }); this.process.once("close", (code, signal) => { this.closed = true; + const stderr = redactCodexDiagnostics(this.stderr.trim()); this.closeError = new Error( - `Codex app-server exited (${code ?? signal ?? "unknown"})${this.stderr.trim() ? `: ${this.stderr.trim()}` : ""}`, + `Codex app-server exited (${code ?? signal ?? "unknown"})${stderr ? `: ${stderr}` : ""}`, ); this.failAll(this.closeError); resolveProcessClosed(); @@ -89,18 +184,28 @@ export class CodexAppServer { await this.notify("initialized"); } - request(method: string, params?: unknown): Promise { + request(method: string, params?: unknown): Promise; + request(method: string, params: unknown, validate: JsonRpcResultValidator): Promise; + request(method: string, params?: unknown, validate?: JsonRpcResultValidator): Promise { if (this.closed) return Promise.reject(new Error("Codex app-server is closed")); const id = this.nextId++; - const result = new Promise((resolve, reject) => { - this.pending.set(id, { resolve: (value) => resolve(value as T), reject }); + const result = new Promise((resolve, reject) => { + this.pending.set(id, { + resolve, + reject, + ...(validate ? { validate: validate as JsonRpcResultValidator } : {}), + }); }); void this.send({ id, method, ...(params === undefined ? {} : { params }) }).catch((error) => { const waiter = this.pending.get(id); this.pending.delete(id); waiter?.reject(error instanceof Error ? error : new Error(String(error))); }); - return result; + if (!validate) return result; + return result.then((value) => { + if (!validate(value)) throw new CodexRpcError("Codex app-server response has an invalid result"); + return value; + }); } async notify(method: string, params?: unknown): Promise { @@ -120,21 +225,35 @@ export class CodexAppServer { if (!line.trim()) return; let message: JsonRpcMessage; try { - message = JSON.parse(line) as JsonRpcMessage; + const parsed: unknown = JSON.parse(line); + if (!isJsonRpcMessage(parsed)) throw new Error("Codex app-server emitted an invalid JSON-RPC message"); + message = parsed; } catch { - throw new Error(`Codex app-server emitted invalid JSON: ${line.slice(0, 500)}`); + throw new Error(redactCodexDiagnostics(`Codex app-server emitted invalid JSON: ${line.slice(0, 500)}`)); } if (message.id !== undefined && !message.method) { const waiter = this.pending.get(message.id); - if (!waiter) return; + if (!waiter) throw new CodexRpcError(`Codex app-server sent an unknown response id ${String(message.id)}`); this.pending.delete(message.id); - if (message.error) + if ("error" in message) { + if (!message.error || typeof message.error !== "object") { + waiter.reject(new CodexRpcError("Codex app-server response has an invalid error")); + return; + } waiter.reject( new CodexRpcError( - `Codex ${message.error.code ?? "error"}: ${message.error.message ?? JSON.stringify(message.error.data)}`, + redactCodexDiagnostics( + `Codex ${message.error.code ?? "error"}: ${message.error.message ?? JSON.stringify(message.error.data)}`, + ), ), ); - else waiter.resolve(message.result); + } else if ("result" in message) { + if (waiter.validate && !waiter.validate(message.result)) { + waiter.reject(new CodexRpcError("Codex app-server response has an invalid result")); + return; + } + waiter.resolve(message.result); + } else waiter.reject(new CodexRpcError("Codex app-server response is missing result or error")); return; } if (!message.method) return; diff --git a/src/harness/codex-auth.ts b/src/harness/codex-auth.ts new file mode 100644 index 000000000..1237a140e --- /dev/null +++ b/src/harness/codex-auth.ts @@ -0,0 +1,326 @@ +import { randomBytes } from "node:crypto"; +import { open as openFile } from "node:fs/promises"; +import { + chmodSync, + closeSync, + existsSync, + mkdirSync, + openSync, + readFileSync, + renameSync, + rmSync, + statSync, + unlinkSync, + writeFileSync, + writeSync, +} from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { swallow } from "../util/errors.ts"; + +type JsonObject = Record; + +const CODEX_OAUTH_MODES = new Set(["chatgpt", "chatgptAuthTokens"]); + +function asObject(value: unknown): JsonObject | null { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonObject) : null; +} + +function codexOAuthJwtAccountId(value: unknown): string | undefined { + const auth = asObject(value); + const tokens = auth ? asObject(auth.tokens) : null; + if (typeof tokens?.id_token !== "string") return undefined; + try { + const payload = asObject( + JSON.parse(Buffer.from(tokens.id_token.split(".")[1] ?? "", "base64url").toString("utf8")), + ); + const claims = payload ? asObject(payload["https://api.openai.com/auth"]) : null; + return typeof claims?.chatgpt_account_id === "string" && claims.chatgpt_account_id + ? claims.chatgpt_account_id + : undefined; + } catch { + return undefined; + } +} + +function readJsonFile(path: string): JsonObject | null { + try { + return asObject(JSON.parse(readFileSync(path, "utf8"))); + } catch { + return null; + } +} + +function expandPath(path: string): string { + if (path === "~") return homedir(); + if (path.startsWith("~/")) return join(homedir(), path.slice(2)); + return resolve(path); +} + +export function codexAuthFileForEnv(env: NodeJS.ProcessEnv, includeDefault = false): string | undefined { + const explicit = env.CODEX_AUTH_FILE?.trim(); + if (explicit) return expandPath(explicit); + if (!includeDefault) return undefined; + const codexHome = env.CODEX_HOME?.trim(); + if (codexHome) return join(expandPath(codexHome), "auth.json"); + const home = env.HOME?.trim(); + return home ? join(expandPath(home), ".codex", "auth.json") : undefined; +} + +function isCodexOAuthAuth(value: unknown): value is JsonObject { + const auth = asObject(value); + if (!auth || typeof auth.auth_mode !== "string" || !CODEX_OAUTH_MODES.has(auth.auth_mode)) return false; + const tokens = asObject(auth.tokens); + return Boolean( + tokens && + typeof tokens.access_token === "string" && + tokens.access_token && + typeof tokens.refresh_token === "string" && + tokens.refresh_token && + codexOAuthJwtAccountId(auth), + ); +} + +export function readCodexOAuthAuthFile(path: string): JsonObject | null { + try { + if (statSync(path).mode & 0o077) return null; + } catch { + return null; + } + const auth = readJsonFile(path); + return isCodexOAuthAuth(auth) ? auth : null; +} + +export function sanitizedCodexOAuthAuth(auth: JsonObject): JsonObject { + const copy: JsonObject = {}; + for (const key of ["auth_mode", "last_refresh", "tokens"] as const) { + if (key === "tokens") { + const tokens = asObject(auth.tokens); + if (tokens) { + copy.tokens = Object.fromEntries( + ["access_token", "refresh_token", "id_token", "account_id"].flatMap((token) => + typeof tokens[token] === "string" ? [[token, tokens[token]]] : [], + ), + ); + } + } else if (key in auth) copy[key] = auth[key]; + } + return copy; +} + +export function codexOAuthRefreshToken(value: unknown): string | undefined { + const auth = asObject(value); + const tokens = auth ? asObject(auth.tokens) : null; + return typeof tokens?.refresh_token === "string" && tokens.refresh_token ? tokens.refresh_token : undefined; +} + +export function codexOAuthAccessToken(value: unknown): string | undefined { + const auth = asObject(value); + const tokens = auth ? asObject(auth.tokens) : null; + return typeof tokens?.access_token === "string" && tokens.access_token ? tokens.access_token : undefined; +} + +function writeJsonAtomically(path: string, value: JsonObject): void { + const directory = dirname(path); + mkdirSync(directory, { recursive: true }); + const temporary = join(directory, `.qm-codex-auth-${process.pid}-${randomBytes(8).toString("hex")}.tmp`); + try { + writeFileSync(temporary, JSON.stringify(value), { mode: 0o600 }); + chmodSync(temporary, 0o600); + renameSync(temporary, path); + } finally { + rmSync(temporary, { force: true }); + } +} + +function lockPath(sourcePath: string): string { + return `${sourcePath}.lock`; +} + +export interface CodexOAuthAuthLock { + path: string; + isHeld(): boolean; + release(): Promise; +} + +function processAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return Boolean(error && typeof error === "object" && "code" in error && error.code !== "ESRCH"); + } +} + +function removeStaleLock(path: string): boolean { + let contents: string; + try { + contents = readFileSync(path, "utf8"); + const owner = Number(contents.trim().split(":", 1)[0]); + if (Number.isInteger(owner) && owner > 0) { + if (processAlive(owner)) return false; + } else if (Date.now() - statSync(path).mtimeMs <= 60_000) return false; + } catch { + return true; + } + const detached = `${path}.stale-${process.pid}-${randomBytes(6).toString("hex")}`; + try { + renameSync(path, detached); + } catch { + return true; + } + try { + if (readFileSync(detached, "utf8") !== contents) { + if (!existsSync(path)) renameSync(detached, path); + return false; + } + unlinkSync(detached); + return true; + } catch { + if (!existsSync(path)) { + try { + renameSync(detached, path); + } catch (error) { + swallow("codex: stale lock restore", error); + } + } + return true; + } +} + +export async function acquireCodexOAuthAuthLock( + sourcePath: string, + signal?: AbortSignal, + timeoutMs = 120_000, +): Promise { + const path = lockPath(sourcePath); + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (signal?.aborted) throw new Error("Codex OAuth auth lock acquisition cancelled"); + try { + const handle = await openFile(path, "wx", 0o600); + const owner = `${process.pid}:${randomBytes(8).toString("hex")}`; + await handle.writeFile(owner); + let released = false; + return { + path, + isHeld() { + if (released) return false; + try { + return readFileSync(path, "utf8") === owner; + } catch { + return false; + } + }, + async release() { + if (released) return; + released = true; + await handle.close().catch(() => undefined); + try { + if (readFileSync(path, "utf8") === owner) unlinkSync(path); + } catch { + return; + } + }, + }; + } catch (error) { + const code = error && typeof error === "object" && "code" in error ? error.code : undefined; + if (code !== "EEXIST") throw error; + removeStaleLock(path); + await new Promise((resolveWait) => { + const onAbort = () => { + clearTimeout(timer); + signal?.removeEventListener("abort", onAbort); + resolveWait(); + }; + const timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolveWait(); + }, 100); + signal?.addEventListener("abort", onAbort, { once: true }); + }); + } + } + throw new Error("timed out acquiring the Codex OAuth auth lock"); +} + +type SyncLock = { fd: number; owner: string }; + +function lockFile(sourcePath: string): SyncLock { + const path = lockPath(sourcePath); + for (let attempt = 0; attempt < 25; attempt += 1) { + try { + const fd = openSync(path, "wx", 0o600); + const owner = `${process.pid}:${randomBytes(8).toString("hex")}`; + writeSync(fd, owner); + return { fd, owner }; + } catch (error) { + const code = error && typeof error === "object" && "code" in error ? error.code : undefined; + if (code !== "EEXIST") throw error; + removeStaleLock(path); + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10); + } + } + throw new Error("timed out acquiring the Codex OAuth auth lock"); +} + +export function syncCodexOAuthAuthFile( + sourcePath: string | undefined, + childPath: string, + heldLockPath?: string, + expectedRefreshToken?: string, + expectedAccessToken?: string, + expectedSourceAuth?: JsonObject, +): void { + if (!sourcePath) return; + const child = readCodexOAuthAuthFile(childPath); + if (!child) return; + const lock = heldLockPath ? undefined : lockFile(sourcePath); + try { + const source = readJsonFile(sourcePath); + if (!source) return; + if (source.auth_mode !== child.auth_mode) return; + const sourceTokens = asObject(source.tokens); + const childTokens = asObject(child.tokens); + const sourceAccountId = codexOAuthJwtAccountId(source); + const childAccountId = codexOAuthJwtAccountId(child); + if ( + !sourceTokens || + !childTokens || + typeof sourceTokens.id_token !== "string" || + typeof childTokens.id_token !== "string" || + sourceTokens.id_token !== childTokens.id_token || + !sourceAccountId || + sourceAccountId !== childAccountId + ) + return; + if (expectedSourceAuth && JSON.stringify(source) !== JSON.stringify(expectedSourceAuth)) return; + if (expectedRefreshToken && codexOAuthRefreshToken(source) !== expectedRefreshToken) return; + if (expectedAccessToken && codexOAuthAccessToken(source) !== expectedAccessToken) return; + const sanitized = sanitizedCodexOAuthAuth(child); + const next = { + ...source, + ...sanitized, + ...(childTokens + ? { + tokens: { + ...sourceTokens, + ...childTokens, + ...(typeof sourceTokens.account_id === "string" ? { account_id: sourceTokens.account_id } : {}), + }, + } + : {}), + }; + if (JSON.stringify(next) === JSON.stringify(source)) return; + writeJsonAtomically(sourcePath, next); + } finally { + if (lock !== undefined) { + try { + if (readFileSync(lockPath(sourcePath), "utf8") === lock.owner) unlinkSync(lockPath(sourcePath)); + } catch (error) { + swallow("codex: oauth lock cleanup", error); + } + closeSync(lock.fd); + } + } +} diff --git a/src/harness/codex-harness.ts b/src/harness/codex-harness.ts index e40eaf8f1..bb0b57bb6 100644 --- a/src/harness/codex-harness.ts +++ b/src/harness/codex-harness.ts @@ -13,7 +13,17 @@ import type { ScopeId, SessionEntry } from "../types.ts"; import { swallow } from "../util/errors.ts"; import { countTokens } from "../util/tokens.ts"; import { parseSecurityScreenVerdict, SECURITY_SCREEN_SYSTEM_PROMPT } from "../security/security-posture.ts"; -import { CodexAppServer, CodexRpcError } from "./codex-app-server.ts"; +import { CodexAppServer, CodexRpcError, redactCodexDiagnostics } from "./codex-app-server.ts"; +import { + codexAuthFileForEnv, + acquireCodexOAuthAuthLock, + codexOAuthAccessToken, + codexOAuthRefreshToken, + readCodexOAuthAuthFile, + sanitizedCodexOAuthAuth, + syncCodexOAuthAuthFile, + type CodexOAuthAuthLock, +} from "./codex-auth.ts"; import { defineHarness, type Harness, type HarnessTurnInput, type HarnessTurnResult } from "./harness.ts"; import { coreToolOptions, createPiTools, type PiToolsOptions, type ToolContextRef } from "./pi-tools.ts"; import { reconstructMessagesFromHistory, seedPriorTurns, type PiReplayMessage } from "./replay.ts"; @@ -78,7 +88,59 @@ type BridgedTool = { type CodexItem = Record & { type: string }; type CodexTurn = { id: string; status: string; error?: { message?: string } | null; items?: CodexItem[] }; +const CODEX_TERMINAL_TURN_STATUSES = new Set(["completed", "failed", "interrupted", "cancelled", "canceled"]); + +function isCodexThreadStart(value: unknown): value is { thread: { id: string }; model?: string } { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const response = value as Record; + const thread = response.thread; + return Boolean( + thread && + typeof thread === "object" && + !Array.isArray(thread) && + typeof (thread as Record).id === "string" && + (!("model" in response) || typeof response.model === "string"), + ); +} + +function isCodexTurnStart(value: unknown): value is { turn: CodexTurn } { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const turn = (value as Record).turn; + if (!turn || typeof turn !== "object" || Array.isArray(turn)) return false; + const response = turn as Record; + return typeof response.id === "string" && typeof response.status === "string"; +} + +function isCodexTurn(value: unknown): value is CodexTurn { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const turn = value as Record; + if (typeof turn.id !== "string" || typeof turn.status !== "string") return false; + if (!CODEX_TERMINAL_TURN_STATUSES.has(turn.status)) return false; + if ( + "items" in turn && + (!Array.isArray(turn.items) || + turn.items.some( + (item) => + !item || + typeof item !== "object" || + Array.isArray(item) || + typeof (item as Record).type !== "string" || + !(item as Record).type, + )) + ) + return false; + const error = turn.error; + return ( + error === undefined || + error === null || + (typeof error === "object" && + !Array.isArray(error) && + (!("message" in error) || typeof (error as Record).message === "string")) + ); +} + type ActiveTurn = { + server: CodexAppServer; threadId: string; turn: HarnessTurnInput; tools: Map; @@ -100,7 +162,21 @@ type ActiveTurn = { stopped: boolean; }; -type Runtime = { server: CodexAppServer; jail: string }; +type Runtime = { + server: CodexAppServer; + jail: string; + persistAuth( + expectedRefreshToken?: string, + expectedAccessToken?: string, + heldLockPath?: string, + expectedSourceAuth?: Record, + ): void; +}; +type StartingRuntime = { + promise: Promise; + abort: AbortController; + waiters: number; +}; const CODEX_START_TIMEOUT_MS = 30_000; const CODEX_NON_RETRYABLE_PATTERN = @@ -111,7 +187,8 @@ export function codexNonRetryable(message: string): boolean { } export function codexProviderFailure(message: string): Error { - return codexNonRetryable(message) ? new NonRetryableTurnError(message) : new Error(message); + const safe = redactCodexDiagnostics(message); + return codexNonRetryable(safe) ? new NonRetryableTurnError(safe) : new Error(safe); } const CODEX_CHILD_TOOL_NAMES = new Set(["execute", "read", "write", "publish", "memory", "history", "background"]); @@ -190,7 +267,11 @@ export function codexChildEnv(source: NodeJS.ProcessEnv, jail: string): NodeJS.P HOME: jail, CODEX_HOME: join(jail, "codex-home"), }; + const authPath = codexAuthFileForEnv(source, true); + const oauthAuth = authPath ? readCodexOAuthAuthFile(authPath) : null; for (const name of CODEX_ENV_PASSTHROUGH) { + if (oauthAuth && (name === "OPENAI_API_KEY" || name === "OPENAI_BASE_URL" || name === "CODEX_ACCESS_TOKEN")) + continue; if (source[name] !== undefined) env[name] = source[name]; } return env; @@ -199,6 +280,12 @@ export function codexChildEnv(source: NodeJS.ProcessEnv, jail: string): NodeJS.P export function prepareCodexHome(source: NodeJS.ProcessEnv, jail: string): string { const target = join(jail, "codex-home"); mkdirSync(target, { recursive: true }); + const authPath = codexAuthFileForEnv(source, true); + const oauthAuth = authPath ? readCodexOAuthAuthFile(authPath) : null; + if (oauthAuth) { + writeFileSync(join(target, "auth.json"), JSON.stringify(sanitizedCodexOAuthAuth(oauthAuth)), { mode: 0o600 }); + return target; + } if (source.OPENAI_API_KEY) { writeFileSync( join(target, "auth.json"), @@ -356,9 +443,19 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { DEFAULT_CODEX_MODEL_ID, ].find((id): id is string => modelSupportedByHarness(id, "codex"))!; const defaultTurnWallClockMs = opts.turnWallClockMs ?? CONFIG_DEFAULTS.turnWallClockSec * 1000; + const sourceEnv = opts.env ?? {}; + const authPath = codexAuthFileForEnv(sourceEnv, true); + const oauthConfigured = Boolean(authPath && readCodexOAuthAuthFile(authPath)); + const closeAbort = new AbortController(); + let activeAuthLock: CodexOAuthAuthLock | undefined; + let activeExpectedRefreshToken: string | undefined; + let activeExpectedAccessToken: string | undefined; + let activeExpectedSourceAuth: Record | undefined; let runtime: Runtime | null = null; - let starting: Promise | null = null; + let starting: StartingRuntime | null = null; let startingServer: CodexAppServer | null = null; + let setupUsers = 0; + let runtimeCleanupRequested = false; const processCollabItem = async (state: ActiveTurn, item: CodexItem): Promise => { if (item.type !== "collabAgentToolCall") return; @@ -421,156 +518,292 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { } }; - const ensureRuntime = async (): Promise => { + const ensureRuntime = async (registerCancel?: (release: () => void) => void): Promise => { if (runtime && runtime.server.process.exitCode === null) return runtime; - if (starting) return await starting; - starting = (async () => { - const jail = mkdtempSync(join(tmpdir(), "qm-codex-")); - const sourceEnv = opts.env ?? {}; - prepareCodexHome(sourceEnv, jail); - const binaryPath = opts.binaryPath ?? resolve("node_modules/.bin/codex"); - const server = new CodexAppServer({ - binaryPath, - cwd: jail, - env: codexChildEnv(sourceEnv, jail), - onNotification: async (method, params) => { - const p = (params ?? {}) as Record; - const threadId = typeof p.threadId === "string" ? p.threadId : ""; - const state = active.get(threadId); - if (!state) return; - if (method === "thread/tokenUsage/updated") { - const totals = codexUsageTotals(p); - if (totals) state.usageByThread.set(threadId, totals); - const usage = codexTokenUsageUpdate(p, state.usageInputTotals.get(threadId)); - if (!usage) return; - state.usageInputTotals.set(threadId, usage.totalInputTokens); - state.modelCalls++; - state.turn.recordModelCall({ - model: state.model, - inputTokens: usage.inputTokens, - entryCount: state.turn.history.length, - }); - } - if (method === "item/agentMessage/delta" && threadId === state.threadId && typeof p.delta === "string") { - state.firstOutputAt ??= Date.now(); - state.turn.onDelta?.(p.delta); + if (runtime) { + const stale = runtime; + runtime = null; + runtimeCleanupRequested = false; + rmSync(stale.jail, { recursive: true, force: true }); + } + let startup = starting; + if (startup?.abort.signal.aborted) { + await startup.promise.catch(() => undefined); + if (starting === startup) starting = null; + startup = starting; + } + if (!startup) { + const startupAbort = new AbortController(); + const promise = (async () => { + const jail = mkdtempSync(join(tmpdir(), "qm-codex-")); + const sourceAuth = authPath ? readCodexOAuthAuthFile(authPath) : null; + let expectedRefreshToken = codexOAuthRefreshToken(sourceAuth); + let expectedAccessToken = codexOAuthAccessToken(sourceAuth); + let expectedSourceAuth = sourceAuth ?? undefined; + let authLock: CodexOAuthAuthLock | undefined; + let server!: CodexAppServer; + try { + if (oauthConfigured && !sourceAuth) throw new Error("Codex OAuth auth.json is unavailable"); + if (oauthConfigured && authPath && sourceAuth) { + authLock = await acquireCodexOAuthAuthLock( + authPath, + AbortSignal.any([closeAbort.signal, startupAbort.signal]), + ); + const currentAuth = readCodexOAuthAuthFile(authPath); + expectedRefreshToken = codexOAuthRefreshToken(currentAuth); + expectedAccessToken = codexOAuthAccessToken(currentAuth); + expectedSourceAuth = currentAuth ?? undefined; + if (!currentAuth) throw new Error("Codex OAuth auth.json is unavailable"); + if (startupAbort.signal.aborted) throw new Error("Codex app-server startup cancelled"); } - if ((method === "item/started" || method === "item/completed") && p.item && typeof p.item === "object") { - const item = p.item as CodexItem; - if (method === "item/completed") { - state.completedItems.push(item); - if (state.turn.tape) { - try { - await state.turn.tape({ - kind: "message", - harness: "codex", - scopeLabel: state.turn.scopeLabel, - payload: item, - }); - } catch (error) { - state.tapeWriteFailed = true; - swallow("codex: tape append", error); + prepareCodexHome(sourceEnv, jail); + if (startupAbort.signal.aborted) throw new Error("Codex app-server startup cancelled"); + const binaryPath = opts.binaryPath ?? resolve("node_modules/.bin/codex"); + server = new CodexAppServer({ + binaryPath, + cwd: jail, + env: codexChildEnv(sourceEnv, jail), + onNotification: async (method, params) => { + const p = (params ?? {}) as Record; + const threadId = typeof p.threadId === "string" ? p.threadId : ""; + const state = active.get(threadId); + if (!state) return; + if (method === "thread/tokenUsage/updated") { + const totals = codexUsageTotals(p); + if (totals) state.usageByThread.set(threadId, totals); + const usage = codexTokenUsageUpdate(p, state.usageInputTotals.get(threadId)); + if (!usage) return; + state.usageInputTotals.set(threadId, usage.totalInputTokens); + state.modelCalls++; + state.turn.recordModelCall({ + model: state.model, + inputTokens: usage.inputTokens, + entryCount: state.turn.history.length, + }); + } + if (method === "item/agentMessage/delta" && threadId === state.threadId && typeof p.delta === "string") { + state.firstOutputAt ??= Date.now(); + state.turn.onDelta?.(p.delta); + } + if ((method === "item/started" || method === "item/completed") && p.item && typeof p.item === "object") { + const item = p.item as CodexItem; + if (method === "item/completed") { + state.completedItems.push(item); + if (state.turn.tape) { + try { + await state.turn.tape({ + kind: "message", + harness: "codex", + scopeLabel: state.turn.scopeLabel, + payload: item, + }); + } catch (error) { + state.tapeWriteFailed = true; + swallow("codex: tape append", error); + } + } } + await processCollabItem(state, item); } - } - await processCollabItem(state, item); - } - if (method === "turn/completed" && threadId === state.threadId) { - const completed = p.turn as CodexTurn | undefined; - if (completed) - state.resolve(completed.items?.length ? completed : { ...completed, items: state.completedItems }); - } - }, - onRequest: async (method, params) => { - if (method !== "item/tool/call") throw new Error(`unsupported Codex request ${method}`); - const p = (params ?? {}) as Record; - const threadId = String(p.threadId ?? ""); - const state = active.get(threadId); - if (!state) throw new Error("inactive Codex thread"); - const name = String(p.tool ?? ""); - const callId = String(p.callId ?? ""); - if (threadId !== state.threadId && !codexChildToolAllowed(name)) - throw new Error(`Codex child requested unavailable tool ${name}`); - const tool = state.tools.get(name); - if (!tool) throw new Error(`Codex requested unavailable tool ${name}`); - state.responseItems.push({ - type: "function_call", - call_id: callId, - name, - arguments: JSON.stringify(p.arguments ?? {}), - }); - try { - const result = await tool.execute(callId, p.arguments ?? {}); - const output = toolText(result); - state.responseItems.push({ type: "function_call_output", call_id: callId, output }); - if (result.terminate || state.turn.cancel?.aborted) - setImmediate(() => { - const requestingTurnId = String(p.turnId ?? ""); - if (threadId !== state.threadId && requestingTurnId) { - void server.request("turn/interrupt", { threadId, turnId: requestingTurnId }).catch(() => undefined); + if (method === "turn/completed" && threadId === state.threadId) { + const completed = p.turn as CodexTurn | undefined; + if (!isCodexTurn(completed)) { + state.reject(new CodexRpcError("Codex app-server sent an invalid turn/completed payload")); + return; } - void state.interrupt?.(); + state.resolve(completed.items?.length ? completed : { ...completed, items: state.completedItems }); + } + }, + onRequest: async (method, params) => { + if (method !== "item/tool/call") throw new Error(`unsupported Codex request ${method}`); + const p = (params ?? {}) as Record; + const threadId = String(p.threadId ?? ""); + const state = active.get(threadId); + if (!state) throw new Error("inactive Codex thread"); + const name = String(p.tool ?? ""); + const callId = String(p.callId ?? ""); + if (threadId !== state.threadId && !codexChildToolAllowed(name)) + throw new Error(`Codex child requested unavailable tool ${name}`); + const tool = state.tools.get(name); + if (!tool) throw new Error(`Codex requested unavailable tool ${name}`); + state.responseItems.push({ + type: "function_call", + call_id: callId, + name, + arguments: JSON.stringify(p.arguments ?? {}), }); - return { contentItems: [{ type: "inputText", text: output }], success: true }; + try { + const result = await tool.execute(callId, p.arguments ?? {}); + const output = toolText(result); + state.responseItems.push({ type: "function_call_output", call_id: callId, output }); + if (result.terminate || state.turn.cancel?.aborted) + setImmediate(() => { + const requestingTurnId = String(p.turnId ?? ""); + if (threadId !== state.threadId && requestingTurnId) { + void server + .request("turn/interrupt", { threadId, turnId: requestingTurnId }) + .catch(() => undefined); + } + void state.interrupt?.(); + }); + return { contentItems: [{ type: "inputText", text: output }], success: true }; + } catch (error) { + const output = error instanceof Error ? error.message : String(error); + state.responseItems.push({ type: "function_call_output", call_id: callId, output }); + return { contentItems: [{ type: "inputText", text: output }], success: false }; + } + }, + }); + startingServer = server; + if (startupAbort.signal.aborted) throw new Error("Codex app-server startup cancelled"); + } catch (error) { + await server?.close().catch(() => undefined); + await authLock?.release(); + rmSync(jail, { recursive: true, force: true }); + throw error; + } + const childAuthPath = join(jail, "codex-home", "auth.json"); + const persistAuth = ( + expectedRefresh = expectedRefreshToken, + expectedAccess = expectedAccessToken, + heldLockPath?: string, + expectedSource = expectedSourceAuth, + ) => { + try { + syncCodexOAuthAuthFile( + authPath, + childAuthPath, + heldLockPath, + expectedRefresh, + expectedAccess, + expectedSource, + ); } catch (error) { - const output = error instanceof Error ? error.message : String(error); - state.responseItems.push({ type: "function_call_output", call_id: callId, output }); - return { contentItems: [{ type: "inputText", text: output }], success: false }; + swallow("codex: oauth auth persistence", error); } + }; + let startTimer: NodeJS.Timeout | undefined; + try { + await Promise.race([ + server.initialize(), + new Promise((_, reject) => { + startTimer = setTimeout( + () => reject(new Error("Codex app-server initialization timed out")), + opts.appServerStartTimeoutMs ?? CODEX_START_TIMEOUT_MS, + ); + }), + ]); + if (authLock) persistAuth(expectedRefreshToken, expectedAccessToken, authLock.path, expectedSourceAuth); + await authLock?.release(); + authLock = undefined; + } catch (error) { + if (authLock) persistAuth(expectedRefreshToken, expectedAccessToken, authLock.path, expectedSourceAuth); + await server.close().catch(() => undefined); + await authLock?.release(); + rmSync(jail, { recursive: true, force: true }); + throw error; + } finally { + if (startTimer) clearTimeout(startTimer); + if (startingServer === server) startingServer = null; + } + runtime = { server, jail, persistAuth }; + runtimeCleanupRequested = false; + server.process.once("close", () => { + for (const [threadId, state] of active) { + if (state.server !== server) continue; + state.reject(server.error() ?? new Error("Codex app-server exited during a turn")); + active.delete(threadId); + } + if (runtime?.server !== server) { + rmSync(jail, { recursive: true, force: true }); + return; + } + runtime = null; + runtimeCleanupRequested = false; + const lock = activeAuthLock; + activeAuthLock = undefined; + if (lock) { + if (lock.isHeld()) + persistAuth(activeExpectedRefreshToken, activeExpectedAccessToken, lock.path, activeExpectedSourceAuth); + void lock.release().catch((error) => swallow("codex: oauth lock release", error)); + activeExpectedRefreshToken = undefined; + activeExpectedAccessToken = undefined; + activeExpectedSourceAuth = undefined; + } + rmSync(jail, { recursive: true, force: true }); + }); + return runtime; + })(); + startup = { promise, abort: startupAbort, waiters: 0 }; + starting = startup; + const current = startup; + void promise.then( + () => { + if (starting === current) starting = null; }, - }); - startingServer = server; - let startTimer: NodeJS.Timeout | undefined; - try { - await Promise.race([ - server.initialize(), - new Promise((_, reject) => { - startTimer = setTimeout( - () => reject(new Error("Codex app-server initialization timed out")), - opts.appServerStartTimeoutMs ?? CODEX_START_TIMEOUT_MS, - ); - }), - ]); - } catch (error) { - await server.close().catch(() => undefined); - rmSync(jail, { recursive: true, force: true }); - throw error; - } finally { - if (startTimer) clearTimeout(startTimer); - if (startingServer === server) startingServer = null; + () => { + if (starting === current) starting = null; + }, + ); + } + const current = startup; + current.waiters += 1; + let released = false; + const release = () => { + if (released) return; + released = true; + current.waiters -= 1; + if (current.waiters === 0 && starting === current) { + current.abort.abort(); + void startingServer?.close().catch(() => undefined); } - runtime = { server, jail }; - server.process.once("close", () => { - if (runtime?.server !== server) return; - for (const state of active.values()) - state.reject(server.error() ?? new Error("Codex app-server exited during a turn")); - active.clear(); - runtime = null; - rmSync(jail, { recursive: true, force: true }); - }); - return runtime; - })(); + }; + registerCancel?.(release); try { - return await starting; + return await current.promise; } finally { - starting = null; + release(); + } + }; + + const closeIdleRuntime = async (): Promise => { + const current = runtime; + if (!current) return; + if (active.size || setupUsers) { + runtimeCleanupRequested = true; + return; } + runtimeCleanupRequested = false; + if (runtime === current) runtime = null; + await current.server.close().catch(() => undefined); + rmSync(current.jail, { recursive: true, force: true }); }; const runPrompt = async (turn: HarnessTurnInput, toolsEnabled = true): Promise => { if (turn.cancel?.aborted) return { reply: "", stopped: true }; + setupUsers += 1; + let setupUserReleased = false; + const releaseSetupUser = () => { + if (setupUserReleased) return; + setupUserReleased = true; + setupUsers -= 1; + }; const wallMs = turn.turnWallClockMs ?? defaultTurnWallClockMs; const deadline = wallMs > 0 ? Date.now() + wallMs : 0; const setupCancelled = new Error("Codex setup cancelled"); const setupTimedOut = new NonRetryableTurnError(`Codex turn exceeded ${Math.round(wallMs / 1000)}s wall clock`); let rejectSetup!: (error: Error) => void; let setupSettled = false; + let releaseStartupWaiter: () => void = () => {}; + const authAcquireAbort = new AbortController(); const setupStop = new Promise((_, reject) => { rejectSetup = reject; }); const stopSetup = (error: Error) => { if (setupSettled) return; setupSettled = true; + releaseStartupWaiter(); + authAcquireAbort.abort(); rejectSetup(error); }; const onSetupCancel = () => stopSetup(setupCancelled); @@ -579,73 +812,174 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { const awaitSetup = (operation: Promise): Promise => Promise.race([operation, setupStop]); let rt: Runtime; try { - rt = await awaitSetup(ensureRuntime()); + rt = await awaitSetup( + ensureRuntime((release) => { + releaseStartupWaiter = release; + }), + ); } catch (error) { setupSettled = true; if (setupTimer) clearTimeout(setupTimer); turn.cancel?.removeEventListener("abort", onSetupCancel); - if (error === setupCancelled) return { reply: "", stopped: true }; + releaseSetupUser(); + await closeIdleRuntime(); + if (error === setupCancelled) { + return { reply: "", stopped: true }; + } throw error; } - const ref = codexToolContext(turn); - const toolAbort = new AbortController(); - ref.abortSignal = toolAbort.signal; - const tools = toolsEnabled ? asTools(ref, toolOptions(opts, turn)) : []; - const dynamicTools = tools.map((tool) => ({ - type: "function", - name: tool.name, - description: tool.description, - inputSchema: tool.parameters, - })); - const model = modelSupportedByHarness(turn.model, "codex") ? turn.model! : resolveModelId(turn.scopeLabel); - const threadStartRequest = { - ...(model ? { model } : {}), - cwd: rt.jail, - approvalPolicy: "never", - sandbox: "read-only", - ephemeral: true, - baseInstructions: turn.systemPrompt, - developerInstructions: - "Use the supplied dynamic QM tools for all workspace, execution, memory, history, and surface operations. The built-in working directory is an empty read-only control jail, not the user's workspace.", - dynamicTools, - experimentalRawEvents: true, - environments: [], - config: { - web_search: "disabled", - ...(codexReasoningEffort(turn.thinkingLevel) - ? { model_reasoning_effort: codexReasoningEffort(turn.thinkingLevel) } - : {}), - features: { - shell_tool: false, - unified_exec: false, - shell_snapshot: false, - apps: false, - plugins: false, - browser_use: false, - browser_use_external: false, - computer_use: false, - image_generation: false, - in_app_browser: false, - multi_agent: !turn.readOnly, - request_permissions_tool: false, - tool_suggest: false, - }, - }, + let turnAuthLock: CodexOAuthAuthLock | undefined; + let expectedRefreshToken: string | undefined; + let expectedAccessToken: string | undefined; + let expectedSourceAuth: Record | undefined; + let turnAuthReleased = false; + const releaseTurnAuth = async (): Promise => { + if (turnAuthReleased) return; + turnAuthReleased = true; + const lock = turnAuthLock; + if (!lock) return; + try { + if (lock.isHeld()) rt.persistAuth(expectedRefreshToken, expectedAccessToken, lock.path, expectedSourceAuth); + } finally { + if (activeAuthLock === lock) { + activeAuthLock = undefined; + activeExpectedRefreshToken = undefined; + activeExpectedAccessToken = undefined; + activeExpectedSourceAuth = undefined; + } + await lock.release(); + } }; - let started: { thread: { id: string }; model?: string }; try { - started = await awaitSetup(rt.server.request("thread/start", threadStartRequest)); + const sourceAuth = authPath ? readCodexOAuthAuthFile(authPath) : null; + if (oauthConfigured && !sourceAuth) { + rmSync(join(rt.jail, "codex-home", "auth.json"), { force: true }); + throw new NonRetryableTurnError("Codex OAuth auth.json is unavailable"); + } + if (oauthConfigured && authPath && sourceAuth) { + const authLockPromise = acquireCodexOAuthAuthLock( + authPath, + AbortSignal.any([closeAbort.signal, authAcquireAbort.signal]), + Math.max(120_000, wallMs + 5_000), + ); + try { + turnAuthLock = await awaitSetup(authLockPromise); + } catch (error) { + void authLockPromise.then( + (lock) => lock.release(), + () => undefined, + ); + throw error; + } + const currentAuth = readCodexOAuthAuthFile(authPath); + if (!currentAuth) { + rmSync(join(rt.jail, "codex-home", "auth.json"), { force: true }); + throw new NonRetryableTurnError("Codex OAuth auth.json is unavailable"); + } + expectedRefreshToken = codexOAuthRefreshToken(currentAuth); + expectedAccessToken = codexOAuthAccessToken(currentAuth); + expectedSourceAuth = currentAuth ?? undefined; + prepareCodexHome(sourceEnv, rt.jail); + activeAuthLock = turnAuthLock; + activeExpectedRefreshToken = expectedRefreshToken; + activeExpectedAccessToken = expectedAccessToken; + activeExpectedSourceAuth = expectedSourceAuth; + } } catch (error) { + await releaseTurnAuth(); setupSettled = true; if (setupTimer) clearTimeout(setupTimer); turn.cancel?.removeEventListener("abort", onSetupCancel); + releaseSetupUser(); + await closeIdleRuntime(); + if (error === setupCancelled) { + return { reply: "", stopped: true }; + } + throw error; + } + let ref!: ToolContextRef; + let toolAbort!: AbortController; + let tools!: BridgedTool[]; + let dynamicTools!: Array>; + let model: string | undefined; + let threadStartRequest!: Record; + try { + ref = codexToolContext(turn); + toolAbort = new AbortController(); + ref.abortSignal = toolAbort.signal; + tools = toolsEnabled ? asTools(ref, toolOptions(opts, turn)) : []; + dynamicTools = tools.map((tool) => ({ + type: "function", + name: tool.name, + description: tool.description, + inputSchema: tool.parameters, + })); + model = modelSupportedByHarness(turn.model, "codex") ? turn.model! : resolveModelId(turn.scopeLabel); + threadStartRequest = { + ...(model ? { model } : {}), + cwd: rt.jail, + approvalPolicy: "never", + sandbox: "read-only", + ephemeral: true, + baseInstructions: turn.systemPrompt, + developerInstructions: + "Use the supplied dynamic QM tools for all workspace, execution, memory, history, and surface operations. The built-in working directory is an empty read-only control jail, not the user's workspace.", + dynamicTools, + experimentalRawEvents: true, + environments: [], + config: { + web_search: "disabled", + ...(codexReasoningEffort(turn.thinkingLevel) + ? { model_reasoning_effort: codexReasoningEffort(turn.thinkingLevel) } + : {}), + features: { + shell_tool: false, + unified_exec: false, + shell_snapshot: false, + apps: false, + plugins: false, + browser_use: false, + browser_use_external: false, + computer_use: false, + image_generation: false, + in_app_browser: false, + multi_agent: !turn.readOnly, + request_permissions_tool: false, + tool_suggest: false, + }, + }, + }; + } catch (error) { + await releaseTurnAuth(); + setupSettled = true; + if (setupTimer) clearTimeout(setupTimer); + turn.cancel?.removeEventListener("abort", onSetupCancel); + releaseSetupUser(); + await closeIdleRuntime(); if (error === setupCancelled) return { reply: "", stopped: true }; throw error; } - const threadId = started.thread.id; - const replay = replayItems(reconstructMessagesFromHistory(turn.history)); - let userEntry: SessionEntry; + let started: { thread: { id: string }; model?: string }; + try { + started = await awaitSetup(rt.server.request("thread/start", threadStartRequest, isCodexThreadStart)); + } catch (error) { + await releaseTurnAuth(); + setupSettled = true; + if (setupTimer) clearTimeout(setupTimer); + turn.cancel?.removeEventListener("abort", onSetupCancel); + releaseSetupUser(); + await closeIdleRuntime(); + if (error === setupCancelled) { + return { reply: "", stopped: true }; + } + throw error; + } + let threadId!: string; + let replay!: ReturnType; + let userEntry!: SessionEntry; try { + threadId = started.thread.id; + replay = replayItems(reconstructMessagesFromHistory(turn.history)); if (replay.length) await awaitSetup(rt.server.request("thread/inject_items", { threadId, items: replay })); userEntry = await awaitSetup( turn.emit({ @@ -659,51 +993,75 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { }), ); } catch (error) { + await releaseTurnAuth(); setupSettled = true; if (setupTimer) clearTimeout(setupTimer); turn.cancel?.removeEventListener("abort", onSetupCancel); + releaseSetupUser(); + await closeIdleRuntime(); + if (error === setupCancelled) { + return { reply: "", stopped: true }; + } + throw error; + } + let resolveCompleted!: (value: CodexTurn) => void; + let rejectCompleted!: (error: Error) => void; + let completed!: Promise; + let inputText!: string; + let input!: Array>; + let selectedModel!: string; + let state!: ActiveTurn; + try { + completed = new Promise((resolveTurn, rejectTurn) => { + resolveCompleted = resolveTurn; + rejectCompleted = rejectTurn; + }); + void completed.catch(() => undefined); + inputText = codexTurnInputText(turn); + input = [ + userInput(inputText), + ...(turn.images ?? []).map((image) => ({ + type: "image", + url: `data:${image.mimeType};base64,${image.dataBase64}`, + })), + ]; + selectedModel = model ?? started.model ?? "codex-default"; + state = { + server: rt.server, + threadId, + turn, + tools: new Map(tools.map((tool) => [tool.name, tool])), + resolve: resolveCompleted, + reject: rejectCompleted, + responseItems: [], + completedItems: [], + taskIds: new Map(), + taskStatuses: new Map(), + taskResults: new Set(), + model: selectedModel, + modelCalls: 0, + usageInputTotals: new Map(), + usageByThread: new Map(), + firstOutputAt: null, + fallbackInputTokens: countTokens(JSON.stringify({ replay, input })), + tapeWriteFailed: false, + stopped: false, + }; + } catch (error) { + await releaseTurnAuth(); + setupSettled = true; + if (setupTimer) clearTimeout(setupTimer); + turn.cancel?.removeEventListener("abort", onSetupCancel); + releaseSetupUser(); + await closeIdleRuntime(); if (error === setupCancelled) return { reply: "", stopped: true }; throw error; } + active.set(threadId, state); setupSettled = true; if (setupTimer) clearTimeout(setupTimer); turn.cancel?.removeEventListener("abort", onSetupCancel); - let resolveCompleted!: (value: CodexTurn) => void; - let rejectCompleted!: (error: Error) => void; - const completed = new Promise((resolveTurn, rejectTurn) => { - resolveCompleted = resolveTurn; - rejectCompleted = rejectTurn; - }); - const inputText = codexTurnInputText(turn); - const input = [ - userInput(inputText), - ...(turn.images ?? []).map((image) => ({ - type: "image", - url: `data:${image.mimeType};base64,${image.dataBase64}`, - })), - ]; - const selectedModel = model ?? started.model ?? "codex-default"; - const state: ActiveTurn = { - threadId, - turn, - tools: new Map(tools.map((tool) => [tool.name, tool])), - resolve: resolveCompleted, - reject: rejectCompleted, - responseItems: [], - completedItems: [], - taskIds: new Map(), - taskStatuses: new Map(), - taskResults: new Set(), - model: selectedModel, - modelCalls: 0, - usageInputTotals: new Map(), - usageByThread: new Map(), - firstOutputAt: null, - fallbackInputTokens: countTokens(JSON.stringify({ replay, input })), - tapeWriteFailed: false, - stopped: false, - }; - active.set(threadId, state); + releaseSetupUser(); const requestPayload = { threadStart: { ...threadStartRequest, @@ -796,7 +1154,7 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { let timer: NodeJS.Timeout | undefined; try { const response = await rt.server - .request<{ turn: CodexTurn }>("turn/start", { threadId, input, ...(model ? { model } : {}) }) + .request<{ turn: CodexTurn }>("turn/start", { threadId, input, ...(model ? { model } : {}) }, isCodexTurnStart) .catch((error: unknown) => { throw error instanceof CodexRpcError ? codexProviderFailure(error.message) : error; }); @@ -809,6 +1167,7 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { completed, new Promise((_, reject) => { timer = setTimeout(() => { + runtimeCleanupRequested = true; void interrupt(false); reject(setupTimedOut); }, remainingWallMs); @@ -847,6 +1206,7 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { if (timer) clearTimeout(timer); await stopSignals?.(); await recordRequest(); + await releaseTurnAuth(); turn.cancel?.removeEventListener("abort", onCancel); for (const [taskId, status] of state.taskStatuses) { if (status === "pending" || status === "in_progress") { @@ -856,6 +1216,7 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { for (const [activeThreadId, activeState] of active) { if (activeState === state) active.delete(activeThreadId); } + if (runtimeCleanupRequested) await closeIdleRuntime(); } }; @@ -910,13 +1271,29 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { { runTurn: runPrompt, close: async () => { + closeAbort.abort(); await startingServer?.close().catch(() => undefined); - await starting?.catch(() => undefined); + await starting?.promise.catch(() => undefined); const current = runtime; if (current) { for (const state of active.values()) state.reject(new Error("Codex harness closed during a turn")); active.clear(); await current.server.close(); + const lock = activeAuthLock; + activeAuthLock = undefined; + if (lock) { + if (lock.isHeld()) + current.persistAuth( + activeExpectedRefreshToken, + activeExpectedAccessToken, + lock.path, + activeExpectedSourceAuth, + ); + await lock.release(); + activeExpectedRefreshToken = undefined; + activeExpectedAccessToken = undefined; + activeExpectedSourceAuth = undefined; + } rmSync(current.jail, { recursive: true, force: true }); if (runtime === current) runtime = null; } diff --git a/src/model/pi-models.ts b/src/model/pi-models.ts index a57462ef8..128ae60dd 100644 --- a/src/model/pi-models.ts +++ b/src/model/pi-models.ts @@ -193,6 +193,11 @@ export interface ModelProviderAvailability { anthropic: boolean; openai: boolean; openrouter: boolean; + codexOAuth?: boolean; +} + +function providerFlags(value: ModelProviderAvailability): ModelProviderAvailability { + return { anthropic: value.anthropic, openai: value.openai, openrouter: value.openrouter }; } export function modelServiceable(id: string, providers: ModelProviderAvailability): boolean { @@ -215,9 +220,10 @@ export function modelProviderAvailabilityFor( configKeys: ModelProviderAvailability, managedKeys: ModelProviderAvailability = configKeys, ): ModelProviderAvailability { - if (harness === "pi") return managedKeys; - if (harness === "opencode") return { ...configKeys, openrouter: false }; - if (harness === "codex") return configKeys; + if (harness === "pi") return providerFlags(managedKeys); + if (harness === "opencode") return { ...providerFlags(configKeys), openrouter: false }; + if (harness === "codex") + return { ...providerFlags(configKeys), openai: configKeys.openai || Boolean(configKeys.codexOAuth) }; return ALL_PROVIDERS_AVAILABLE; } diff --git a/test/base-model-serviceability.test.ts b/test/base-model-serviceability.test.ts index 8b7c766e6..386d9350a 100644 --- a/test/base-model-serviceability.test.ts +++ b/test/base-model-serviceability.test.ts @@ -9,7 +9,7 @@ import type { AddressInfo } from "node:net"; import { createInsecureTestServer } from "../src/api/server.ts"; import { buildApp } from "../src/wiring.ts"; import { baseModelProviders, configuredModelForHarness, providerKeysPresent } from "../src/config.ts"; -import { defaultModelForHarness } from "../src/model/pi-models.ts"; +import { defaultModelForHarness, modelProviderAvailabilityFor } from "../src/model/pi-models.ts"; import { testConfig } from "./support/test-config.ts"; const ADMIN = { "content-type": "application/json", "x-admin-actor": "admin-alice@default-org" }; @@ -60,6 +60,23 @@ test("base-model set rejects a model whose provider key is absent (would fail pr } }); +test("ChatGPT OAuth is serviceable for Codex without advertising OpenAI to Pi", () => { + const config = testConfig({ harness: "codex", codexAuthFile: "/tmp/codex-auth.json" }); + const configured = providerKeysPresent(config); + assert.equal(configured.openai, false); + assert.equal( + modelProviderAvailabilityFor("codex", configured).openai, + true, + "Codex can use its harness OAuth session", + ); + assert.equal(modelProviderAvailabilityFor("opencode", configured).openai, false); + assert.equal( + modelProviderAvailabilityFor("pi", configured, { anthropic: false, openai: false, openrouter: false }).openai, + false, + "Pi still requires an API-key credential", + ); +}); + test("a deployment that declares a provider runs that provider's base model", async () => { for (const [modelProvider, key, expected] of [ ["anthropic", "anthropicApiKey", "claude-opus-5"], diff --git a/test/codex-harness.test.ts b/test/codex-harness.test.ts index 168d858c6..6a74775e4 100644 --- a/test/codex-harness.test.ts +++ b/test/codex-harness.test.ts @@ -1,7 +1,17 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { homedir, tmpdir } from "node:os"; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + unlinkSync, + utimesSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { createRequire } from "node:module"; import { @@ -24,8 +34,10 @@ import type { HarnessLlmRequestRecord, HarnessTurnInput } from "../src/harness/h import { NonRetryableTurnError } from "../src/core/turn-error.ts"; import type { ScopeId, Session, SessionEntry } from "../src/types.ts"; import { createMemoryTaskStore } from "../src/tasks/memory-task-store.ts"; -import { CodexAppServer } from "../src/harness/codex-app-server.ts"; +import { CodexAppServer, redactCodexDiagnostics } from "../src/harness/codex-app-server.ts"; import { DEFAULT_CODEX_MODEL_ID } from "../src/model/pi-models.ts"; +import { readCodexOAuthAuthFile, syncCodexOAuthAuthFile } from "../src/harness/codex-auth.ts"; +import { acquireCodexOAuthAuthLock } from "../src/harness/codex-auth.ts"; const replaySmokeItems = [ { type: "message", role: "user", content: [{ type: "input_text", text: "earlier question" }] }, @@ -34,6 +46,17 @@ const replaySmokeItems = [ { type: "function_call_output", call_id: "call-1", output: "[exit 0]" }, ]; +function testHarnessEnv(home: string): NodeJS.ProcessEnv { + return { ...process.env, HOME: home, CODEX_HOME: join(home, "codex-home") }; +} + +function oauthIdToken(accountId: string): string { + const payload = Buffer.from( + JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: accountId } }), + ).toString("base64url"); + return `header.${payload}.signature`; +} + test("Codex replay keeps paired tool ids within the provider's 64-character limit", () => { const longId = "tool-call-".repeat(9); const normalized = codexReplayCallId(longId); @@ -165,6 +188,202 @@ process.stdin.resume(); return path; } +function startupCancellationCodexBinary(dir: string): string { + const path = join(dir, "startup-cancellation-codex"); + writeFileSync( + path, + `#!${process.execPath} +const fs = require("node:fs"); +fs.appendFileSync(${JSON.stringify(join(dir, "starts"))}, "start\\n"); +process.on("SIGTERM", () => { + fs.writeFileSync(${JSON.stringify(join(dir, "closed"))}, "closed"); + process.exit(0); +}); +process.stdin.resume(); +`, + ); + chmodSync(path, 0o755); + return path; +} + +function refreshThenNonresponsiveCodexBinary(dir: string): string { + const path = join(dir, "refresh-then-nonresponsive-codex"); + writeFileSync( + path, + `#!${process.execPath} +const fs = require("node:fs"); +const path = require("node:path"); +const authPath = path.join(process.env.CODEX_HOME, "auth.json"); +const auth = JSON.parse(fs.readFileSync(authPath, "utf8")); +auth.tokens.access_token = "startup-access-after"; +auth.tokens.refresh_token = "startup-refresh-after"; +fs.writeFileSync(authPath, JSON.stringify(auth)); +process.stdin.resume(); +`, + ); + chmodSync(path, 0o755); + return path; +} + +function malformedCodexBinary(dir: string): string { + const path = join(dir, "malformed-codex"); + writeFileSync( + path, + `#!${process.execPath} +process.stdout.write('{"access_token":"oauth-secret-123456789"\\n'); +process.stdin.resume(); +`, + ); + chmodSync(path, 0o755); + return path; +} + +function incompleteResponseCodexBinary(dir: string): string { + const path = join(dir, "incomplete-response-codex"); + writeFileSync( + path, + `#!${process.execPath} +process.stdout.write('{"id":1}\\n'); +process.stdin.resume(); +`, + ); + chmodSync(path, 0o755); + return path; +} + +function arrayMessageCodexBinary(dir: string): string { + const path = join(dir, "array-message-codex"); + writeFileSync( + path, + `#!${process.execPath} +process.stdout.write('[]\\n'); +process.stdin.resume(); +`, + ); + chmodSync(path, 0o755); + return path; +} + +function invalidJsonRpcBinary(dir: string): string { + const path = join(dir, "invalid-json-rpc-codex"); + writeFileSync( + path, + `#!${process.execPath} +process.stdout.write('{"id":true,"result":{}}\\n'); +process.stdin.resume(); +`, + ); + chmodSync(path, 0o755); + return path; +} + +function noIdResponseCodexBinary(dir: string): string { + const path = join(dir, "no-id-response-codex"); + writeFileSync( + path, + `#!${process.execPath} +process.stdout.write('{"result":{}}\\n'); +process.stdin.resume(); +`, + ); + chmodSync(path, 0o755); + return path; +} + +function unknownResponseCodexBinary(dir: string): string { + const path = join(dir, "unknown-response-codex"); + writeFileSync( + path, + `#!${process.execPath} +process.stdout.write('{"id":999,"result":{}}\\n'); +process.stdin.resume(); +`, + ); + chmodSync(path, 0o755); + return path; +} + +function malformedTurnCompletedCodexBinary(dir: string): string { + const path = join(dir, "malformed-turn-completed-codex"); + writeFileSync( + path, + `#!${process.execPath} +const readline = require("node:readline"); +const rl = readline.createInterface({ input: process.stdin }); +const send = value => process.stdout.write(JSON.stringify(value) + "\\n"); +rl.on("line", line => { + const msg = JSON.parse(line); + if (msg.method === "initialize") return send({ id: msg.id, result: {} }); + if (msg.method === "initialized") return; + if (msg.method === "thread/start") return send({ id: msg.id, result: { thread: { id: "malformed-thread" } } }); + if (msg.method === "turn/start") { + send({ id: msg.id, result: { turn: { id: "malformed-turn", status: "inProgress", items: [] } } }); + return send({ method: "turn/completed", params: { threadId: "malformed-thread", turn: {} } }); + } +}); +`, + ); + chmodSync(path, 0o755); + return path; +} + +function oauthTurnBinary(dir: string, token: string, delayMs: number): string { + const path = join(dir, `oauth-${token}`); + const events = join(dir, "oauth-events"); + writeFileSync( + path, + `#!${process.execPath} +const fs = require("node:fs"); +const path = require("node:path"); +const readline = require("node:readline"); +const authPath = path.join(process.env.CODEX_HOME, "auth.json"); +const send = (value) => process.stdout.write(JSON.stringify(value) + "\\n"); +const rl = readline.createInterface({ input: process.stdin }); +rl.on("line", (line) => { + const msg = JSON.parse(line); + if (msg.method === "initialize") return send({ id: msg.id, result: {} }); + if (msg.method === "initialized") return; + if (msg.method === "thread/start") return send({ id: msg.id, result: { thread: { id: "thread-${token}" } } }); + if (msg.method === "turn/start") { + const auth = JSON.parse(fs.readFileSync(authPath, "utf8")); + auth.tokens.access_token = ${JSON.stringify(token)}; + fs.writeFileSync(authPath, JSON.stringify(auth)); + fs.appendFileSync(${JSON.stringify(events)}, ${JSON.stringify(`${token}\n`)}); + send({ id: msg.id, result: { turn: { id: "turn-${token}", status: "inProgress", items: [] } } }); + return setTimeout(() => send({ method: "turn/completed", params: { threadId: "thread-${token}", turn: { id: "turn-${token}", status: "completed", items: [{ type: "agentMessage", text: ${JSON.stringify(token)}, phase: "final_answer" }] } } }), ${delayMs}); + } + if (msg.method === "turn/interrupt") return send({ id: msg.id, result: {} }); +}); +`, + ); + chmodSync(path, 0o755); + return path; +} + +function exitingCodexBinary(dir: string): string { + const path = join(dir, "exiting-codex"); + writeFileSync( + path, + `#!${process.execPath} +const readline = require("node:readline"); +const rl = readline.createInterface({ input: process.stdin }); +const send = (value) => process.stdout.write(JSON.stringify(value) + "\\n"); +rl.on("line", (line) => { + const msg = JSON.parse(line); + if (msg.method === "initialize") return send({ id: msg.id, result: {} }); + if (msg.method === "initialized") return; + if (msg.method === "thread/start") return send({ id: msg.id, result: { thread: { id: "thread-exit" } } }); + if (msg.method === "turn/start") { + send({ id: msg.id, result: { turn: { id: "turn-exit", status: "inProgress", items: [] } } }); + setTimeout(() => process.exit(17), 50); + } +}); +`, + ); + chmodSync(path, 0o755); + return path; +} + test("Codex forwards external-content screening into its native tool bridge", () => { const screenExternalContent: NonNullable = async () => ({ decision: "auto", @@ -176,7 +395,7 @@ test("Codex forwards external-content screening into its native tool bridge", () test("Codex harness drives app-server JSON-RPC with a read-only jail", async (t) => { const dir = mkdtempSync(join(tmpdir(), "qm-codex-test-")); const tasks = createMemoryTaskStore(); - const harness = createCodexHarness({ binaryPath: fakeCodexBinary(dir), env: process.env, tasks }); + const harness = createCodexHarness({ binaryPath: fakeCodexBinary(dir), env: testHarnessEnv(dir), tasks }); t.after(async () => { await harness.turns.close?.(); rmSync(dir, { recursive: true, force: true }); @@ -293,7 +512,7 @@ test("Codex child environment excludes core credentials and user homes", () => { test("Codex materializes API-key auth into its isolated home, and never an ambient login", (t) => { const jail = mkdtempSync(join(tmpdir(), "qm-codex-auth-test-")); t.after(() => rmSync(jail, { recursive: true, force: true })); - const home = prepareCodexHome({ OPENAI_API_KEY: "sk-test" }, jail); + const home = prepareCodexHome({ CODEX_HOME: join(jail, "empty-source"), OPENAI_API_KEY: "sk-test" }, jail); assert.deepEqual(JSON.parse(readFileSync(join(home, "auth.json"), "utf8")), { auth_mode: "apikey", OPENAI_API_KEY: "sk-test", @@ -301,7 +520,403 @@ test("Codex materializes API-key auth into its isolated home, and never an ambie const bare = mkdtempSync(join(tmpdir(), "qm-codex-auth-bare-")); t.after(() => rmSync(bare, { recursive: true, force: true })); - assert.equal(existsSync(join(prepareCodexHome({ HOME: homedir() }, bare), "auth.json")), false); + assert.equal( + existsSync(join(prepareCodexHome({ CODEX_HOME: join(bare, "empty-source") }, bare), "auth.json")), + false, + ); +}); + +test("Codex materializes ChatGPT OAuth auth without an API-key override and persists refreshes", async (t) => { + const source = mkdtempSync(join(tmpdir(), "qm-codex-oauth-source-")); + const jail = mkdtempSync(join(tmpdir(), "qm-codex-oauth-jail-")); + t.after(() => { + rmSync(source, { recursive: true, force: true }); + rmSync(jail, { recursive: true, force: true }); + }); + const authFile = join(source, "auth.json"); + writeFileSync( + authFile, + JSON.stringify({ + auth_mode: "chatgpt", + OPENAI_API_KEY: "ambient-api-key", + tokens: { + access_token: "access-before", + refresh_token: "refresh-before", + account_id: "account-before", + id_token: oauthIdToken("account-before"), + }, + }), + ); + chmodSync(authFile, 0o600); + const sourceEnv = { + CODEX_AUTH_FILE: authFile, + OPENAI_API_KEY: "ambient-api-key", + OPENAI_BASE_URL: "https://untrusted.example/v1", + CODEX_ACCESS_TOKEN: "ambient-codex-token", + }; + assert.deepEqual(codexChildEnv(sourceEnv, jail), { + HOME: jail, + CODEX_HOME: join(jail, "codex-home"), + }); + const home = prepareCodexHome(sourceEnv, jail); + const childAuthFile = join(home, "auth.json"); + const childAuth = JSON.parse(readFileSync(childAuthFile, "utf8")) as Record; + assert.equal(childAuth.OPENAI_API_KEY, undefined); + assert.equal((childAuth.tokens as Record).access_token, "access-before"); + assert.equal((childAuth.tokens as Record).account_id, "account-before"); + writeFileSync( + childAuthFile, + JSON.stringify({ + ...childAuth, + tokens: { + access_token: "access-after", + refresh_token: "refresh-after", + account_id: "account-before", + id_token: oauthIdToken("account-before"), + }, + }), + ); + const lock = await acquireCodexOAuthAuthLock(authFile); + try { + syncCodexOAuthAuthFile(authFile, childAuthFile, lock.path); + const persisted = JSON.parse(readFileSync(authFile, "utf8")) as Record; + assert.equal(persisted.OPENAI_API_KEY, "ambient-api-key"); + assert.equal((persisted.tokens as Record).access_token, "access-after"); + } finally { + await lock.release(); + } + writeFileSync( + authFile, + JSON.stringify({ + auth_mode: "chatgpt", + tokens: { + access_token: "access-latest", + refresh_token: "refresh-latest", + account_id: "account-before", + id_token: oauthIdToken("account-before"), + }, + }), + ); + writeFileSync( + childAuthFile, + JSON.stringify({ + auth_mode: "chatgpt", + tokens: { + access_token: "stale-access", + refresh_token: "refresh-after", + account_id: "account-before", + id_token: oauthIdToken("account-before"), + }, + }), + ); + syncCodexOAuthAuthFile(authFile, childAuthFile, undefined, "refresh-before"); + const latest = JSON.parse(readFileSync(authFile, "utf8")) as Record; + assert.equal((latest.tokens as Record).access_token, "access-latest"); + writeFileSync( + authFile, + JSON.stringify({ + auth_mode: "chatgpt", + tokens: { + access_token: "access-newest", + refresh_token: "refresh-stable", + account_id: "account-before", + id_token: oauthIdToken("account-before"), + }, + }), + ); + writeFileSync( + childAuthFile, + JSON.stringify({ + auth_mode: "chatgpt", + tokens: { + access_token: "stale-access", + refresh_token: "refresh-stable", + account_id: "account-before", + id_token: oauthIdToken("account-before"), + }, + }), + ); + syncCodexOAuthAuthFile(authFile, childAuthFile, undefined, "refresh-stable", "access-before"); + const stable = JSON.parse(readFileSync(authFile, "utf8")) as Record; + assert.equal((stable.tokens as Record).access_token, "access-newest"); + const liveLock = `${authFile}.lock`; + writeFileSync(liveLock, String(process.pid)); + utimesSync(liveLock, new Date(0), new Date(0)); + try { + assert.throws(() => syncCodexOAuthAuthFile(authFile, childAuthFile), /timed out acquiring/); + } finally { + unlinkSync(liveLock); + } + const liveLockSafe = JSON.parse(readFileSync(authFile, "utf8")) as Record; + assert.equal((liveLockSafe.tokens as Record).access_token, "access-newest"); + + const defaultSource = mkdtempSync(join(tmpdir(), "qm-codex-oauth-default-source-")); + const defaultJail = mkdtempSync(join(tmpdir(), "qm-codex-oauth-default-jail-")); + t.after(() => { + rmSync(defaultSource, { recursive: true, force: true }); + rmSync(defaultJail, { recursive: true, force: true }); + }); + mkdirSync(join(defaultSource, ".codex"), { recursive: true }); + writeFileSync( + join(defaultSource, ".codex", "auth.json"), + JSON.stringify({ + auth_mode: "chatgpt", + tokens: { + access_token: "default-access", + refresh_token: "default-refresh", + account_id: "default-account", + id_token: oauthIdToken("default-account"), + }, + }), + ); + chmodSync(join(defaultSource, ".codex", "auth.json"), 0o600); + const defaultEnv = { HOME: defaultSource, OPENAI_API_KEY: "ambient-default-api-key" }; + assert.equal(codexChildEnv(defaultEnv, defaultJail).OPENAI_API_KEY, undefined); + assert.equal(existsSync(join(prepareCodexHome(defaultEnv, defaultJail), "auth.json")), true); +}); + +test("Codex diagnostics redact credential-shaped stderr", () => { + assert.equal( + redactCodexDiagnostics( + '{"access_token":"access-secret","refresh_token":"refresh-secret"} Bearer bearer-secret-123456789 sk-secret-value', + ), + '{"access_token":"[redacted]","refresh_token":"[redacted]"} Bearer [redacted] [redacted]', + ); + const diagnostics = redactCodexDiagnostics( + "Authorization: Basic basic-secret-123456 Cookie: session-cookie-secret; Set-Cookie: refresh-cookie-secret; X-Api-Key: api-secret-123456 accessToken=camel-secret-123456 token=generic-secret-123456", + ); + for (const secret of [ + "basic-secret-123456", + "session-cookie-secret", + "refresh-cookie-secret", + "api-secret-123456", + "camel-secret-123456", + "generic-secret-123456", + ]) + assert.equal(diagnostics.includes(secret), false, secret); + const structured = redactCodexDiagnostics('authorization=["Bearer array-secret"] access_token="unterminated-secret'); + assert.equal(structured.includes("array-secret"), false); + assert.equal(structured.includes("unterminated-secret"), false); + const arrayDiagnostics = redactCodexDiagnostics('access_token=["first-array-secret","second-array-secret"]'); + assert.equal(arrayDiagnostics.includes("first-array-secret"), false); + assert.equal(arrayDiagnostics.includes("second-array-secret"), false); + const malformedArray = redactCodexDiagnostics('access_token=["first-array-secret",\n"second-array-secret"'); + assert.equal(malformedArray.includes("first-array-secret"), false); + assert.equal(malformedArray.includes("second-array-secret"), false); + const malformedObject = redactCodexDiagnostics('access_token={"a":"first-object-secret","b":"second-object-secret"}'); + assert.equal(malformedObject.includes("first-object-secret"), false); + assert.equal(malformedObject.includes("second-object-secret"), false); + const nested = redactCodexDiagnostics( + JSON.stringify({ + nested: { authorization: { header: "Bearer nested-secret" } }, + tokens: { access_token: ["one-secret"] }, + }), + ); + assert.equal(nested.includes("nested-secret"), false); + assert.equal(nested.includes("one-secret"), false); + assert.equal(redactCodexDiagnostics("id_token=header.payload.signature").includes("header.payload.signature"), false); +}); + +test("Codex ignores OAuth auth files that are readable by other users", (t) => { + const dir = mkdtempSync(join(tmpdir(), "qm-codex-oauth-mode-test-")); + const authFile = join(dir, "auth.json"); + t.after(() => rmSync(dir, { recursive: true, force: true })); + writeFileSync( + authFile, + JSON.stringify({ + auth_mode: "chatgpt", + tokens: { + access_token: "mode-access", + refresh_token: "mode-refresh", + account_id: "mode-account", + id_token: oauthIdToken("mode-account"), + }, + }), + { mode: 0o600 }, + ); + assert.deepEqual(readCodexOAuthAuthFile(authFile), { + auth_mode: "chatgpt", + tokens: { + access_token: "mode-access", + refresh_token: "mode-refresh", + account_id: "mode-account", + id_token: oauthIdToken("mode-account"), + }, + }); + chmodSync(authFile, 0o644); + assert.equal(readCodexOAuthAuthFile(authFile), null); +}); + +test("Codex rejects OAuth auth files without a trusted account claim", (t) => { + const dir = mkdtempSync(join(tmpdir(), "qm-codex-oauth-optional-account-test-")); + const authFile = join(dir, "auth.json"); + t.after(() => rmSync(dir, { recursive: true, force: true })); + writeFileSync( + authFile, + JSON.stringify({ + auth_mode: "chatgpt", + tokens: { access_token: "optional-access", refresh_token: "optional-refresh" }, + }), + { mode: 0o600 }, + ); + assert.equal(readCodexOAuthAuthFile(authFile), null); +}); + +test("Codex does not persist OAuth refreshes without a trusted account claim", (t) => { + const dir = mkdtempSync(join(tmpdir(), "qm-codex-oauth-unbound-refresh-test-")); + const source = join(dir, "source.json"); + const child = join(dir, "child.json"); + t.after(() => rmSync(dir, { recursive: true, force: true })); + writeFileSync( + source, + JSON.stringify({ + auth_mode: "chatgpt", + tokens: { access_token: "source-access", refresh_token: "source-refresh", account_id: "same" }, + }), + { mode: 0o600 }, + ); + writeFileSync( + child, + JSON.stringify({ + auth_mode: "chatgpt", + tokens: { access_token: "child-access", refresh_token: "child-refresh", account_id: "same" }, + }), + { mode: 0o600 }, + ); + syncCodexOAuthAuthFile(source, child); + assert.equal( + (JSON.parse(readFileSync(source, "utf8")).tokens as Record).access_token, + "source-access", + ); +}); + +test("Codex diagnostics redact malformed app-server output at the protocol boundary", async (t) => { + const dir = mkdtempSync(join(tmpdir(), "qm-codex-malformed-test-")); + const server = new CodexAppServer({ + binaryPath: malformedCodexBinary(dir), + cwd: dir, + env: { PATH: process.env.PATH }, + onNotification: () => {}, + onRequest: async () => ({}), + }); + t.after(async () => { + await server.close().catch(() => undefined); + rmSync(dir, { recursive: true, force: true }); + }); + await assert.rejects(server.initialize(), (error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + assert.equal(message.includes("oauth-secret-123456789"), false); + assert.equal(message.includes("[redacted]"), true); + return true; + }); +}); + +test("Codex rejects incomplete JSON-RPC responses", async (t) => { + const dir = mkdtempSync(join(tmpdir(), "qm-codex-incomplete-response-test-")); + const server = new CodexAppServer({ + binaryPath: incompleteResponseCodexBinary(dir), + cwd: dir, + env: { PATH: process.env.PATH }, + onNotification: () => {}, + onRequest: async () => ({}), + }); + t.after(async () => { + await server.close().catch(() => undefined); + rmSync(dir, { recursive: true, force: true }); + }); + await assert.rejects(server.initialize(), /invalid JSON/); +}); + +test("Codex rejects response messages without ids", async (t) => { + const dir = mkdtempSync(join(tmpdir(), "qm-codex-no-id-response-test-")); + const server = new CodexAppServer({ + binaryPath: noIdResponseCodexBinary(dir), + cwd: dir, + env: { PATH: process.env.PATH }, + onNotification: () => {}, + onRequest: async () => ({}), + }); + t.after(async () => { + await server.close().catch(() => undefined); + rmSync(dir, { recursive: true, force: true }); + }); + await assert.rejects(server.initialize(), /invalid JSON/); +}); + +test("Codex rejects JSON arrays at the JSON-RPC boundary", async (t) => { + const dir = mkdtempSync(join(tmpdir(), "qm-codex-array-message-test-")); + const server = new CodexAppServer({ + binaryPath: arrayMessageCodexBinary(dir), + cwd: dir, + env: { PATH: process.env.PATH }, + onNotification: () => {}, + onRequest: async () => ({}), + }); + t.after(async () => { + await server.close().catch(() => undefined); + rmSync(dir, { recursive: true, force: true }); + }); + await assert.rejects(server.initialize(), /invalid JSON/); +}); + +test("Codex rejects malformed JSON-RPC field types", async (t) => { + const dir = mkdtempSync(join(tmpdir(), "qm-codex-invalid-json-rpc-test-")); + const server = new CodexAppServer({ + binaryPath: invalidJsonRpcBinary(dir), + cwd: dir, + env: { PATH: process.env.PATH }, + onNotification: () => {}, + onRequest: async () => ({}), + }); + t.after(async () => { + await server.close().catch(() => undefined); + rmSync(dir, { recursive: true, force: true }); + }); + await assert.rejects(server.initialize(), /invalid JSON/); +}); + +test("Codex rejects unknown JSON-RPC response ids", async (t) => { + const dir = mkdtempSync(join(tmpdir(), "qm-codex-unknown-response-test-")); + const server = new CodexAppServer({ + binaryPath: unknownResponseCodexBinary(dir), + cwd: dir, + env: { PATH: process.env.PATH }, + onNotification: () => {}, + onRequest: async () => ({}), + }); + t.after(async () => { + await server.close().catch(() => undefined); + rmSync(dir, { recursive: true, force: true }); + }); + await assert.rejects(server.initialize(), /unknown response id/); +}); + +test("Codex rejects malformed turn completion payloads", async (t) => { + const dir = mkdtempSync(join(tmpdir(), "qm-codex-malformed-turn-test-")); + const harness = createCodexHarness({ + binaryPath: malformedTurnCompletedCodexBinary(dir), + env: testHarnessEnv(dir), + turnWallClockMs: 2_000, + }); + t.after(async () => { + await harness.turns.close?.(); + rmSync(dir, { recursive: true, force: true }); + }); + const scope = { kind: "org", id: "test" } as unknown as ScopeId; + await assert.rejects( + harness.turns.runTurn({ + session: { id: "malformed-turn" } as Session, + input: "hi", + systemPrompt: "be concise", + history: [], + tools: {} as HarnessTurnInput["tools"], + scopeLabel: scope, + orgScopeId: scope, + emit: async (entry) => ({ ...entry, sessionId: "malformed-turn", seq: 1, createdAt: Date.now() }) as SessionEntry, + recordModelCall: () => {}, + }), + /invalid turn\/completed payload/, + ); }); test("Codex children cannot use parent surface, control, or terminal tools", () => { @@ -329,7 +944,7 @@ test("Codex interrupts the provider after a terminal QM tool", async (t) => { const dir = mkdtempSync(join(tmpdir(), "qm-codex-stop-test-")); const harness = createCodexHarness({ binaryPath: terminatingCodexBinary(dir), - env: process.env, + env: testHarnessEnv(dir), turnWallClockMs: 2_000, }); t.after(async () => { @@ -396,7 +1011,7 @@ test("Codex discards a nonresponsive startup so a later turn can retry", async ( const dir = mkdtempSync(join(tmpdir(), "qm-codex-startup-test-")); const harness = createCodexHarness({ binaryPath: nonresponsiveCodexBinary(dir), - env: process.env, + env: testHarnessEnv(dir), appServerStartTimeoutMs: 1_000, turnWallClockMs: 6_000, }); @@ -423,11 +1038,288 @@ test("Codex discards a nonresponsive startup so a later turn can retry", async ( assert.equal(readFileSync(join(dir, "starts"), "utf8"), "start\nstart\n"); }); +test("Codex persists an OAuth refresh before discarding a failed startup", async (t) => { + const dir = mkdtempSync(join(tmpdir(), "qm-codex-startup-oauth-test-")); + const authFile = join(dir, "auth.json"); + writeFileSync( + authFile, + JSON.stringify({ + auth_mode: "chatgpt", + tokens: { + access_token: "startup-access-before", + refresh_token: "startup-refresh-before", + account_id: "startup-account", + id_token: oauthIdToken("startup-account"), + }, + }), + ); + chmodSync(authFile, 0o600); + const harness = createCodexHarness({ + binaryPath: refreshThenNonresponsiveCodexBinary(dir), + env: { CODEX_AUTH_FILE: authFile }, + appServerStartTimeoutMs: 1_000, + turnWallClockMs: 3_000, + }); + t.after(async () => { + await harness.turns.close?.(); + rmSync(dir, { recursive: true, force: true }); + }); + const scope = { kind: "org", id: "test" } as unknown as ScopeId; + await assert.rejects( + harness.turns.runTurn({ + session: { id: "startup-oauth" } as Session, + input: "hi", + systemPrompt: "be concise", + history: [], + tools: {} as HarnessTurnInput["tools"], + scopeLabel: scope, + orgScopeId: scope, + emit: async (entry) => ({ ...entry, sessionId: "startup-oauth", seq: 1, createdAt: Date.now() }) as SessionEntry, + recordModelCall: () => {}, + }), + (error: unknown) => /timed out|exited|closed/i.test(error instanceof Error ? error.message : String(error)), + ); + const persisted = JSON.parse(readFileSync(authFile, "utf8")) as Record; + assert.equal((persisted.tokens as Record).access_token, "startup-access-after"); +}); + +test("cancelling an OAuth startup lock wait prevents the provider from starting", async (t) => { + const dir = mkdtempSync(join(tmpdir(), "qm-codex-cancel-startup-oauth-test-")); + const authFile = join(dir, "auth.json"); + const lockFile = `${authFile}.lock`; + writeFileSync( + authFile, + JSON.stringify({ + auth_mode: "chatgpt", + tokens: { + access_token: "cancel-access", + refresh_token: "cancel-refresh", + account_id: "cancel-account", + id_token: oauthIdToken("cancel-account"), + }, + }), + ); + chmodSync(authFile, 0o600); + writeFileSync(lockFile, String(process.pid)); + const harness = createCodexHarness({ + binaryPath: nonresponsiveCodexBinary(dir), + env: { CODEX_AUTH_FILE: authFile, PATH: process.env.PATH }, + turnWallClockMs: 3_000, + }); + t.after(async () => { + unlinkSync(lockFile); + await harness.turns.close?.(); + rmSync(dir, { recursive: true, force: true }); + }); + const cancel = new AbortController(); + const scope = { kind: "org", id: "test" } as unknown as ScopeId; + const turn = harness.turns.runTurn({ + session: { id: "cancel-startup-oauth" } as Session, + input: "hi", + systemPrompt: "be concise", + history: [], + tools: {} as HarnessTurnInput["tools"], + scopeLabel: scope, + orgScopeId: scope, + cancel: cancel.signal, + emit: async (entry) => + ({ ...entry, sessionId: "cancel-startup-oauth", seq: 1, createdAt: Date.now() }) as SessionEntry, + recordModelCall: () => {}, + }); + setTimeout(() => cancel.abort(), 50); + assert.deepEqual(await turn, { reply: "", stopped: true }); + await new Promise((resolve) => setTimeout(resolve, 150)); + assert.equal(existsSync(join(dir, "starts")), false); +}); + +test("cancelling an OAuth startup after spawn closes the provider", async (t) => { + const dir = mkdtempSync(join(tmpdir(), "qm-codex-cancel-startup-child-test-")); + const authFile = join(dir, "auth.json"); + writeFileSync( + authFile, + JSON.stringify({ + auth_mode: "chatgpt", + tokens: { + access_token: "cancel-child-access", + refresh_token: "cancel-child-refresh", + account_id: "cancel-child-account", + id_token: oauthIdToken("cancel-child-account"), + }, + }), + { mode: 0o600 }, + ); + const harness = createCodexHarness({ + binaryPath: startupCancellationCodexBinary(dir), + env: { CODEX_AUTH_FILE: authFile }, + appServerStartTimeoutMs: 1_000, + turnWallClockMs: 3_000, + }); + t.after(async () => { + await harness.turns.close?.(); + rmSync(dir, { recursive: true, force: true }); + }); + const cancel = new AbortController(); + const scope = { kind: "org", id: "test" } as unknown as ScopeId; + const turn = harness.turns.runTurn({ + session: { id: "cancel-startup-child" } as Session, + input: "hi", + systemPrompt: "be concise", + history: [], + tools: {} as HarnessTurnInput["tools"], + scopeLabel: scope, + orgScopeId: scope, + cancel: cancel.signal, + emit: async (entry) => + ({ ...entry, sessionId: "cancel-startup-child", seq: 1, createdAt: Date.now() }) as SessionEntry, + recordModelCall: () => {}, + }); + for (let attempt = 0; attempt < 50 && !existsSync(join(dir, "starts")); attempt += 1) + await new Promise((resolve) => setTimeout(resolve, 10)); + assert.equal(existsSync(join(dir, "starts")), true); + cancel.abort(); + assert.deepEqual(await turn, { reply: "", stopped: true }); + await new Promise((resolve) => setTimeout(resolve, 150)); + assert.equal(readFileSync(join(dir, "closed"), "utf8"), "closed"); +}); + +test("OAuth turns serialize shared auth ownership and cancel a waiting contender", async (t) => { + const dir = mkdtempSync(join(tmpdir(), "qm-codex-oauth-turn-lock-test-")); + const authFile = join(dir, "auth.json"); + writeFileSync( + authFile, + JSON.stringify({ + auth_mode: "chatgpt", + tokens: { + access_token: "shared-access", + refresh_token: "shared-refresh", + account_id: "shared-account", + id_token: oauthIdToken("shared-account"), + }, + }), + ); + chmodSync(authFile, 0o600); + const first = createCodexHarness({ + binaryPath: oauthTurnBinary(dir, "first", 250), + env: { CODEX_AUTH_FILE: authFile }, + turnWallClockMs: 3_000, + }); + const second = createCodexHarness({ + binaryPath: oauthTurnBinary(dir, "second", 30), + env: { CODEX_AUTH_FILE: authFile }, + turnWallClockMs: 3_000, + }); + t.after(async () => { + await first.turns.close?.(); + await second.turns.close?.(); + rmSync(dir, { recursive: true, force: true }); + }); + const scope = { kind: "org", id: "test" } as unknown as ScopeId; + const turnInput = (id: string, cancel?: AbortSignal): HarnessTurnInput => ({ + session: { id } as Session, + input: id, + systemPrompt: "be concise", + history: [], + tools: {} as HarnessTurnInput["tools"], + scopeLabel: scope, + orgScopeId: scope, + ...(cancel ? { cancel } : {}), + emit: async (entry) => ({ ...entry, sessionId: id, seq: 1, createdAt: Date.now() }) as SessionEntry, + recordModelCall: () => {}, + }); + const firstTurn = first.turns.runTurn(turnInput("first-turn")); + await new Promise((resolve) => setTimeout(resolve, 70)); + const controller = new AbortController(); + const secondTurn = second.turns.runTurn(turnInput("second-turn", controller.signal)); + setTimeout(() => controller.abort(), 50); + assert.deepEqual(await secondTurn, { reply: "", stopped: true }); + assert.equal((await firstTurn).reply, "first"); + assert.equal(readFileSync(join(dir, "oauth-events"), "utf8"), "first\n"); + const persisted = JSON.parse(readFileSync(authFile, "utf8")) as Record; + assert.equal((persisted.tokens as Record).access_token, "first"); +}); + +test("Codex fails closed when OAuth auth is removed after startup", async (t) => { + const dir = mkdtempSync(join(tmpdir(), "qm-codex-oauth-delete-test-")); + const authFile = join(dir, "auth.json"); + writeFileSync( + authFile, + JSON.stringify({ + auth_mode: "chatgpt", + tokens: { + access_token: "delete-access", + refresh_token: "delete-refresh", + account_id: "delete-account", + id_token: oauthIdToken("delete-account"), + }, + }), + { mode: 0o600 }, + ); + const harness = createCodexHarness({ + binaryPath: oauthTurnBinary(dir, "delete", 1), + env: { CODEX_AUTH_FILE: authFile }, + turnWallClockMs: 3_000, + }); + t.after(async () => { + await harness.turns.close?.(); + rmSync(dir, { recursive: true, force: true }); + }); + const scope = { kind: "org", id: "test" } as unknown as ScopeId; + const run = (id: string) => + harness.turns.runTurn({ + session: { id } as Session, + input: id, + systemPrompt: "be concise", + history: [], + tools: {} as HarnessTurnInput["tools"], + scopeLabel: scope, + orgScopeId: scope, + emit: async (entry) => ({ ...entry, sessionId: id, seq: 1, createdAt: Date.now() }) as SessionEntry, + recordModelCall: () => {}, + }); + assert.equal((await run("before-delete")).reply, "delete"); + rmSync(authFile); + await assert.rejects(run("after-delete"), /auth\.json is unavailable/); +}); + +test("Codex app-server exits reject turns without unhandled rejections", async (t) => { + const dir = mkdtempSync(join(tmpdir(), "qm-codex-exit-test-")); + const harness = createCodexHarness({ + binaryPath: exitingCodexBinary(dir), + env: testHarnessEnv(dir), + turnWallClockMs: 3_000, + }); + const unhandled: unknown[] = []; + const onUnhandled = (error: unknown) => unhandled.push(error); + process.on("unhandledRejection", onUnhandled); + t.after(async () => { + process.off("unhandledRejection", onUnhandled); + await harness.turns.close?.(); + rmSync(dir, { recursive: true, force: true }); + }); + const scope = { kind: "org", id: "test" } as unknown as ScopeId; + await assert.rejects( + harness.turns.runTurn({ + session: { id: "exit-turn" } as Session, + input: "hi", + systemPrompt: "be concise", + history: [], + tools: {} as HarnessTurnInput["tools"], + scopeLabel: scope, + orgScopeId: scope, + emit: async (entry) => ({ ...entry, sessionId: "exit-turn", seq: 1, createdAt: Date.now() }) as SessionEntry, + recordModelCall: () => {}, + }), + /exited \(17\)/, + ); + await new Promise((resolve) => setTimeout(resolve, 25)); + assert.deepEqual(unhandled, []); +}); + test("cancelling one Codex setup does not kill another active turn", async (t) => { const dir = mkdtempSync(join(tmpdir(), "qm-codex-concurrent-test-")); const harness = createCodexHarness({ binaryPath: concurrentCodexBinary(dir), - env: process.env, + env: testHarnessEnv(dir), turnWallClockMs: 2_000, }); t.after(async () => { @@ -510,6 +1402,10 @@ test("Codex never classifies its own infrastructure failures as terminal", () => } assert.equal(codexProviderFailure("Codex turn failed").message, "Codex turn failed"); assert.ok(!(codexProviderFailure("socket hang up") instanceof NonRetryableTurnError)); + assert.equal( + codexProviderFailure("401 access_token=provider-secret-123456").message.includes("provider-secret"), + false, + ); }); test("Codex reads cumulative usage totals off the app-server's token notification", () => { @@ -557,7 +1453,7 @@ for (const mode of ["turnFailed", "startRejected"] as const) { const dir = mkdtempSync(join(tmpdir(), "qm-codex-fail-test-")); const harness = createCodexHarness({ binaryPath: failingProviderCodexBinary(dir, mode), - env: process.env, + env: testHarnessEnv(dir), turnWallClockMs: 5_000, }); t.after(async () => { @@ -587,7 +1483,7 @@ test("Codex records one llm row per turn carrying real timings and usage, even w const records: HarnessLlmRequestRecord[] = []; const scope = { kind: "org", id: "test" } as unknown as ScopeId; const runWith = async (binaryPath: string, id: string) => { - const harness = createCodexHarness({ binaryPath, env: process.env, turnWallClockMs: 5_000 }); + const harness = createCodexHarness({ binaryPath, env: testHarnessEnv(dir), turnWallClockMs: 5_000 }); t.after(async () => await harness.turns.close?.()); return await harness.turns.runTurn({ session: { id } as Session, @@ -637,7 +1533,7 @@ test( const server = new CodexAppServer({ binaryPath: realCodexBinary!, cwd: jail, - env: codexChildEnv({ PATH: process.env.PATH }, jail), + env: codexChildEnv({ PATH: process.env.PATH, CODEX_HOME: join(jail, "empty-source") }, jail), onNotification: () => {}, onRequest: async (method) => { requests.push(method); @@ -650,43 +1546,56 @@ test( }); await server.initialize(); - const started = await server.request<{ thread: { id: string } }>("thread/start", { - model: DEFAULT_CODEX_MODEL_ID, - cwd: jail, - approvalPolicy: "never", - sandbox: "read-only", - ephemeral: true, - baseInstructions: "be concise", - developerInstructions: "use the supplied dynamic tools", - dynamicTools: [ - { - type: "function", - name: "execute", - description: "run a command", - inputSchema: { type: "object", properties: {} }, - }, - ], - experimentalRawEvents: true, - environments: [], - config: { - web_search: "disabled", - features: { - shell_tool: false, - unified_exec: false, - shell_snapshot: false, - apps: false, - plugins: false, - browser_use: false, - browser_use_external: false, - computer_use: false, - image_generation: false, - in_app_browser: false, - multi_agent: true, - request_permissions_tool: false, - tool_suggest: false, + const started = await server.request( + "thread/start", + { + model: DEFAULT_CODEX_MODEL_ID, + cwd: jail, + approvalPolicy: "never", + sandbox: "read-only", + ephemeral: true, + baseInstructions: "be concise", + developerInstructions: "use the supplied dynamic tools", + dynamicTools: [ + { + type: "function", + name: "execute", + description: "run a command", + inputSchema: { type: "object", properties: {} }, + }, + ], + experimentalRawEvents: true, + environments: [], + config: { + web_search: "disabled", + features: { + shell_tool: false, + unified_exec: false, + shell_snapshot: false, + apps: false, + plugins: false, + browser_use: false, + browser_use_external: false, + computer_use: false, + image_generation: false, + in_app_browser: false, + multi_agent: true, + request_permissions_tool: false, + tool_suggest: false, + }, }, }, - }); + (value: unknown): value is { thread: { id: string } } => { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const thread = (value as Record).thread; + return Boolean( + thread && + typeof thread === "object" && + !Array.isArray(thread) && + typeof (thread as Record).id === "string", + ); + }, + ); assert.ok(started.thread.id, "the real app-server returned a thread id for our start shape"); await server.request("thread/inject_items", { threadId: started.thread.id, diff --git a/test/dev-cli-lib.test.ts b/test/dev-cli-lib.test.ts index d34267e6a..695101e94 100644 --- a/test/dev-cli-lib.test.ts +++ b/test/dev-cli-lib.test.ts @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { envSha, formatAge, readEnvFile } from "../scripts/dev/lib/util.ts"; @@ -30,7 +30,7 @@ import { } from "../scripts/dev/lib/lease.ts"; import { assembleEnv, completeDevSecuritySecrets } from "../scripts/dev/lib/envctx.ts"; import { buildChildSpecs, type SpecInputs } from "../scripts/dev/supervisor/specs.ts"; -import { loadConfig, OPENCODE_RUNTIME_VERSION } from "../src/config.ts"; +import { loadConfig, OPENCODE_RUNTIME_VERSION, providerKeysPresent } from "../src/config.ts"; import type { LeaseInfo } from "../scripts/dev/lib/types.ts"; function tmpStore(): string { @@ -214,9 +214,34 @@ test("env assembly precedence: caller > login shell > dev.env > worktree .env; h assert.equal(openCode.env.PI_CAPTURE_REQUESTS, undefined); await assert.rejects( - assembleEnv({ worktree, callerEnv: { HARNESS: "codex" }, allowMock: false, log, probeLoginShell: async () => "" }), + assembleEnv({ + worktree, + callerEnv: { HARNESS: "codex", CODEX_HOME: join(worktree, "empty-codex") }, + allowMock: false, + log, + probeLoginShell: async () => "", + }), /HARNESS=codex needs OPENAI_API_KEY/, ); + const oauthAuthFile = join(worktree, "codex-auth.json"); + writeFileSync( + oauthAuthFile, + JSON.stringify({ + auth_mode: "chatgpt", + tokens: { access_token: "access", refresh_token: "refresh", account_id: "account" }, + }), + ); + chmodSync(oauthAuthFile, 0o600); + const codexOAuth = await assembleEnv({ + worktree, + callerEnv: { HARNESS: "codex", CODEX_AUTH_FILE: oauthAuthFile }, + allowMock: false, + log, + probeLoginShell: async () => "", + }); + assert.equal(codexOAuth.harness, "codex"); + assert.equal(codexOAuth.env.CODEX_AUTH_FILE, oauthAuthFile); + assert.equal(codexOAuth.codexAuthSource, oauthAuthFile); const codex = await assembleEnv({ worktree, callerEnv: { HARNESS: "codex", OPENAI_API_KEY: "sk-openai" }, @@ -315,6 +340,26 @@ test("OpenCode config is strict, pinned, and inherits the Pi model", () => { "claude-opus-4-8", ); assert.equal(loadConfig({ HARNESS: "claude", CLAUDE_BIN: "/bin/claude" }).claudeBinPath, "/bin/claude"); + const source = mkdtempSync(join(tmpdir(), "qm-codex-config-")); + const authFile = join(source, "auth.json"); + writeFileSync( + authFile, + JSON.stringify({ + auth_mode: "chatgpt", + tokens: { access_token: "access", refresh_token: "refresh", account_id: "account" }, + }), + ); + chmodSync(authFile, 0o600); + const oauthConfig = loadConfig({ HARNESS: "codex", CODEX_AUTH_FILE: authFile }); + assert.equal(oauthConfig.codexAuthFile, authFile); + assert.equal(providerKeysPresent(oauthConfig).openai, false); + assert.equal(providerKeysPresent(oauthConfig).codexOAuth, true); + assert.throws( + () => + loadConfig({ HARNESS: "codex", CODEX_AUTH_FILE: join(source, "missing.json"), OPENAI_API_KEY: "placeholder" }), + /OPENAI_API_KEY/, + ); + rmSync(source, { recursive: true, force: true }); assert.throws(() => loadConfig({ HARNESS: "bogus" }), /use mock, pi, opencode, codex, or claude/); assert.throws(() => loadConfig({ HARNESS: "PI" }), /use mock, pi, opencode, codex, or claude/); }); @@ -349,7 +394,7 @@ test("supervised children share the selected dev org", () => { const inputs: SpecInputs = { worktree: "/tmp/worktree", ports: slotPorts("pool1"), - baseEnv: { DEV_INSTANCE_ORG_ID: "beta" }, + baseEnv: { DEV_INSTANCE_ORG_ID: "beta", CODEX_AUTH_FILE: "/tmp/codex-auth.json" }, watch: false, webUiBasePath: "/", slack: { botToken: "xoxb-test", appToken: "xapp-test" }, @@ -364,6 +409,8 @@ test("supervised children share the selected dev org", () => { }; const specs = buildChildSpecs(inputs); assert.equal(specs.find((spec) => spec.name === "core")!.env.ORG_ID, "beta"); + assert.equal(specs.find((spec) => spec.name === "core")!.env.CODEX_AUTH_FILE, "/tmp/codex-auth.json"); + for (const spec of specs.filter((spec) => spec.name !== "core")) assert.equal(spec.env.CODEX_AUTH_FILE, ""); for (const spec of specs) assert.equal(spec.env.CORE_ORG_ID, "beta"); inputs.baseEnv = {}; assert.equal(buildChildSpecs(inputs).find((spec) => spec.name === "core")!.env.ORG_ID, "acme"); diff --git a/test/model-credential-route.test.ts b/test/model-credential-route.test.ts index e127025ab..4a1016933 100644 --- a/test/model-credential-route.test.ts +++ b/test/model-credential-route.test.ts @@ -8,6 +8,7 @@ import { join } from "node:path"; import { test } from "node:test"; import { createInsecureTestServer } from "../src/api/server.ts"; import { buildApp, type BuiltApp } from "../src/wiring.ts"; +import { providerKeysPresent } from "../src/config.ts"; import { testConfig } from "./support/test-config.ts"; import { createModelCredentialStore, type StoredModelCredential } from "../src/model/model-credential-store.ts"; import { createMemoryMap } from "../src/persistence/durable-map.ts"; @@ -22,23 +23,17 @@ function start( built: BuiltApp; close: () => Promise; } { - const built = buildApp( - testConfig({ - dataDir: mkdtempSync(join(tmpdir(), "model-credential-route-")), - ...config, - }), - { modelCredentialFetch }, - ); + const appConfig = testConfig({ + dataDir: mkdtempSync(join(tmpdir(), "model-credential-route-")), + ...config, + }); + const built = buildApp(appConfig, { modelCredentialFetch }); const server = createInsecureTestServer(built.app, { config: built.config, modelCredentials: built.modelCredentials, modelCredentialFetch, harnessId: config.harness ?? "pi", - providerKeys: { - anthropic: Boolean(config.anthropicApiKey), - openai: Boolean(config.openaiApiKey), - openrouter: Boolean(config.openrouterApiKey), - }, + providerKeys: providerKeysPresent(appConfig), admin: built.admin, auditLog: built.auditLog, }); @@ -401,6 +396,34 @@ test("surface-config reports whether any model provider is configured", async () } }); +test("surface-config respects an admin-disabled environment provider", async () => { + const srv = start({ anthropicApiKey: "deployment-anthropic-key" }); + try { + const before = await fetch(`${srv.base}/v1/surface-config`); + assert.equal(((await before.json()) as { modelProviderConfigured?: boolean }).modelProviderConfigured, true); + const disabled = await fetch(`${srv.base}/v1/admin/model-providers/anthropic`, { + method: "DELETE", + headers: ADMIN, + }); + assert.equal(disabled.status, 200); + const after = await fetch(`${srv.base}/v1/surface-config`); + assert.equal(((await after.json()) as { modelProviderConfigured?: boolean }).modelProviderConfigured, false); + } finally { + await srv.close(); + } +}); + +test("surface-config reports Codex ChatGPT OAuth without making it a Pi credential", async () => { + const srv = start({ harness: "codex", codexAuthFile: "/tmp/codex-auth.json" }); + try { + const surface = await fetch(`${srv.base}/v1/surface-config`); + assert.equal(surface.status, 200); + assert.equal(((await surface.json()) as { modelProviderConfigured?: boolean }).modelProviderConfigured, true); + } finally { + await srv.close(); + } +}); + test("admin model credentials survive a second app instance on the same durable store", async () => { const backing = createMemoryMap(); const first = createModelCredentialStore({ backing, keyMaterial: "shared-model-key" }); From 9f1bae69649738fe614cd08afb422676b4a1a93e Mon Sep 17 00:00:00 2001 From: "open-swe[bot]" Date: Sun, 2 Aug 2026 21:16:45 +1000 Subject: [PATCH 02/24] test: use trusted Codex OAuth fixtures --- test/dev-cli-lib.test.ts | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/test/dev-cli-lib.test.ts b/test/dev-cli-lib.test.ts index 695101e94..2b9e327eb 100644 --- a/test/dev-cli-lib.test.ts +++ b/test/dev-cli-lib.test.ts @@ -39,6 +39,13 @@ function tmpStore(): string { return store; } +function oauthIdToken(accountId: string): string { + const payload = Buffer.from( + JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: accountId } }), + ).toString("base64url"); + return `header.${payload}.signature`; +} + function addSlot(store: string, n: number, extra = ""): void { writeFileSync( join(store, `pool${n}.env`), @@ -228,7 +235,12 @@ test("env assembly precedence: caller > login shell > dev.env > worktree .env; h oauthAuthFile, JSON.stringify({ auth_mode: "chatgpt", - tokens: { access_token: "access", refresh_token: "refresh", account_id: "account" }, + tokens: { + access_token: "access", + refresh_token: "refresh", + account_id: "account", + id_token: oauthIdToken("account"), + }, }), ); chmodSync(oauthAuthFile, 0o600); @@ -346,7 +358,12 @@ test("OpenCode config is strict, pinned, and inherits the Pi model", () => { authFile, JSON.stringify({ auth_mode: "chatgpt", - tokens: { access_token: "access", refresh_token: "refresh", account_id: "account" }, + tokens: { + access_token: "access", + refresh_token: "refresh", + account_id: "account", + id_token: oauthIdToken("account"), + }, }), ); chmodSync(authFile, 0o600); From a0226625aff5edb83dccb382d4f77c0cb830c58e Mon Sep 17 00:00:00 2001 From: "open-swe[bot]" Date: Sun, 2 Aug 2026 21:22:09 +1000 Subject: [PATCH 03/24] fix: persist rotated Codex OAuth tokens --- src/harness/codex-auth.ts | 25 ++++++++++++++++++++++--- test/codex-harness.test.ts | 10 +++++++--- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/src/harness/codex-auth.ts b/src/harness/codex-auth.ts index 1237a140e..b11c8e0fe 100644 --- a/src/harness/codex-auth.ts +++ b/src/harness/codex-auth.ts @@ -200,7 +200,17 @@ export async function acquireCodexOAuthAuthLock( try { const handle = await openFile(path, "wx", 0o600); const owner = `${process.pid}:${randomBytes(8).toString("hex")}`; - await handle.writeFile(owner); + try { + await handle.writeFile(owner); + } catch (error) { + await handle.close().catch(() => undefined); + try { + unlinkSync(path); + } catch (cleanupError) { + swallow("codex: oauth lock creation cleanup", cleanupError); + } + throw error; + } let released = false; return { path, @@ -252,7 +262,17 @@ function lockFile(sourcePath: string): SyncLock { try { const fd = openSync(path, "wx", 0o600); const owner = `${process.pid}:${randomBytes(8).toString("hex")}`; - writeSync(fd, owner); + try { + writeSync(fd, owner); + } catch (error) { + closeSync(fd); + try { + unlinkSync(path); + } catch (cleanupError) { + swallow("codex: oauth lock creation cleanup", cleanupError); + } + throw error; + } return { fd, owner }; } catch (error) { const code = error && typeof error === "object" && "code" in error ? error.code : undefined; @@ -289,7 +309,6 @@ export function syncCodexOAuthAuthFile( !childTokens || typeof sourceTokens.id_token !== "string" || typeof childTokens.id_token !== "string" || - sourceTokens.id_token !== childTokens.id_token || !sourceAccountId || sourceAccountId !== childAccountId ) diff --git a/test/codex-harness.test.ts b/test/codex-harness.test.ts index 6a74775e4..ce53df607 100644 --- a/test/codex-harness.test.ts +++ b/test/codex-harness.test.ts @@ -50,9 +50,9 @@ function testHarnessEnv(home: string): NodeJS.ProcessEnv { return { ...process.env, HOME: home, CODEX_HOME: join(home, "codex-home") }; } -function oauthIdToken(accountId: string): string { +function oauthIdToken(accountId: string, marker = ""): string { const payload = Buffer.from( - JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: accountId } }), + JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: accountId }, marker }), ).toString("base64url"); return `header.${payload}.signature`; } @@ -572,7 +572,7 @@ test("Codex materializes ChatGPT OAuth auth without an API-key override and pers access_token: "access-after", refresh_token: "refresh-after", account_id: "account-before", - id_token: oauthIdToken("account-before"), + id_token: oauthIdToken("account-before", "rotated"), }, }), ); @@ -582,6 +582,10 @@ test("Codex materializes ChatGPT OAuth auth without an API-key override and pers const persisted = JSON.parse(readFileSync(authFile, "utf8")) as Record; assert.equal(persisted.OPENAI_API_KEY, "ambient-api-key"); assert.equal((persisted.tokens as Record).access_token, "access-after"); + assert.equal( + (persisted.tokens as Record).id_token, + oauthIdToken("account-before", "rotated"), + ); } finally { await lock.release(); } From c33c55831017ac3785671dd3d45b65fa9cb6d445 Mon Sep 17 00:00:00 2001 From: "open-swe[bot]" Date: Sun, 2 Aug 2026 21:25:48 +1000 Subject: [PATCH 04/24] style: format OAuth regression test --- test/codex-harness.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/test/codex-harness.test.ts b/test/codex-harness.test.ts index ce53df607..84dbf5c2c 100644 --- a/test/codex-harness.test.ts +++ b/test/codex-harness.test.ts @@ -582,10 +582,7 @@ test("Codex materializes ChatGPT OAuth auth without an API-key override and pers const persisted = JSON.parse(readFileSync(authFile, "utf8")) as Record; assert.equal(persisted.OPENAI_API_KEY, "ambient-api-key"); assert.equal((persisted.tokens as Record).access_token, "access-after"); - assert.equal( - (persisted.tokens as Record).id_token, - oauthIdToken("account-before", "rotated"), - ); + assert.equal((persisted.tokens as Record).id_token, oauthIdToken("account-before", "rotated")); } finally { await lock.release(); } From 353904ee33ae9b31c79ad50764159693a0f68343 Mon Sep 17 00:00:00 2001 From: "open-swe[bot]" Date: Sun, 2 Aug 2026 21:34:08 +1000 Subject: [PATCH 05/24] fix: preserve OAuth state during runtime replacement --- src/harness/codex-auth.ts | 26 ++++++++++++++++++-------- src/harness/codex-harness.ts | 3 +++ 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/harness/codex-auth.ts b/src/harness/codex-auth.ts index b11c8e0fe..c67bc9f7c 100644 --- a/src/harness/codex-auth.ts +++ b/src/harness/codex-auth.ts @@ -226,10 +226,15 @@ export async function acquireCodexOAuthAuthLock( if (released) return; released = true; await handle.close().catch(() => undefined); - try { - if (readFileSync(path, "utf8") === owner) unlinkSync(path); - } catch { - return; + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + if (readFileSync(path, "utf8") !== owner) return; + unlinkSync(path); + return; + } catch (error) { + if (attempt === 2) swallow("codex: oauth lock release", error); + else await new Promise((resolveWait) => setTimeout(resolveWait, 10)); + } } }, }; @@ -334,10 +339,15 @@ export function syncCodexOAuthAuthFile( writeJsonAtomically(sourcePath, next); } finally { if (lock !== undefined) { - try { - if (readFileSync(lockPath(sourcePath), "utf8") === lock.owner) unlinkSync(lockPath(sourcePath)); - } catch (error) { - swallow("codex: oauth lock cleanup", error); + const path = lockPath(sourcePath); + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + if (readFileSync(path, "utf8") !== lock.owner) break; + unlinkSync(path); + break; + } catch (error) { + if (attempt === 2) swallow("codex: oauth lock cleanup", error); + } } closeSync(lock.fd); } diff --git a/src/harness/codex-harness.ts b/src/harness/codex-harness.ts index bb0b57bb6..13c4e6d4f 100644 --- a/src/harness/codex-harness.ts +++ b/src/harness/codex-harness.ts @@ -524,6 +524,9 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { const stale = runtime; runtime = null; runtimeCleanupRequested = false; + const lock = activeAuthLock; + if (lock?.isHeld()) + stale.persistAuth(activeExpectedRefreshToken, activeExpectedAccessToken, lock.path, activeExpectedSourceAuth); rmSync(stale.jail, { recursive: true, force: true }); } let startup = starting; From 8c3fdd75c5de30b96d3da6eda486e0b7ae6852b5 Mon Sep 17 00:00:00 2001 From: "open-swe[bot]" Date: Sun, 2 Aug 2026 21:42:00 +1000 Subject: [PATCH 06/24] fix: release stale OAuth runtime locks --- src/harness/codex-auth.ts | 12 +++++++++++- src/harness/codex-harness.ts | 20 ++++++++++++++++++-- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/harness/codex-auth.ts b/src/harness/codex-auth.ts index c67bc9f7c..2ee28f23d 100644 --- a/src/harness/codex-auth.ts +++ b/src/harness/codex-auth.ts @@ -21,6 +21,7 @@ import { swallow } from "../util/errors.ts"; type JsonObject = Record; const CODEX_OAUTH_MODES = new Set(["chatgpt", "chatgptAuthTokens"]); +const heldOAuthLockPaths = new Set(); function asObject(value: unknown): JsonObject | null { return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonObject) : null; @@ -158,6 +159,7 @@ function removeStaleLock(path: string): boolean { contents = readFileSync(path, "utf8"); const owner = Number(contents.trim().split(":", 1)[0]); if (Number.isInteger(owner) && owner > 0) { + if (owner === process.pid && !heldOAuthLockPaths.has(path)) return true; if (processAlive(owner)) return false; } else if (Date.now() - statSync(path).mtimeMs <= 60_000) return false; } catch { @@ -202,6 +204,7 @@ export async function acquireCodexOAuthAuthLock( const owner = `${process.pid}:${randomBytes(8).toString("hex")}`; try { await handle.writeFile(owner); + heldOAuthLockPaths.add(path); } catch (error) { await handle.close().catch(() => undefined); try { @@ -228,14 +231,19 @@ export async function acquireCodexOAuthAuthLock( await handle.close().catch(() => undefined); for (let attempt = 0; attempt < 3; attempt += 1) { try { - if (readFileSync(path, "utf8") !== owner) return; + if (readFileSync(path, "utf8") !== owner) { + heldOAuthLockPaths.delete(path); + return; + } unlinkSync(path); + heldOAuthLockPaths.delete(path); return; } catch (error) { if (attempt === 2) swallow("codex: oauth lock release", error); else await new Promise((resolveWait) => setTimeout(resolveWait, 10)); } } + heldOAuthLockPaths.delete(path); }, }; } catch (error) { @@ -269,6 +277,7 @@ function lockFile(sourcePath: string): SyncLock { const owner = `${process.pid}:${randomBytes(8).toString("hex")}`; try { writeSync(fd, owner); + heldOAuthLockPaths.add(path); } catch (error) { closeSync(fd); try { @@ -350,6 +359,7 @@ export function syncCodexOAuthAuthFile( } } closeSync(lock.fd); + heldOAuthLockPaths.delete(path); } } } diff --git a/src/harness/codex-harness.ts b/src/harness/codex-harness.ts index 13c4e6d4f..1bf4291a1 100644 --- a/src/harness/codex-harness.ts +++ b/src/harness/codex-harness.ts @@ -524,9 +524,25 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { const stale = runtime; runtime = null; runtimeCleanupRequested = false; + const staleError = stale.server.error() ?? new Error("Codex app-server exited during a turn"); + for (const [threadId, state] of active) { + if (state.server !== stale.server) continue; + state.reject(staleError); + active.delete(threadId); + } const lock = activeAuthLock; - if (lock?.isHeld()) - stale.persistAuth(activeExpectedRefreshToken, activeExpectedAccessToken, lock.path, activeExpectedSourceAuth); + if (lock) { + if (lock.isHeld()) + stale.persistAuth(activeExpectedRefreshToken, activeExpectedAccessToken, lock.path, activeExpectedSourceAuth); + if (activeAuthLock === lock) { + activeAuthLock = undefined; + activeExpectedRefreshToken = undefined; + activeExpectedAccessToken = undefined; + activeExpectedSourceAuth = undefined; + } + await lock.release().catch((error) => swallow("codex: oauth lock release", error)); + } + await stale.server.close().catch(() => undefined); rmSync(stale.jail, { recursive: true, force: true }); } let startup = starting; From 7a170cc96b9be71c4913354b3aa7362446fe0838 Mon Sep 17 00:00:00 2001 From: "open-swe[bot]" Date: Sun, 2 Aug 2026 21:49:31 +1000 Subject: [PATCH 07/24] test: cover same-process stale OAuth locks --- src/harness/codex-auth.ts | 6 ++++-- test/codex-harness.test.ts | 9 ++++----- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/harness/codex-auth.ts b/src/harness/codex-auth.ts index 2ee28f23d..25d53e892 100644 --- a/src/harness/codex-auth.ts +++ b/src/harness/codex-auth.ts @@ -159,8 +159,10 @@ function removeStaleLock(path: string): boolean { contents = readFileSync(path, "utf8"); const owner = Number(contents.trim().split(":", 1)[0]); if (Number.isInteger(owner) && owner > 0) { - if (owner === process.pid && !heldOAuthLockPaths.has(path)) return true; - if (processAlive(owner)) return false; + if (owner === process.pid && heldOAuthLockPaths.has(path)) return false; + if (owner === process.pid) { + if (Date.now() - statSync(path).mtimeMs <= 60_000) return false; + } else if (processAlive(owner)) return false; } else if (Date.now() - statSync(path).mtimeMs <= 60_000) return false; } catch { return true; diff --git a/test/codex-harness.test.ts b/test/codex-harness.test.ts index 84dbf5c2c..cedf27597 100644 --- a/test/codex-harness.test.ts +++ b/test/codex-harness.test.ts @@ -643,11 +643,10 @@ test("Codex materializes ChatGPT OAuth auth without an API-key override and pers const liveLock = `${authFile}.lock`; writeFileSync(liveLock, String(process.pid)); utimesSync(liveLock, new Date(0), new Date(0)); - try { - assert.throws(() => syncCodexOAuthAuthFile(authFile, childAuthFile), /timed out acquiring/); - } finally { - unlinkSync(liveLock); - } + const recoveredLock = await acquireCodexOAuthAuthLock(authFile, undefined, 1_000); + assert.equal(recoveredLock.isHeld(), true); + await recoveredLock.release(); + assert.equal(existsSync(liveLock), false); const liveLockSafe = JSON.parse(readFileSync(authFile, "utf8")) as Record; assert.equal((liveLockSafe.tokens as Record).access_token, "access-newest"); From 5c54ed5027fb9eda20cb50d0671cacb876550abb Mon Sep 17 00:00:00 2001 From: "open-swe[bot]" Date: Sun, 2 Aug 2026 22:02:30 +1000 Subject: [PATCH 08/24] fix: bound Codex setup requests --- src/harness/codex-harness.ts | 75 +++++++++++++++++++++++------------- 1 file changed, 49 insertions(+), 26 deletions(-) diff --git a/src/harness/codex-harness.ts b/src/harness/codex-harness.ts index 1bf4291a1..9d0d51a73 100644 --- a/src/harness/codex-harness.ts +++ b/src/harness/codex-harness.ts @@ -876,33 +876,42 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { throw new NonRetryableTurnError("Codex OAuth auth.json is unavailable"); } if (oauthConfigured && authPath && sourceAuth) { - const authLockPromise = acquireCodexOAuthAuthLock( - authPath, - AbortSignal.any([closeAbort.signal, authAcquireAbort.signal]), - Math.max(120_000, wallMs + 5_000), - ); - try { - turnAuthLock = await awaitSetup(authLockPromise); - } catch (error) { - void authLockPromise.then( - (lock) => lock.release(), - () => undefined, + while (true) { + const authLockPromise = acquireCodexOAuthAuthLock( + authPath, + AbortSignal.any([closeAbort.signal, authAcquireAbort.signal]), + Math.max(120_000, wallMs + 5_000), ); - throw error; - } - const currentAuth = readCodexOAuthAuthFile(authPath); - if (!currentAuth) { - rmSync(join(rt.jail, "codex-home", "auth.json"), { force: true }); - throw new NonRetryableTurnError("Codex OAuth auth.json is unavailable"); + try { + turnAuthLock = await awaitSetup(authLockPromise); + } catch (error) { + void authLockPromise.then( + (lock) => lock.release(), + () => undefined, + ); + throw error; + } + if (runtime !== rt || rt.server.process.exitCode !== null) { + await turnAuthLock.release(); + turnAuthLock = undefined; + rt = await awaitSetup(ensureRuntime()); + continue; + } + const currentAuth = readCodexOAuthAuthFile(authPath); + if (!currentAuth) { + rmSync(join(rt.jail, "codex-home", "auth.json"), { force: true }); + throw new NonRetryableTurnError("Codex OAuth auth.json is unavailable"); + } + expectedRefreshToken = codexOAuthRefreshToken(currentAuth); + expectedAccessToken = codexOAuthAccessToken(currentAuth); + expectedSourceAuth = currentAuth ?? undefined; + prepareCodexHome(sourceEnv, rt.jail); + activeAuthLock = turnAuthLock; + activeExpectedRefreshToken = expectedRefreshToken; + activeExpectedAccessToken = expectedAccessToken; + activeExpectedSourceAuth = expectedSourceAuth; + break; } - expectedRefreshToken = codexOAuthRefreshToken(currentAuth); - expectedAccessToken = codexOAuthAccessToken(currentAuth); - expectedSourceAuth = currentAuth ?? undefined; - prepareCodexHome(sourceEnv, rt.jail); - activeAuthLock = turnAuthLock; - activeExpectedRefreshToken = expectedRefreshToken; - activeExpectedAccessToken = expectedAccessToken; - activeExpectedSourceAuth = expectedSourceAuth; } } catch (error) { await releaseTurnAuth(); @@ -980,7 +989,21 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { } let started: { thread: { id: string }; model?: string }; try { - started = await awaitSetup(rt.server.request("thread/start", threadStartRequest, isCodexThreadStart)); + const requestTimeoutMs = deadline ? Math.max(1, deadline - Date.now()) : CODEX_START_TIMEOUT_MS; + let requestTimer: NodeJS.Timeout | undefined; + started = await awaitSetup( + Promise.race([ + rt.server.request("thread/start", threadStartRequest, isCodexThreadStart), + new Promise((_, reject) => { + requestTimer = setTimeout( + () => reject(new NonRetryableTurnError("Codex thread/start request timed out")), + requestTimeoutMs, + ); + }), + ]).finally(() => { + if (requestTimer) clearTimeout(requestTimer); + }), + ); } catch (error) { await releaseTurnAuth(); setupSettled = true; From eaef48712c8a04283d7d0ed627cf1b107c7cbac5 Mon Sep 17 00:00:00 2001 From: "open-swe[bot]" Date: Sun, 2 Aug 2026 22:08:58 +1000 Subject: [PATCH 09/24] fix: bound OAuth runtime recovery --- src/harness/codex-harness.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/harness/codex-harness.ts b/src/harness/codex-harness.ts index 9d0d51a73..f4add77bd 100644 --- a/src/harness/codex-harness.ts +++ b/src/harness/codex-harness.ts @@ -809,6 +809,7 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { }; const wallMs = turn.turnWallClockMs ?? defaultTurnWallClockMs; const deadline = wallMs > 0 ? Date.now() + wallMs : 0; + const runtimeRecoveryDeadline = Date.now() + Math.max(wallMs, CODEX_START_TIMEOUT_MS); const setupCancelled = new Error("Codex setup cancelled"); const setupTimedOut = new NonRetryableTurnError(`Codex turn exceeded ${Math.round(wallMs / 1000)}s wall clock`); let rejectSetup!: (error: Error) => void; @@ -877,10 +878,12 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { } if (oauthConfigured && authPath && sourceAuth) { while (true) { + if (Date.now() >= runtimeRecoveryDeadline) + throw new NonRetryableTurnError("Codex OAuth runtime recovery timed out"); const authLockPromise = acquireCodexOAuthAuthLock( authPath, AbortSignal.any([closeAbort.signal, authAcquireAbort.signal]), - Math.max(120_000, wallMs + 5_000), + Math.min(120_000, Math.max(1, runtimeRecoveryDeadline - Date.now())), ); try { turnAuthLock = await awaitSetup(authLockPromise); @@ -894,7 +897,11 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { if (runtime !== rt || rt.server.process.exitCode !== null) { await turnAuthLock.release(); turnAuthLock = undefined; - rt = await awaitSetup(ensureRuntime()); + rt = await awaitSetup( + ensureRuntime((release) => { + releaseStartupWaiter = release; + }), + ); continue; } const currentAuth = readCodexOAuthAuthFile(authPath); From 65f08bde9865721db4b032b4f3885b8b8d3a204c Mon Sep 17 00:00:00 2001 From: "open-swe[bot]" Date: Sun, 2 Aug 2026 22:19:49 +1000 Subject: [PATCH 10/24] fix: cancel timed out Codex requests --- src/harness/codex-app-server.ts | 39 +++++++++++++++++++++++++++++---- src/harness/codex-harness.ts | 39 ++++++++++++++++++++++++--------- 2 files changed, 64 insertions(+), 14 deletions(-) diff --git a/src/harness/codex-app-server.ts b/src/harness/codex-app-server.ts index d3415be40..6f4e5d04d 100644 --- a/src/harness/codex-app-server.ts +++ b/src/harness/codex-app-server.ts @@ -125,6 +125,7 @@ export class CodexAppServer { JsonRpcId, { resolve(value: unknown): void; reject(error: Error): void; validate?: JsonRpcResultValidator } >(); + private readonly cancelledRequestIds = new Set(); private writeTail = Promise.resolve(); private eventTail = Promise.resolve(); private stderr = ""; @@ -184,18 +185,44 @@ export class CodexAppServer { await this.notify("initialized"); } - request(method: string, params?: unknown): Promise; - request(method: string, params: unknown, validate: JsonRpcResultValidator): Promise; - request(method: string, params?: unknown, validate?: JsonRpcResultValidator): Promise { + request(method: string, params?: unknown, signal?: AbortSignal): Promise; + request(method: string, params: unknown, validate: JsonRpcResultValidator, signal?: AbortSignal): Promise; + request( + method: string, + params?: unknown, + validateOrSignal?: JsonRpcResultValidator | AbortSignal, + signal?: AbortSignal, + ): Promise { if (this.closed) return Promise.reject(new Error("Codex app-server is closed")); + const validate = typeof validateOrSignal === "function" ? validateOrSignal : undefined; + const requestSignal: AbortSignal | undefined = validate + ? signal + : typeof validateOrSignal === "function" + ? undefined + : validateOrSignal; + if (requestSignal?.aborted) return Promise.reject(new Error("Codex app-server request cancelled")); const id = this.nextId++; + let rejectResult!: (error: Error) => void; const result = new Promise((resolve, reject) => { + rejectResult = reject; this.pending.set(id, { resolve, reject, ...(validate ? { validate: validate as JsonRpcResultValidator } : {}), }); }); + if (requestSignal) { + const onAbort = () => { + if (!this.pending.delete(id)) return; + this.cancelledRequestIds.add(id); + rejectResult(new Error("Codex app-server request cancelled")); + }; + requestSignal.addEventListener("abort", onAbort, { once: true }); + void result.then( + () => requestSignal.removeEventListener("abort", onAbort), + () => requestSignal.removeEventListener("abort", onAbort), + ); + } void this.send({ id, method, ...(params === undefined ? {} : { params }) }).catch((error) => { const waiter = this.pending.get(id); this.pending.delete(id); @@ -233,7 +260,10 @@ export class CodexAppServer { } if (message.id !== undefined && !message.method) { const waiter = this.pending.get(message.id); - if (!waiter) throw new CodexRpcError(`Codex app-server sent an unknown response id ${String(message.id)}`); + if (!waiter) { + if (this.cancelledRequestIds.delete(message.id)) return; + throw new CodexRpcError(`Codex app-server sent an unknown response id ${String(message.id)}`); + } this.pending.delete(message.id); if ("error" in message) { if (!message.error || typeof message.error !== "object") { @@ -284,5 +314,6 @@ export class CodexAppServer { private failAll(error: Error): void { for (const waiter of this.pending.values()) waiter.reject(error); this.pending.clear(); + this.cancelledRequestIds.clear(); } } diff --git a/src/harness/codex-harness.ts b/src/harness/codex-harness.ts index f4add77bd..a684ead18 100644 --- a/src/harness/codex-harness.ts +++ b/src/harness/codex-harness.ts @@ -518,7 +518,10 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { } }; - const ensureRuntime = async (registerCancel?: (release: () => void) => void): Promise => { + const ensureRuntime = async ( + registerCancel?: (release: () => void) => void, + startupDeadline = 0, + ): Promise => { if (runtime && runtime.server.process.exitCode === null) return runtime; if (runtime) { const stale = runtime; @@ -547,9 +550,8 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { } let startup = starting; if (startup?.abort.signal.aborted) { - await startup.promise.catch(() => undefined); if (starting === startup) starting = null; - startup = starting; + startup = null; } if (!startup) { const startupAbort = new AbortController(); @@ -564,9 +566,13 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { try { if (oauthConfigured && !sourceAuth) throw new Error("Codex OAuth auth.json is unavailable"); if (oauthConfigured && authPath && sourceAuth) { + const lockTimeout = startupDeadline + ? Math.min(120_000, Math.max(1, startupDeadline - Date.now())) + : 120_000; authLock = await acquireCodexOAuthAuthLock( authPath, AbortSignal.any([closeAbort.signal, startupAbort.signal]), + lockTimeout, ); const currentAuth = readCodexOAuthAuthFile(authPath); expectedRefreshToken = codexOAuthRefreshToken(currentAuth); @@ -703,12 +709,18 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { }; let startTimer: NodeJS.Timeout | undefined; try { + const initializationTimeout = startupDeadline + ? Math.min( + opts.appServerStartTimeoutMs ?? CODEX_START_TIMEOUT_MS, + Math.max(1, startupDeadline - Date.now()), + ) + : (opts.appServerStartTimeoutMs ?? CODEX_START_TIMEOUT_MS); await Promise.race([ server.initialize(), new Promise((_, reject) => { startTimer = setTimeout( () => reject(new Error("Codex app-server initialization timed out")), - opts.appServerStartTimeoutMs ?? CODEX_START_TIMEOUT_MS, + initializationTimeout, ); }), ]); @@ -897,10 +909,11 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { if (runtime !== rt || rt.server.process.exitCode !== null) { await turnAuthLock.release(); turnAuthLock = undefined; + if (authAcquireAbort.signal.aborted) throw setupCancelled; rt = await awaitSetup( ensureRuntime((release) => { releaseStartupWaiter = release; - }), + }, runtimeRecoveryDeadline), ); continue; } @@ -998,14 +1011,20 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { try { const requestTimeoutMs = deadline ? Math.max(1, deadline - Date.now()) : CODEX_START_TIMEOUT_MS; let requestTimer: NodeJS.Timeout | undefined; + const requestAbort = new AbortController(); started = await awaitSetup( Promise.race([ - rt.server.request("thread/start", threadStartRequest, isCodexThreadStart), + rt.server.request( + "thread/start", + threadStartRequest, + isCodexThreadStart, + AbortSignal.any([authAcquireAbort.signal, closeAbort.signal, requestAbort.signal]), + ), new Promise((_, reject) => { - requestTimer = setTimeout( - () => reject(new NonRetryableTurnError("Codex thread/start request timed out")), - requestTimeoutMs, - ); + requestTimer = setTimeout(() => { + requestAbort.abort(); + reject(new NonRetryableTurnError("Codex thread/start request timed out")); + }, requestTimeoutMs); }), ]).finally(() => { if (requestTimer) clearTimeout(requestTimer); From e646e12ab0b71d98f26dcfdb4038e96d1ad28b5d Mon Sep 17 00:00:00 2001 From: "open-swe[bot]" Date: Sun, 2 Aug 2026 22:22:43 +1000 Subject: [PATCH 11/24] style: simplify request signal selection --- src/harness/codex-app-server.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/harness/codex-app-server.ts b/src/harness/codex-app-server.ts index 6f4e5d04d..2b0b1779c 100644 --- a/src/harness/codex-app-server.ts +++ b/src/harness/codex-app-server.ts @@ -195,11 +195,9 @@ export class CodexAppServer { ): Promise { if (this.closed) return Promise.reject(new Error("Codex app-server is closed")); const validate = typeof validateOrSignal === "function" ? validateOrSignal : undefined; - const requestSignal: AbortSignal | undefined = validate - ? signal - : typeof validateOrSignal === "function" - ? undefined - : validateOrSignal; + let requestSignal: AbortSignal | undefined; + if (validate) requestSignal = signal; + else if (typeof validateOrSignal !== "function") requestSignal = validateOrSignal; if (requestSignal?.aborted) return Promise.reject(new Error("Codex app-server request cancelled")); const id = this.nextId++; let rejectResult!: (error: Error) => void; From 191bf7c219fc2289bb59741d584b3860c0c12127 Mon Sep 17 00:00:00 2001 From: "open-swe[bot]" Date: Sun, 2 Aug 2026 22:36:19 +1000 Subject: [PATCH 12/24] fix: harden Codex turn cancellation --- src/harness/codex-harness.ts | 90 +++++++++++++++++++++++++++--------- 1 file changed, 68 insertions(+), 22 deletions(-) diff --git a/src/harness/codex-harness.ts b/src/harness/codex-harness.ts index a684ead18..ada5bff45 100644 --- a/src/harness/codex-harness.ts +++ b/src/harness/codex-harness.ts @@ -170,7 +170,7 @@ type Runtime = { expectedAccessToken?: string, heldLockPath?: string, expectedSourceAuth?: Record, - ): void; + ): boolean; }; type StartingRuntime = { promise: Promise; @@ -592,7 +592,7 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { const p = (params ?? {}) as Record; const threadId = typeof p.threadId === "string" ? p.threadId : ""; const state = active.get(threadId); - if (!state) return; + if (!state || state.server !== server) return; if (method === "thread/tokenUsage/updated") { const totals = codexUsageTotals(p); if (totals) state.usageByThread.set(threadId, totals); @@ -644,7 +644,7 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { const p = (params ?? {}) as Record; const threadId = String(p.threadId ?? ""); const state = active.get(threadId); - if (!state) throw new Error("inactive Codex thread"); + if (!state || state.server !== server) throw new Error("inactive Codex thread"); const name = String(p.tool ?? ""); const callId = String(p.callId ?? ""); if (threadId !== state.threadId && !codexChildToolAllowed(name)) @@ -682,6 +682,20 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { startingServer = server; if (startupAbort.signal.aborted) throw new Error("Codex app-server startup cancelled"); } catch (error) { + if (authLock) { + try { + syncCodexOAuthAuthFile( + authPath, + join(jail, "codex-home", "auth.json"), + authLock.path, + expectedRefreshToken, + expectedAccessToken, + expectedSourceAuth, + ); + } catch (persistenceError) { + swallow("codex: oauth auth persistence", persistenceError); + } + } await server?.close().catch(() => undefined); await authLock?.release(); rmSync(jail, { recursive: true, force: true }); @@ -693,19 +707,24 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { expectedAccess = expectedAccessToken, heldLockPath?: string, expectedSource = expectedSourceAuth, - ) => { - try { - syncCodexOAuthAuthFile( - authPath, - childAuthPath, - heldLockPath, - expectedRefresh, - expectedAccess, - expectedSource, - ); - } catch (error) { - swallow("codex: oauth auth persistence", error); + ): boolean => { + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + syncCodexOAuthAuthFile( + authPath, + childAuthPath, + heldLockPath, + expectedRefresh, + expectedAccess, + expectedSource, + ); + return true; + } catch (error) { + if (attempt === 2) swallow("codex: oauth auth persistence", error); + else Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10); + } } + return false; }; let startTimer: NodeJS.Timeout | undefined; try { @@ -724,7 +743,8 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { ); }), ]); - if (authLock) persistAuth(expectedRefreshToken, expectedAccessToken, authLock.path, expectedSourceAuth); + if (authLock && !persistAuth(expectedRefreshToken, expectedAccessToken, authLock.path, expectedSourceAuth)) + throw new Error("Codex OAuth auth persistence failed"); await authLock?.release(); authLock = undefined; } catch (error) { @@ -754,8 +774,11 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { const lock = activeAuthLock; activeAuthLock = undefined; if (lock) { - if (lock.isHeld()) - persistAuth(activeExpectedRefreshToken, activeExpectedAccessToken, lock.path, activeExpectedSourceAuth); + if ( + lock.isHeld() && + !persistAuth(activeExpectedRefreshToken, activeExpectedAccessToken, lock.path, activeExpectedSourceAuth) + ) + swallow("codex: oauth auth persistence", new Error("Codex OAuth auth persistence failed")); void lock.release().catch((error) => swallow("codex: oauth lock release", error)); activeExpectedRefreshToken = undefined; activeExpectedAccessToken = undefined; @@ -871,7 +894,8 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { const lock = turnAuthLock; if (!lock) return; try { - if (lock.isHeld()) rt.persistAuth(expectedRefreshToken, expectedAccessToken, lock.path, expectedSourceAuth); + if (lock.isHeld() && !rt.persistAuth(expectedRefreshToken, expectedAccessToken, lock.path, expectedSourceAuth)) + throw new NonRetryableTurnError("Codex OAuth auth persistence failed"); } finally { if (activeAuthLock === lock) { activeAuthLock = undefined; @@ -1221,8 +1245,28 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { : null; let timer: NodeJS.Timeout | undefined; try { - const response = await rt.server - .request<{ turn: CodexTurn }>("turn/start", { threadId, input, ...(model ? { model } : {}) }, isCodexTurnStart) + const turnStartAbort = new AbortController(); + const turnStartSignals = [closeAbort.signal, turnStartAbort.signal]; + if (turn.cancel) turnStartSignals.push(turn.cancel); + const turnStartTimeoutMs = deadline ? Math.max(1, deadline - Date.now()) : CODEX_START_TIMEOUT_MS; + let turnStartTimer: NodeJS.Timeout | undefined; + const response = await Promise.race([ + rt.server.request<{ turn: CodexTurn }>( + "turn/start", + { threadId, input, ...(model ? { model } : {}) }, + isCodexTurnStart, + AbortSignal.any(turnStartSignals), + ), + new Promise((_, reject) => { + turnStartTimer = setTimeout(() => { + turnStartAbort.abort(); + reject(new NonRetryableTurnError("Codex turn/start request timed out")); + }, turnStartTimeoutMs); + }), + ]) + .finally(() => { + if (turnStartTimer) clearTimeout(turnStartTimer); + }) .catch((error: unknown) => { throw error instanceof CodexRpcError ? codexProviderFailure(error.message) : error; }); @@ -1349,9 +1393,10 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { await current.server.close(); const lock = activeAuthLock; activeAuthLock = undefined; + let persistenceFailed = false; if (lock) { if (lock.isHeld()) - current.persistAuth( + persistenceFailed = !current.persistAuth( activeExpectedRefreshToken, activeExpectedAccessToken, lock.path, @@ -1362,6 +1407,7 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { activeExpectedAccessToken = undefined; activeExpectedSourceAuth = undefined; } + if (persistenceFailed) throw new Error("Codex OAuth auth persistence failed"); rmSync(current.jail, { recursive: true, force: true }); if (runtime === current) runtime = null; } From 97ee905f1bebddffdc1d04a50f0b54e559eb1c7d Mon Sep 17 00:00:00 2001 From: "open-swe[bot]" Date: Sun, 2 Aug 2026 22:47:07 +1000 Subject: [PATCH 13/24] fix: harden Codex turn cancellation --- src/harness/codex-harness.ts | 47 ++++++++++++++++++++++++--- test/codex-harness.test.ts | 62 ++++++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 5 deletions(-) diff --git a/src/harness/codex-harness.ts b/src/harness/codex-harness.ts index ada5bff45..cbee73478 100644 --- a/src/harness/codex-harness.ts +++ b/src/harness/codex-harness.ts @@ -527,6 +527,7 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { const stale = runtime; runtime = null; runtimeCleanupRequested = false; + let persistenceFailed = false; const staleError = stale.server.error() ?? new Error("Codex app-server exited during a turn"); for (const [threadId, state] of active) { if (state.server !== stale.server) continue; @@ -536,7 +537,12 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { const lock = activeAuthLock; if (lock) { if (lock.isHeld()) - stale.persistAuth(activeExpectedRefreshToken, activeExpectedAccessToken, lock.path, activeExpectedSourceAuth); + persistenceFailed = !stale.persistAuth( + activeExpectedRefreshToken, + activeExpectedAccessToken, + lock.path, + activeExpectedSourceAuth, + ); if (activeAuthLock === lock) { activeAuthLock = undefined; activeExpectedRefreshToken = undefined; @@ -547,6 +553,7 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { } await stale.server.close().catch(() => undefined); rmSync(stale.jail, { recursive: true, force: true }); + if (persistenceFailed) throw new Error("Codex OAuth auth persistence failed"); } let startup = starting; if (startup?.abort.signal.aborted) { @@ -682,6 +689,7 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { startingServer = server; if (startupAbort.signal.aborted) throw new Error("Codex app-server startup cancelled"); } catch (error) { + let persistenceFailed = false; if (authLock) { try { syncCodexOAuthAuthFile( @@ -693,12 +701,14 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { expectedSourceAuth, ); } catch (persistenceError) { + persistenceFailed = true; swallow("codex: oauth auth persistence", persistenceError); } } await server?.close().catch(() => undefined); await authLock?.release(); rmSync(jail, { recursive: true, force: true }); + if (persistenceFailed) throw new Error("Codex OAuth auth persistence failed", { cause: error }); throw error; } const childAuthPath = join(jail, "codex-home", "auth.json"); @@ -748,10 +758,13 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { await authLock?.release(); authLock = undefined; } catch (error) { - if (authLock) persistAuth(expectedRefreshToken, expectedAccessToken, authLock.path, expectedSourceAuth); + const persistenceFailed = authLock + ? !persistAuth(expectedRefreshToken, expectedAccessToken, authLock.path, expectedSourceAuth) + : false; await server.close().catch(() => undefined); await authLock?.release(); rmSync(jail, { recursive: true, force: true }); + if (persistenceFailed) throw new Error("Codex OAuth auth persistence failed", { cause: error }); throw error; } finally { if (startTimer) clearTimeout(startTimer); @@ -1244,8 +1257,10 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { ) : null; let timer: NodeJS.Timeout | undefined; + const turnStartAbort = new AbortController(); + let turnStartTimedOut = false; + const cleanupErrors: unknown[] = []; try { - const turnStartAbort = new AbortController(); const turnStartSignals = [closeAbort.signal, turnStartAbort.signal]; if (turn.cancel) turnStartSignals.push(turn.cancel); const turnStartTimeoutMs = deadline ? Math.max(1, deadline - Date.now()) : CODEX_START_TIMEOUT_MS; @@ -1259,6 +1274,8 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { ), new Promise((_, reject) => { turnStartTimer = setTimeout(() => { + turnStartTimedOut = true; + runtimeCleanupRequested = true; turnStartAbort.abort(); reject(new NonRetryableTurnError("Codex turn/start request timed out")); }, turnStartTimeoutMs); @@ -1268,6 +1285,11 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { if (turnStartTimer) clearTimeout(turnStartTimer); }) .catch((error: unknown) => { + if (turnStartTimedOut) { + const timeoutError = new NonRetryableTurnError("Codex turn/start request timed out"); + timeoutError.cause = error; + throw timeoutError; + } throw error instanceof CodexRpcError ? codexProviderFailure(error.message) : error; }); turnId = response.turn.id; @@ -1314,11 +1336,25 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { modelCalls: state.modelCalls, ...(state.tapeWriteFailed ? { tapeWriteFailed: true } : {}), }; + } catch (error) { + if (turn.cancel?.aborted) { + runtimeCleanupRequested = true; + return { reply: "", stopped: true }; + } + throw error; } finally { if (timer) clearTimeout(timer); - await stopSignals?.(); + try { + await stopSignals?.(); + } catch (error) { + cleanupErrors.push(error); + } + try { + await releaseTurnAuth(); + } catch (error) { + cleanupErrors.push(error); + } await recordRequest(); - await releaseTurnAuth(); turn.cancel?.removeEventListener("abort", onCancel); for (const [taskId, status] of state.taskStatuses) { if (status === "pending" || status === "in_progress") { @@ -1330,6 +1366,7 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { } if (runtimeCleanupRequested) await closeIdleRuntime(); } + if (cleanupErrors.length) throw cleanupErrors[0]; }; const single = async ( diff --git a/test/codex-harness.test.ts b/test/codex-harness.test.ts index cedf27597..0ce1a174d 100644 --- a/test/codex-harness.test.ts +++ b/test/codex-harness.test.ts @@ -206,6 +206,32 @@ process.stdin.resume(); return path; } +function pendingTurnStartCodexBinary(dir: string): string { + const path = join(dir, "pending-turn-start-codex"); + writeFileSync( + path, + `#!${process.execPath} +const fs = require("node:fs"); +const readline = require("node:readline"); +const rl = readline.createInterface({ input: process.stdin }); +const send = (value) => process.stdout.write(JSON.stringify(value) + "\\n"); +rl.on("line", (line) => { + const msg = JSON.parse(line); + if (msg.method === "initialize") return send({ id: msg.id, result: {} }); + if (msg.method === "initialized") return; + if (msg.method === "thread/start") return send({ id: msg.id, result: { thread: { id: "thread-pending" } } }); + if (msg.method === "turn/start") fs.writeFileSync(${JSON.stringify(join(dir, "turn-started"))}, "started"); +}); +process.on("SIGTERM", () => { + fs.writeFileSync(${JSON.stringify(join(dir, "closed"))}, "closed"); + process.exit(0); +}); +`, + ); + chmodSync(path, 0o755); + return path; +} + function refreshThenNonresponsiveCodexBinary(dir: string): string { const path = join(dir, "refresh-then-nonresponsive-codex"); writeFileSync( @@ -1182,6 +1208,42 @@ test("cancelling an OAuth startup after spawn closes the provider", async (t) => assert.equal(readFileSync(join(dir, "closed"), "utf8"), "closed"); }); +test("cancelling a pending Codex turn/start stops and closes the runtime", async (t) => { + const dir = mkdtempSync(join(tmpdir(), "qm-codex-cancel-turn-start-test-")); + const harness = createCodexHarness({ + binaryPath: pendingTurnStartCodexBinary(dir), + env: testHarnessEnv(dir), + turnWallClockMs: 3_000, + }); + t.after(async () => { + await harness.turns.close?.(); + rmSync(dir, { recursive: true, force: true }); + }); + const cancel = new AbortController(); + const scope = { kind: "org", id: "test" } as unknown as ScopeId; + const turn = harness.turns.runTurn({ + session: { id: "cancel-turn-start" } as Session, + input: "hi", + systemPrompt: "be concise", + history: [], + tools: {} as HarnessTurnInput["tools"], + scopeLabel: scope, + orgScopeId: scope, + cancel: cancel.signal, + emit: async (entry) => + ({ ...entry, sessionId: "cancel-turn-start", seq: 1, createdAt: Date.now() }) as SessionEntry, + recordModelCall: () => {}, + }); + for (let attempt = 0; attempt < 100 && !existsSync(join(dir, "turn-started")); attempt += 1) + await new Promise((resolve) => setTimeout(resolve, 10)); + assert.equal(existsSync(join(dir, "turn-started")), true); + cancel.abort(); + assert.deepEqual(await turn, { reply: "", stopped: true }); + for (let attempt = 0; attempt < 100 && !existsSync(join(dir, "closed")); attempt += 1) + await new Promise((resolve) => setTimeout(resolve, 10)); + assert.equal(readFileSync(join(dir, "closed"), "utf8"), "closed"); +}); + test("OAuth turns serialize shared auth ownership and cancel a waiting contender", async (t) => { const dir = mkdtempSync(join(tmpdir(), "qm-codex-oauth-turn-lock-test-")); const authFile = join(dir, "auth.json"); From 7f9bda339c607ecf4b9eb808f2729f5201a92862 Mon Sep 17 00:00:00 2001 From: "open-swe[bot]" Date: Sun, 2 Aug 2026 23:12:38 +1000 Subject: [PATCH 14/24] fix: harden OAuth isolation and cleanup --- scripts/dev/supervisor/specs.ts | 5 +- src/harness/codex-app-server.ts | 16 +++- src/harness/codex-auth.ts | 98 +++++++++++--------- src/harness/codex-harness.ts | 158 +++++++++++++++++++++----------- test/codex-harness.test.ts | 14 ++- test/dev-cli-lib.test.ts | 13 ++- 6 files changed, 195 insertions(+), 109 deletions(-) diff --git a/scripts/dev/supervisor/specs.ts b/scripts/dev/supervisor/specs.ts index 95b824288..33ab510eb 100644 --- a/scripts/dev/supervisor/specs.ts +++ b/scripts/dev/supervisor/specs.ts @@ -21,7 +21,10 @@ export interface SpecInputs { export function buildChildSpecs(i: SpecInputs): ChildSpec[] { const watchArgs = i.watch ? ["--watch"] : []; const base = { ...i.baseEnv, ...i.sandboxEnv }; - const siblingBase = { ...base, CODEX_AUTH_FILE: "" }; + const siblingBase = Object.fromEntries( + Object.entries(base).filter(([key]) => key !== "HOME" && key !== "CODEX_HOME"), + ); + siblingBase.CODEX_AUTH_FILE = ""; const orgId = i.baseEnv.DEV_INSTANCE_ORG_ID || "acme"; const signing: Record = i.coreSigningSecret ? { CORE_SIGNING_SECRET: i.coreSigningSecret } : {}; return [ diff --git a/src/harness/codex-app-server.ts b/src/harness/codex-app-server.ts index 2b0b1779c..a41d4f539 100644 --- a/src/harness/codex-app-server.ts +++ b/src/harness/codex-app-server.ts @@ -52,6 +52,11 @@ const CODEX_DIAGNOSTIC_SENSITIVE_KEYS = new Set([ "idtoken", "apikey", "clientsecret", + "credential", + "credentials", + "password", + "passphrase", + "secret", "token", "authorization", "proxyauthorization", @@ -88,25 +93,26 @@ function redactStructuredDiagnostics(value: string): string { export function redactCodexDiagnostics(value: string): string { return redactStructuredDiagnostics(value) .replace( - /(["']?(?:access[_-]?token|refresh[_-]?token|id[_-]?token|api[_-]?key|apikey|client[_-]?secret|token|authorization|proxy-authorization|cookie|set-cookie)["']?\s*[:=]\s*)\[[\s\S]*?(?:\]|$)/gi, + /(["']?(?:access[_-]?token|refresh[_-]?token|id[_-]?token|api[_-]?key|apikey|client[_-]?secret|credential|credentials|password|passphrase|secret|token|authorization|proxy-authorization|cookie|set-cookie)["']?\s*[:=]\s*)\[[\s\S]*?(?:\]|$)/gi, "$1[redacted]", ) .replace( - /(["']?(?:access[_-]?token|refresh[_-]?token|id[_-]?token|api[_-]?key|apikey|client[_-]?secret|token|authorization|proxy-authorization|cookie|set-cookie)["']?\s*[:=]\s*)\{[\s\S]*$/gi, + /(["']?(?:access[_-]?token|refresh[_-]?token|id[_-]?token|api[_-]?key|apikey|client[_-]?secret|credential|credentials|password|passphrase|secret|token|authorization|proxy-authorization|cookie|set-cookie)["']?\s*[:=]\s*)\{[\s\S]*$/gi, "$1{redacted}", ) .replace( - /(["']?(?:access[_-]?token|refresh[_-]?token|id[_-]?token|api[_-]?key|apikey|client[_-]?secret|token|authorization|proxy-authorization|cookie|set-cookie)["']?\s*[:=]\s*)(["'])(?:(?:\\[\s\S])|(?!\2)[\s\S])*(?:\2|$)/gi, + /(["']?(?:access[_-]?token|refresh[_-]?token|id[_-]?token|api[_-]?key|apikey|client[_-]?secret|credential|credentials|password|passphrase|secret|token|authorization|proxy-authorization|cookie|set-cookie)["']?\s*[:=]\s*)(["'])(?:(?:\\[\s\S])|(?!\2)[\s\S])*(?:\2|$)/gi, "$1$2[redacted]$2", ) .replace( - /(["']?(?:access[_-]?token|refresh[_-]?token|id[_-]?token|api[_-]?key|apikey|client[_-]?secret|token|authorization|proxy-authorization|cookie|set-cookie)["']?\s*[:=]\s*)(?!(?:["']|\[))[^,\r\n}\]]+/gi, + /(["']?(?:access[_-]?token|refresh[_-]?token|id[_-]?token|api[_-]?key|apikey|client[_-]?secret|credential|credentials|password|passphrase|secret|token|authorization|proxy-authorization|cookie|set-cookie)["']?\s*[:=]\s*)(?!(?:["']|\[))[^,\r\n}\]]+/gi, "$1[redacted]", ) .replace(/\b(?:Basic|Digest)\s+\S+/gi, "[redacted]") .replace(/\bBearer\s+\S+/gi, "Bearer [redacted]") .replace(/\bsk-[A-Za-z0-9._-]{8,}/g, "[redacted]") - .replace(/\b[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, "[redacted]"); + .replace(/\b[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, "[redacted]") + .replace(/\b(?=[A-Za-z0-9_-]*\d)[A-Za-z0-9_-]{32,}\b/g, "[redacted]"); } export interface CodexAppServerOptions { diff --git a/src/harness/codex-auth.ts b/src/harness/codex-auth.ts index 25d53e892..b88cc78f4 100644 --- a/src/harness/codex-auth.ts +++ b/src/harness/codex-auth.ts @@ -241,11 +241,10 @@ export async function acquireCodexOAuthAuthLock( heldOAuthLockPaths.delete(path); return; } catch (error) { - if (attempt === 2) swallow("codex: oauth lock release", error); + if (attempt === 2) throw error; else await new Promise((resolveWait) => setTimeout(resolveWait, 10)); } } - heldOAuthLockPaths.delete(path); }, }; } catch (error) { @@ -307,47 +306,53 @@ export function syncCodexOAuthAuthFile( expectedRefreshToken?: string, expectedAccessToken?: string, expectedSourceAuth?: JsonObject, -): void { - if (!sourcePath) return; +): boolean { + if (!sourcePath) return true; const child = readCodexOAuthAuthFile(childPath); - if (!child) return; + if (!child) return false; const lock = heldLockPath ? undefined : lockFile(sourcePath); + let result: boolean; + let cleanupError: unknown; try { - const source = readJsonFile(sourcePath); - if (!source) return; - if (source.auth_mode !== child.auth_mode) return; - const sourceTokens = asObject(source.tokens); - const childTokens = asObject(child.tokens); - const sourceAccountId = codexOAuthJwtAccountId(source); - const childAccountId = codexOAuthJwtAccountId(child); - if ( - !sourceTokens || - !childTokens || - typeof sourceTokens.id_token !== "string" || - typeof childTokens.id_token !== "string" || - !sourceAccountId || - sourceAccountId !== childAccountId - ) - return; - if (expectedSourceAuth && JSON.stringify(source) !== JSON.stringify(expectedSourceAuth)) return; - if (expectedRefreshToken && codexOAuthRefreshToken(source) !== expectedRefreshToken) return; - if (expectedAccessToken && codexOAuthAccessToken(source) !== expectedAccessToken) return; - const sanitized = sanitizedCodexOAuthAuth(child); - const next = { - ...source, - ...sanitized, - ...(childTokens - ? { - tokens: { - ...sourceTokens, - ...childTokens, - ...(typeof sourceTokens.account_id === "string" ? { account_id: sourceTokens.account_id } : {}), - }, - } - : {}), - }; - if (JSON.stringify(next) === JSON.stringify(source)) return; - writeJsonAtomically(sourcePath, next); + result = (() => { + const source = readJsonFile(sourcePath); + if (!source) return false; + if (source.auth_mode !== child.auth_mode) return false; + const sourceTokens = asObject(source.tokens); + const childTokens = asObject(child.tokens); + const sourceAccountId = codexOAuthJwtAccountId(source); + const childAccountId = codexOAuthJwtAccountId(child); + if ( + !sourceTokens || + !childTokens || + typeof sourceTokens.id_token !== "string" || + typeof childTokens.id_token !== "string" || + sourceTokens.id_token !== childTokens.id_token || + !sourceAccountId || + sourceAccountId !== childAccountId + ) + return false; + if (expectedSourceAuth && JSON.stringify(source) !== JSON.stringify(expectedSourceAuth)) return false; + if (expectedRefreshToken && codexOAuthRefreshToken(source) !== expectedRefreshToken) return false; + if (expectedAccessToken && codexOAuthAccessToken(source) !== expectedAccessToken) return false; + const sanitized = sanitizedCodexOAuthAuth(child); + const next = { + ...source, + ...sanitized, + ...(childTokens + ? { + tokens: { + ...sourceTokens, + ...childTokens, + ...(typeof sourceTokens.account_id === "string" ? { account_id: sourceTokens.account_id } : {}), + }, + } + : {}), + }; + if (JSON.stringify(next) === JSON.stringify(source)) return true; + writeJsonAtomically(sourcePath, next); + return true; + })(); } finally { if (lock !== undefined) { const path = lockPath(sourcePath); @@ -355,13 +360,20 @@ export function syncCodexOAuthAuthFile( try { if (readFileSync(path, "utf8") !== lock.owner) break; unlinkSync(path); + heldOAuthLockPaths.delete(path); break; } catch (error) { - if (attempt === 2) swallow("codex: oauth lock cleanup", error); + if (attempt === 2) cleanupError = error; } } - closeSync(lock.fd); - heldOAuthLockPaths.delete(path); + try { + closeSync(lock.fd); + } catch (error) { + cleanupError ??= error; + } + if (!cleanupError) heldOAuthLockPaths.delete(path); } } + if (cleanupError) throw cleanupError; + return result; } diff --git a/src/harness/codex-harness.ts b/src/harness/codex-harness.ts index cbee73478..e90acf340 100644 --- a/src/harness/codex-harness.ts +++ b/src/harness/codex-harness.ts @@ -773,31 +773,40 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { runtime = { server, jail, persistAuth }; runtimeCleanupRequested = false; server.process.once("close", () => { + const currentRuntime = runtime?.server === server; + let persistenceFailed = false; + const lock = currentRuntime ? activeAuthLock : undefined; + if (lock?.isHeld()) + persistenceFailed = !persistAuth( + activeExpectedRefreshToken, + activeExpectedAccessToken, + lock.path, + activeExpectedSourceAuth, + ); + const closeError = persistenceFailed + ? new Error("Codex OAuth auth persistence failed", { cause: server.error() }) + : (server.error() ?? new Error("Codex app-server exited during a turn")); for (const [threadId, state] of active) { if (state.server !== server) continue; - state.reject(server.error() ?? new Error("Codex app-server exited during a turn")); + state.reject(closeError); active.delete(threadId); } - if (runtime?.server !== server) { + if (!currentRuntime) { rmSync(jail, { recursive: true, force: true }); return; } runtime = null; runtimeCleanupRequested = false; - const lock = activeAuthLock; - activeAuthLock = undefined; - if (lock) { - if ( - lock.isHeld() && - !persistAuth(activeExpectedRefreshToken, activeExpectedAccessToken, lock.path, activeExpectedSourceAuth) - ) - swallow("codex: oauth auth persistence", new Error("Codex OAuth auth persistence failed")); + if (closeAbort.signal.aborted) { + if (persistenceFailed) swallow("codex: oauth auth persistence", closeError); + } else if (lock) { + activeAuthLock = undefined; void lock.release().catch((error) => swallow("codex: oauth lock release", error)); activeExpectedRefreshToken = undefined; activeExpectedAccessToken = undefined; activeExpectedSourceAuth = undefined; } - rmSync(jail, { recursive: true, force: true }); + if (!closeAbort.signal.aborted) rmSync(jail, { recursive: true, force: true }); }); return runtime; })(); @@ -919,6 +928,14 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { await lock.release(); } }; + const releaseTurnAuthError = async (): Promise => { + try { + await releaseTurnAuth(); + return undefined; + } catch (error) { + return error; + } + }; try { const sourceAuth = authPath ? readCodexOAuthAuthFile(authPath) : null; if (oauthConfigured && !sourceAuth) { @@ -971,12 +988,13 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { } } } catch (error) { - await releaseTurnAuth(); + const releaseError = await releaseTurnAuthError(); setupSettled = true; if (setupTimer) clearTimeout(setupTimer); turn.cancel?.removeEventListener("abort", onSetupCancel); releaseSetupUser(); await closeIdleRuntime(); + if (releaseError) throw releaseError; if (error === setupCancelled) { return { reply: "", stopped: true }; } @@ -1035,12 +1053,13 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { }, }; } catch (error) { - await releaseTurnAuth(); + const releaseError = await releaseTurnAuthError(); setupSettled = true; if (setupTimer) clearTimeout(setupTimer); turn.cancel?.removeEventListener("abort", onSetupCancel); releaseSetupUser(); await closeIdleRuntime(); + if (releaseError) throw releaseError; if (error === setupCancelled) return { reply: "", stopped: true }; throw error; } @@ -1068,12 +1087,13 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { }), ); } catch (error) { - await releaseTurnAuth(); + const releaseError = await releaseTurnAuthError(); setupSettled = true; if (setupTimer) clearTimeout(setupTimer); turn.cancel?.removeEventListener("abort", onSetupCancel); releaseSetupUser(); await closeIdleRuntime(); + if (releaseError) throw releaseError; if (error === setupCancelled) { return { reply: "", stopped: true }; } @@ -1098,12 +1118,13 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { }), ); } catch (error) { - await releaseTurnAuth(); + const releaseError = await releaseTurnAuthError(); setupSettled = true; if (setupTimer) clearTimeout(setupTimer); turn.cancel?.removeEventListener("abort", onSetupCancel); releaseSetupUser(); await closeIdleRuntime(); + if (releaseError) throw releaseError; if (error === setupCancelled) { return { reply: "", stopped: true }; } @@ -1153,12 +1174,13 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { stopped: false, }; } catch (error) { - await releaseTurnAuth(); + const releaseError = await releaseTurnAuthError(); setupSettled = true; if (setupTimer) clearTimeout(setupTimer); turn.cancel?.removeEventListener("abort", onSetupCancel); releaseSetupUser(); await closeIdleRuntime(); + if (releaseError) throw releaseError; if (error === setupCancelled) return { reply: "", stopped: true }; throw error; } @@ -1178,20 +1200,28 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { const startedAt = Date.now(); const recordRequest = async (): Promise => { if (!turn.recordLlmRequest) return; + let recordTimer: NodeJS.Timeout | undefined; try { - await turn.recordLlmRequest({ - turnSeq: userEntry.seq, - step: 0, - model: selectedModel, - request: requestPayload, - truncated: Boolean(turn.images?.length), - transport: { modelId: selectedModel }, - ttftMs: state.firstOutputAt ? state.firstOutputAt - startedAt : null, - durationMs: Date.now() - startedAt, - usage: sumUsage(state.usageByThread), - }); + await Promise.race([ + turn.recordLlmRequest({ + turnSeq: userEntry.seq, + step: 0, + model: selectedModel, + request: requestPayload, + truncated: Boolean(turn.images?.length), + transport: { modelId: selectedModel }, + ttftMs: state.firstOutputAt ? state.firstOutputAt - startedAt : null, + durationMs: Date.now() - startedAt, + usage: sumUsage(state.usageByThread), + }), + new Promise((_, reject) => { + recordTimer = setTimeout(() => reject(new Error("Codex llm request recording timed out")), 5_000); + }), + ]); } catch (error) { swallow("codex: llm request record", error); + } finally { + if (recordTimer) clearTimeout(recordTimer); } }; if (turn.tape) { @@ -1231,7 +1261,8 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { }; state.interrupt = () => interrupt(false); const onCancel = () => { - void interrupt(false); + runtimeCleanupRequested = true; + void interrupt(true); }; if (turn.cancel) { if (turn.cancel.aborted) onCancel(); @@ -1260,6 +1291,7 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { const turnStartAbort = new AbortController(); let turnStartTimedOut = false; const cleanupErrors: unknown[] = []; + let turnResult: HarnessTurnResult | undefined; try { const turnStartSignals = [closeAbort.signal, turnStartAbort.signal]; if (turn.cancel) turnStartSignals.push(turn.cancel); @@ -1317,31 +1349,37 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { }); } if (result.status === "failed") throw codexProviderFailure(result.error?.message ?? "Codex turn failed"); - const terminal = ref.silentRequested || ref.pausedOnApproval; - const reply = terminal ? "" : textFromTurn(result); - for (const thinking of reasoningFromTurn(result)) - await turn.emit({ type: "thinking", payload: { thinking }, scopeLabel: turn.scopeLabel }); - if (reply && !terminal) - await turn.emit({ - type: "assistant", - payload: { text: reply, stopped: state.stopped || undefined }, - scopeLabel: turn.scopeLabel, - }); - return { - reply, - ...(state.stopped ? { stopped: true as const } : {}), - ...(ref.silentRequested ? { silent: true } : {}), - ...(ref.pendingApprovals?.length ? { pendingApprovals: ref.pendingApprovals } : {}), - ...(ref.pausedOnApproval ? { pausedOnApproval: true } : {}), - modelCalls: state.modelCalls, - ...(state.tapeWriteFailed ? { tapeWriteFailed: true } : {}), - }; + if (turn.cancel?.aborted) { + runtimeCleanupRequested = true; + turnResult = { reply: "", stopped: true }; + } else { + const terminal = ref.silentRequested || ref.pausedOnApproval; + const reply = terminal ? "" : textFromTurn(result); + for (const thinking of reasoningFromTurn(result)) + await turn.emit({ type: "thinking", payload: { thinking }, scopeLabel: turn.scopeLabel }); + if (reply && !terminal) + await turn.emit({ + type: "assistant", + payload: { text: reply, stopped: state.stopped || undefined }, + scopeLabel: turn.scopeLabel, + }); + turnResult = { + reply, + ...(state.stopped ? { stopped: true as const } : {}), + ...(ref.silentRequested ? { silent: true } : {}), + ...(ref.pendingApprovals?.length ? { pendingApprovals: ref.pendingApprovals } : {}), + ...(ref.pausedOnApproval ? { pausedOnApproval: true } : {}), + modelCalls: state.modelCalls, + ...(state.tapeWriteFailed ? { tapeWriteFailed: true } : {}), + }; + } } catch (error) { if (turn.cancel?.aborted) { runtimeCleanupRequested = true; - return { reply: "", stopped: true }; + turnResult = { reply: "", stopped: true }; + } else { + throw error; } - throw error; } finally { if (timer) clearTimeout(timer); try { @@ -1354,19 +1392,25 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { } catch (error) { cleanupErrors.push(error); } - await recordRequest(); turn.cancel?.removeEventListener("abort", onCancel); for (const [taskId, status] of state.taskStatuses) { if (status === "pending" || status === "in_progress") { - await transitionTask(opts.tasks, taskId, status, "failed", turn.runId ?? turn.session.id); + try { + await transitionTask(opts.tasks, taskId, status, "failed", turn.runId ?? turn.session.id); + } catch (error) { + swallow("codex: task cleanup", error); + } } } for (const [activeThreadId, activeState] of active) { if (activeState === state) active.delete(activeThreadId); } if (runtimeCleanupRequested) await closeIdleRuntime(); + await recordRequest(); } if (cleanupErrors.length) throw cleanupErrors[0]; + if (!turnResult) throw new Error("Codex turn did not produce a result"); + return turnResult; }; const single = async ( @@ -1429,7 +1473,6 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { active.clear(); await current.server.close(); const lock = activeAuthLock; - activeAuthLock = undefined; let persistenceFailed = false; if (lock) { if (lock.isHeld()) @@ -1439,10 +1482,13 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { lock.path, activeExpectedSourceAuth, ); - await lock.release(); - activeExpectedRefreshToken = undefined; - activeExpectedAccessToken = undefined; - activeExpectedSourceAuth = undefined; + if (!persistenceFailed) { + await lock.release(); + activeAuthLock = undefined; + activeExpectedRefreshToken = undefined; + activeExpectedAccessToken = undefined; + activeExpectedSourceAuth = undefined; + } } if (persistenceFailed) throw new Error("Codex OAuth auth persistence failed"); rmSync(current.jail, { recursive: true, force: true }); diff --git a/test/codex-harness.test.ts b/test/codex-harness.test.ts index 0ce1a174d..3bc7d9ca0 100644 --- a/test/codex-harness.test.ts +++ b/test/codex-harness.test.ts @@ -598,7 +598,7 @@ test("Codex materializes ChatGPT OAuth auth without an API-key override and pers access_token: "access-after", refresh_token: "refresh-after", account_id: "account-before", - id_token: oauthIdToken("account-before", "rotated"), + id_token: oauthIdToken("account-before"), }, }), ); @@ -608,7 +608,7 @@ test("Codex materializes ChatGPT OAuth auth without an API-key override and pers const persisted = JSON.parse(readFileSync(authFile, "utf8")) as Record; assert.equal(persisted.OPENAI_API_KEY, "ambient-api-key"); assert.equal((persisted.tokens as Record).access_token, "access-after"); - assert.equal((persisted.tokens as Record).id_token, oauthIdToken("account-before", "rotated")); + assert.equal((persisted.tokens as Record).id_token, oauthIdToken("account-before")); } finally { await lock.release(); } @@ -741,6 +741,16 @@ test("Codex diagnostics redact credential-shaped stderr", () => { assert.equal(nested.includes("nested-secret"), false); assert.equal(nested.includes("one-secret"), false); assert.equal(redactCodexDiagnostics("id_token=header.payload.signature").includes("header.payload.signature"), false); + const generic = redactCodexDiagnostics( + JSON.stringify({ + secret: "generic-secret", + password: "generic-password", + opaque: "opaque-secret-value-123456789012345678901234", + }), + ); + assert.equal(generic.includes("generic-secret"), false); + assert.equal(generic.includes("generic-password"), false); + assert.equal(generic.includes("opaque-secret-value-123456789012345678901234"), false); }); test("Codex ignores OAuth auth files that are readable by other users", (t) => { diff --git a/test/dev-cli-lib.test.ts b/test/dev-cli-lib.test.ts index 2b9e327eb..c89814f49 100644 --- a/test/dev-cli-lib.test.ts +++ b/test/dev-cli-lib.test.ts @@ -411,7 +411,12 @@ test("supervised children share the selected dev org", () => { const inputs: SpecInputs = { worktree: "/tmp/worktree", ports: slotPorts("pool1"), - baseEnv: { DEV_INSTANCE_ORG_ID: "beta", CODEX_AUTH_FILE: "/tmp/codex-auth.json" }, + baseEnv: { + DEV_INSTANCE_ORG_ID: "beta", + CODEX_AUTH_FILE: "/tmp/codex-auth.json", + HOME: "/tmp/home", + CODEX_HOME: "/tmp/home/.codex", + }, watch: false, webUiBasePath: "/", slack: { botToken: "xoxb-test", appToken: "xapp-test" }, @@ -427,7 +432,11 @@ test("supervised children share the selected dev org", () => { const specs = buildChildSpecs(inputs); assert.equal(specs.find((spec) => spec.name === "core")!.env.ORG_ID, "beta"); assert.equal(specs.find((spec) => spec.name === "core")!.env.CODEX_AUTH_FILE, "/tmp/codex-auth.json"); - for (const spec of specs.filter((spec) => spec.name !== "core")) assert.equal(spec.env.CODEX_AUTH_FILE, ""); + for (const spec of specs.filter((spec) => spec.name !== "core")) { + assert.equal(spec.env.CODEX_AUTH_FILE, ""); + assert.equal(spec.env.HOME, undefined); + assert.equal(spec.env.CODEX_HOME, undefined); + } for (const spec of specs) assert.equal(spec.env.CORE_ORG_ID, "beta"); inputs.baseEnv = {}; assert.equal(buildChildSpecs(inputs).find((spec) => spec.name === "core")!.env.ORG_ID, "acme"); From 7c23b770087cb13f49432d088f1184d30589c030 Mon Sep 17 00:00:00 2001 From: "open-swe[bot]" Date: Sun, 2 Aug 2026 23:24:06 +1000 Subject: [PATCH 15/24] fix: fail closed on OAuth cleanup errors --- src/harness/codex-app-server.ts | 8 +++++++- src/harness/codex-harness.ts | 27 ++++++++++++++++----------- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/src/harness/codex-app-server.ts b/src/harness/codex-app-server.ts index a41d4f539..c1d5add9c 100644 --- a/src/harness/codex-app-server.ts +++ b/src/harness/codex-app-server.ts @@ -19,6 +19,7 @@ type JsonRpcMessage = { }; type JsonRpcResultValidator = (value: unknown) => value is T; +const MAX_CANCELLED_REQUEST_IDS = 256; function isJsonRpcId(value: unknown): value is JsonRpcId { return (typeof value === "string" && value.length > 0) || (typeof value === "number" && Number.isFinite(value)); @@ -218,7 +219,12 @@ export class CodexAppServer { if (requestSignal) { const onAbort = () => { if (!this.pending.delete(id)) return; - this.cancelledRequestIds.add(id); + if (this.cancelledRequestIds.size >= MAX_CANCELLED_REQUEST_IDS) { + this.failAll(new CodexRpcError("Codex app-server exceeded its cancelled request limit")); + this.process.kill("SIGTERM"); + } else { + this.cancelledRequestIds.add(id); + } rejectResult(new Error("Codex app-server request cancelled")); }; requestSignal.addEventListener("abort", onAbort, { once: true }); diff --git a/src/harness/codex-harness.ts b/src/harness/codex-harness.ts index e90acf340..b19377c17 100644 --- a/src/harness/codex-harness.ts +++ b/src/harness/codex-harness.ts @@ -720,15 +720,20 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { ): boolean => { for (let attempt = 0; attempt < 3; attempt += 1) { try { - syncCodexOAuthAuthFile( - authPath, - childAuthPath, - heldLockPath, - expectedRefresh, - expectedAccess, - expectedSource, - ); - return true; + if ( + syncCodexOAuthAuthFile( + authPath, + childAuthPath, + heldLockPath, + expectedRefresh, + expectedAccess, + expectedSource, + ) + ) + return true; + if (attempt === 2) + swallow("codex: oauth auth persistence", new Error("Codex OAuth auth persistence refused")); + else Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10); } catch (error) { if (attempt === 2) swallow("codex: oauth auth persistence", error); else Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10); @@ -1398,7 +1403,7 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { try { await transitionTask(opts.tasks, taskId, status, "failed", turn.runId ?? turn.session.id); } catch (error) { - swallow("codex: task cleanup", error); + cleanupErrors.push(error); } } } @@ -1406,7 +1411,7 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { if (activeState === state) active.delete(activeThreadId); } if (runtimeCleanupRequested) await closeIdleRuntime(); - await recordRequest(); + void recordRequest(); } if (cleanupErrors.length) throw cleanupErrors[0]; if (!turnResult) throw new Error("Codex turn did not produce a result"); From 0b76b1849bc4c1572e4407703978ac4c52494bc7 Mon Sep 17 00:00:00 2001 From: "open-swe[bot]" Date: Sun, 2 Aug 2026 23:31:14 +1000 Subject: [PATCH 16/24] test: stabilize Codex OAuth cancellation coverage --- src/harness/codex-harness.ts | 2 +- test/codex-harness.test.ts | 37 +++++++++++++++++++++++++++++++++++- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/harness/codex-harness.ts b/src/harness/codex-harness.ts index b19377c17..db2aa1eed 100644 --- a/src/harness/codex-harness.ts +++ b/src/harness/codex-harness.ts @@ -1411,7 +1411,7 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { if (activeState === state) active.delete(activeThreadId); } if (runtimeCleanupRequested) await closeIdleRuntime(); - void recordRequest(); + await recordRequest(); } if (cleanupErrors.length) throw cleanupErrors[0]; if (!turnResult) throw new Error("Codex turn did not produce a result"); diff --git a/test/codex-harness.test.ts b/test/codex-harness.test.ts index 3bc7d9ca0..bf59dd1bf 100644 --- a/test/codex-harness.test.ts +++ b/test/codex-harness.test.ts @@ -1214,7 +1214,8 @@ test("cancelling an OAuth startup after spawn closes the provider", async (t) => assert.equal(existsSync(join(dir, "starts")), true); cancel.abort(); assert.deepEqual(await turn, { reply: "", stopped: true }); - await new Promise((resolve) => setTimeout(resolve, 150)); + for (let attempt = 0; attempt < 100 && !existsSync(join(dir, "closed")); attempt += 1) + await new Promise((resolve) => setTimeout(resolve, 10)); assert.equal(readFileSync(join(dir, "closed"), "utf8"), "closed"); }); @@ -1587,6 +1588,40 @@ test("Codex records one llm row per turn carrying real timings and usage, even w assert.ok(typeof records[1]!.durationMs === "number"); }); +test("Codex waits for the bounded durable llm record before completing a turn", async (t) => { + const dir = mkdtempSync(join(tmpdir(), "qm-codex-telemetry-order-test-")); + const scope = { kind: "org", id: "test" } as unknown as ScopeId; + const harness = createCodexHarness({ + binaryPath: fakeCodexBinary(dir), + env: testHarnessEnv(dir), + turnWallClockMs: 5_000, + }); + t.after(async () => { + await harness.turns.close?.(); + rmSync(dir, { recursive: true, force: true }); + }); + let recorded = false; + const startedAt = Date.now(); + const result = await harness.turns.runTurn({ + session: { id: "telemetry-order" } as Session, + input: "hi", + systemPrompt: "be concise", + history: [], + tools: {} as HarnessTurnInput["tools"], + scopeLabel: scope, + orgScopeId: scope, + emit: async (entry) => ({ ...entry, sessionId: "telemetry-order", seq: 1, createdAt: Date.now() }) as SessionEntry, + recordModelCall: () => {}, + recordLlmRequest: async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + recorded = true; + }, + }); + assert.equal(result.reply, "hello"); + assert.equal(recorded, true); + assert.ok(Date.now() - startedAt >= 45); +}); + const realCodexBinary = (() => { try { return join(dirname(createRequire(import.meta.url).resolve("@openai/codex/package.json")), "bin/codex.js"); From 2d426575eb6e4ec2cb0e07957ad8baf09e8030b5 Mon Sep 17 00:00:00 2001 From: "open-swe[bot]" Date: Sun, 2 Aug 2026 23:46:37 +1000 Subject: [PATCH 17/24] fix: bind Codex OAuth token updates to account --- src/harness/codex-auth.ts | 31 ++++++++++++++++++++-------- test/codex-harness.test.ts | 42 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 8 deletions(-) diff --git a/src/harness/codex-auth.ts b/src/harness/codex-auth.ts index b88cc78f4..23b47d4d7 100644 --- a/src/harness/codex-auth.ts +++ b/src/harness/codex-auth.ts @@ -27,14 +27,10 @@ function asObject(value: unknown): JsonObject | null { return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonObject) : null; } -function codexOAuthJwtAccountId(value: unknown): string | undefined { - const auth = asObject(value); - const tokens = auth ? asObject(auth.tokens) : null; - if (typeof tokens?.id_token !== "string") return undefined; +function codexOAuthJwtAccountIdFromToken(value: unknown): string | undefined { + if (typeof value !== "string" || value.split(".").length !== 3) return undefined; try { - const payload = asObject( - JSON.parse(Buffer.from(tokens.id_token.split(".")[1] ?? "", "base64url").toString("utf8")), - ); + const payload = asObject(JSON.parse(Buffer.from(value.split(".")[1] ?? "", "base64url").toString("utf8"))); const claims = payload ? asObject(payload["https://api.openai.com/auth"]) : null; return typeof claims?.chatgpt_account_id === "string" && claims.chatgpt_account_id ? claims.chatgpt_account_id @@ -44,6 +40,12 @@ function codexOAuthJwtAccountId(value: unknown): string | undefined { } } +function codexOAuthJwtAccountId(value: unknown): string | undefined { + const auth = asObject(value); + const tokens = auth ? asObject(auth.tokens) : null; + return codexOAuthJwtAccountIdFromToken(tokens?.id_token); +} + function readJsonFile(path: string): JsonObject | null { try { return asObject(JSON.parse(readFileSync(path, "utf8"))); @@ -322,6 +324,13 @@ export function syncCodexOAuthAuthFile( const childTokens = asObject(child.tokens); const sourceAccountId = codexOAuthJwtAccountId(source); const childAccountId = codexOAuthJwtAccountId(child); + const sourceDeclaredAccountId = + typeof sourceTokens?.account_id === "string" ? sourceTokens.account_id : undefined; + const childDeclaredAccountId = typeof childTokens?.account_id === "string" ? childTokens.account_id : undefined; + const sourceAccessToken = typeof sourceTokens?.access_token === "string" ? sourceTokens.access_token : undefined; + const childAccessToken = typeof childTokens?.access_token === "string" ? childTokens.access_token : undefined; + const sourceAccessAccountId = codexOAuthJwtAccountIdFromToken(sourceAccessToken); + const childAccessAccountId = codexOAuthJwtAccountIdFromToken(childAccessToken); if ( !sourceTokens || !childTokens || @@ -329,7 +338,13 @@ export function syncCodexOAuthAuthFile( typeof childTokens.id_token !== "string" || sourceTokens.id_token !== childTokens.id_token || !sourceAccountId || - sourceAccountId !== childAccountId + sourceAccountId !== childAccountId || + (sourceDeclaredAccountId && sourceDeclaredAccountId !== sourceAccountId) || + (childDeclaredAccountId && childDeclaredAccountId !== sourceAccountId) || + (sourceAccessAccountId && sourceAccessAccountId !== sourceAccountId) || + (childAccessAccountId && childAccessAccountId !== sourceAccountId) || + (sourceAccessToken?.split(".").length === 3 && sourceAccessAccountId !== sourceAccountId) || + (childAccessToken?.split(".").length === 3 && childAccessAccountId !== sourceAccountId) ) return false; if (expectedSourceAuth && JSON.stringify(source) !== JSON.stringify(expectedSourceAuth)) return false; diff --git a/test/codex-harness.test.ts b/test/codex-harness.test.ts index bf59dd1bf..1c0ec86ab 100644 --- a/test/codex-harness.test.ts +++ b/test/codex-harness.test.ts @@ -57,6 +57,10 @@ function oauthIdToken(accountId: string, marker = ""): string { return `header.${payload}.signature`; } +function oauthAccessToken(accountId: string): string { + return oauthIdToken(accountId, "access"); +} + test("Codex replay keeps paired tool ids within the provider's 64-character limit", () => { const longId = "tool-call-".repeat(9); const normalized = codexReplayCallId(longId); @@ -826,6 +830,44 @@ test("Codex does not persist OAuth refreshes without a trusted account claim", ( ); }); +test("Codex does not persist OAuth tokens for a different declared or access-token account", (t) => { + const dir = mkdtempSync(join(tmpdir(), "qm-codex-oauth-unbound-token-test-")); + const source = join(dir, "source.json"); + const child = join(dir, "child.json"); + t.after(() => rmSync(dir, { recursive: true, force: true })); + writeFileSync( + source, + JSON.stringify({ + auth_mode: "chatgpt", + tokens: { + access_token: oauthAccessToken("same"), + refresh_token: "source-refresh", + account_id: "same", + id_token: oauthIdToken("same"), + }, + }), + { mode: 0o600 }, + ); + writeFileSync( + child, + JSON.stringify({ + auth_mode: "chatgpt", + tokens: { + access_token: oauthAccessToken("different"), + refresh_token: "child-refresh", + account_id: "different", + id_token: oauthIdToken("same"), + }, + }), + { mode: 0o600 }, + ); + assert.equal(syncCodexOAuthAuthFile(source, child), false); + assert.equal( + (JSON.parse(readFileSync(source, "utf8")).tokens as Record).refresh_token, + "source-refresh", + ); +}); + test("Codex diagnostics redact malformed app-server output at the protocol boundary", async (t) => { const dir = mkdtempSync(join(tmpdir(), "qm-codex-malformed-test-")); const server = new CodexAppServer({ From cee71d44beea47edfc30b27fff992c11147ed593 Mon Sep 17 00:00:00 2001 From: "open-swe[bot]" Date: Sun, 2 Aug 2026 23:58:00 +1000 Subject: [PATCH 18/24] fix: reject unverified Codex OAuth token rotation --- src/harness/codex-auth.ts | 12 ++-- test/codex-harness.test.ts | 112 +++++++++++++++++++++++++++++++------ 2 files changed, 104 insertions(+), 20 deletions(-) diff --git a/src/harness/codex-auth.ts b/src/harness/codex-auth.ts index 23b47d4d7..508abd3fe 100644 --- a/src/harness/codex-auth.ts +++ b/src/harness/codex-auth.ts @@ -341,15 +341,17 @@ export function syncCodexOAuthAuthFile( sourceAccountId !== childAccountId || (sourceDeclaredAccountId && sourceDeclaredAccountId !== sourceAccountId) || (childDeclaredAccountId && childDeclaredAccountId !== sourceAccountId) || - (sourceAccessAccountId && sourceAccessAccountId !== sourceAccountId) || - (childAccessAccountId && childAccessAccountId !== sourceAccountId) || - (sourceAccessToken?.split(".").length === 3 && sourceAccessAccountId !== sourceAccountId) || - (childAccessToken?.split(".").length === 3 && childAccessAccountId !== sourceAccountId) + (sourceAccessToken?.split(".").length === 3 && sourceAccessAccountId !== sourceAccountId) ) return false; if (expectedSourceAuth && JSON.stringify(source) !== JSON.stringify(expectedSourceAuth)) return false; if (expectedRefreshToken && codexOAuthRefreshToken(source) !== expectedRefreshToken) return false; if (expectedAccessToken && codexOAuthAccessToken(source) !== expectedAccessToken) return false; + const sourceRefreshToken = codexOAuthRefreshToken(source); + const childRefreshToken = codexOAuthRefreshToken(child); + const childAccessTokenBound = + childAccessToken === sourceAccessToken || + (childAccessToken?.split(".").length === 3 && childAccessAccountId === sourceAccountId); const sanitized = sanitizedCodexOAuthAuth(child); const next = { ...source, @@ -359,6 +361,8 @@ export function syncCodexOAuthAuthFile( tokens: { ...sourceTokens, ...childTokens, + access_token: childAccessTokenBound ? childAccessToken : sourceAccessToken, + refresh_token: childRefreshToken === sourceRefreshToken ? childRefreshToken : sourceRefreshToken, ...(typeof sourceTokens.account_id === "string" ? { account_id: sourceTokens.account_id } : {}), }, } diff --git a/test/codex-harness.test.ts b/test/codex-harness.test.ts index 1c0ec86ab..b74b470bb 100644 --- a/test/codex-harness.test.ts +++ b/test/codex-harness.test.ts @@ -57,8 +57,8 @@ function oauthIdToken(accountId: string, marker = ""): string { return `header.${payload}.signature`; } -function oauthAccessToken(accountId: string): string { - return oauthIdToken(accountId, "access"); +function oauthAccessToken(accountId: string, marker = "access"): string { + return oauthIdToken(accountId, marker); } test("Codex replay keeps paired tool ids within the provider's 64-character limit", () => { @@ -238,6 +238,7 @@ process.on("SIGTERM", () => { function refreshThenNonresponsiveCodexBinary(dir: string): string { const path = join(dir, "refresh-then-nonresponsive-codex"); + const accessToken = oauthAccessToken("startup-account", "startup-after"); writeFileSync( path, `#!${process.execPath} @@ -245,8 +246,7 @@ const fs = require("node:fs"); const path = require("node:path"); const authPath = path.join(process.env.CODEX_HOME, "auth.json"); const auth = JSON.parse(fs.readFileSync(authPath, "utf8")); -auth.tokens.access_token = "startup-access-after"; -auth.tokens.refresh_token = "startup-refresh-after"; +auth.tokens.access_token = ${JSON.stringify(accessToken)}; fs.writeFileSync(authPath, JSON.stringify(auth)); process.stdin.resume(); `, @@ -360,6 +360,7 @@ rl.on("line", line => { function oauthTurnBinary(dir: string, token: string, delayMs: number): string { const path = join(dir, `oauth-${token}`); const events = join(dir, "oauth-events"); + const accessToken = oauthAccessToken("shared-account", token); writeFileSync( path, `#!${process.execPath} @@ -376,7 +377,7 @@ rl.on("line", (line) => { if (msg.method === "thread/start") return send({ id: msg.id, result: { thread: { id: "thread-${token}" } } }); if (msg.method === "turn/start") { const auth = JSON.parse(fs.readFileSync(authPath, "utf8")); - auth.tokens.access_token = ${JSON.stringify(token)}; + auth.tokens.access_token = ${JSON.stringify(accessToken)}; fs.writeFileSync(authPath, JSON.stringify(auth)); fs.appendFileSync(${JSON.stringify(events)}, ${JSON.stringify(`${token}\n`)}); send({ id: msg.id, result: { turn: { id: "turn-${token}", status: "inProgress", items: [] } } }); @@ -556,7 +557,7 @@ test("Codex materializes API-key auth into its isolated home, and never an ambie ); }); -test("Codex materializes ChatGPT OAuth auth without an API-key override and persists refreshes", async (t) => { +test("Codex materializes ChatGPT OAuth auth without an API-key override and persists bound access updates", async (t) => { const source = mkdtempSync(join(tmpdir(), "qm-codex-oauth-source-")); const jail = mkdtempSync(join(tmpdir(), "qm-codex-oauth-jail-")); t.after(() => { @@ -570,7 +571,7 @@ test("Codex materializes ChatGPT OAuth auth without an API-key override and pers auth_mode: "chatgpt", OPENAI_API_KEY: "ambient-api-key", tokens: { - access_token: "access-before", + access_token: oauthAccessToken("account-before", "before"), refresh_token: "refresh-before", account_id: "account-before", id_token: oauthIdToken("account-before"), @@ -592,14 +593,17 @@ test("Codex materializes ChatGPT OAuth auth without an API-key override and pers const childAuthFile = join(home, "auth.json"); const childAuth = JSON.parse(readFileSync(childAuthFile, "utf8")) as Record; assert.equal(childAuth.OPENAI_API_KEY, undefined); - assert.equal((childAuth.tokens as Record).access_token, "access-before"); + assert.equal( + (childAuth.tokens as Record).access_token, + oauthAccessToken("account-before", "before"), + ); assert.equal((childAuth.tokens as Record).account_id, "account-before"); writeFileSync( childAuthFile, JSON.stringify({ ...childAuth, tokens: { - access_token: "access-after", + access_token: oauthAccessToken("account-before", "after"), refresh_token: "refresh-after", account_id: "account-before", id_token: oauthIdToken("account-before"), @@ -611,7 +615,11 @@ test("Codex materializes ChatGPT OAuth auth without an API-key override and pers syncCodexOAuthAuthFile(authFile, childAuthFile, lock.path); const persisted = JSON.parse(readFileSync(authFile, "utf8")) as Record; assert.equal(persisted.OPENAI_API_KEY, "ambient-api-key"); - assert.equal((persisted.tokens as Record).access_token, "access-after"); + assert.equal( + (persisted.tokens as Record).access_token, + oauthAccessToken("account-before", "after"), + ); + assert.equal((persisted.tokens as Record).refresh_token, "refresh-before"); assert.equal((persisted.tokens as Record).id_token, oauthIdToken("account-before")); } finally { await lock.release(); @@ -830,7 +838,7 @@ test("Codex does not persist OAuth refreshes without a trusted account claim", ( ); }); -test("Codex does not persist OAuth tokens for a different declared or access-token account", (t) => { +test("Codex does not persist OAuth tokens for a different declared account", (t) => { const dir = mkdtempSync(join(tmpdir(), "qm-codex-oauth-unbound-token-test-")); const source = join(dir, "source.json"); const child = join(dir, "child.json"); @@ -840,7 +848,7 @@ test("Codex does not persist OAuth tokens for a different declared or access-tok JSON.stringify({ auth_mode: "chatgpt", tokens: { - access_token: oauthAccessToken("same"), + access_token: "source-access", refresh_token: "source-refresh", account_id: "same", id_token: oauthIdToken("same"), @@ -853,7 +861,7 @@ test("Codex does not persist OAuth tokens for a different declared or access-tok JSON.stringify({ auth_mode: "chatgpt", tokens: { - access_token: oauthAccessToken("different"), + access_token: "source-access", refresh_token: "child-refresh", account_id: "different", id_token: oauthIdToken("same"), @@ -868,6 +876,75 @@ test("Codex does not persist OAuth tokens for a different declared or access-tok ); }); +test("Codex does not persist an access token for a different access-token account", (t) => { + const dir = mkdtempSync(join(tmpdir(), "qm-codex-oauth-unbound-access-test-")); + const source = join(dir, "source.json"); + const child = join(dir, "child.json"); + t.after(() => rmSync(dir, { recursive: true, force: true })); + writeFileSync( + source, + JSON.stringify({ + auth_mode: "chatgpt", + tokens: { + access_token: oauthAccessToken("same"), + refresh_token: "source-refresh", + id_token: oauthIdToken("same"), + }, + }), + { mode: 0o600 }, + ); + writeFileSync( + child, + JSON.stringify({ + auth_mode: "chatgpt", + tokens: { + access_token: oauthAccessToken("different"), + refresh_token: "child-refresh", + id_token: oauthIdToken("same"), + }, + }), + { mode: 0o600 }, + ); + assert.equal(syncCodexOAuthAuthFile(source, child), true); + const persisted = JSON.parse(readFileSync(source, "utf8")).tokens as Record; + assert.equal(persisted.access_token, oauthAccessToken("same")); + assert.equal(persisted.refresh_token, "source-refresh"); +}); + +test("Codex does not persist an opaque refresh-token replacement", (t) => { + const dir = mkdtempSync(join(tmpdir(), "qm-codex-oauth-unbound-refresh-token-test-")); + const source = join(dir, "source.json"); + const child = join(dir, "child.json"); + t.after(() => rmSync(dir, { recursive: true, force: true })); + writeFileSync( + source, + JSON.stringify({ + auth_mode: "chatgpt", + tokens: { + access_token: oauthAccessToken("same"), + refresh_token: "source-refresh", + id_token: oauthIdToken("same"), + }, + }), + { mode: 0o600 }, + ); + writeFileSync( + child, + JSON.stringify({ + auth_mode: "chatgpt", + tokens: { + access_token: oauthAccessToken("same"), + refresh_token: "child-refresh", + id_token: oauthIdToken("same"), + }, + }), + { mode: 0o600 }, + ); + assert.equal(syncCodexOAuthAuthFile(source, child), true); + const persisted = JSON.parse(readFileSync(source, "utf8")).tokens as Record; + assert.equal(persisted.refresh_token, "source-refresh"); +}); + test("Codex diagnostics redact malformed app-server output at the protocol boundary", async (t) => { const dir = mkdtempSync(join(tmpdir(), "qm-codex-malformed-test-")); const server = new CodexAppServer({ @@ -1116,7 +1193,7 @@ test("Codex discards a nonresponsive startup so a later turn can retry", async ( assert.equal(readFileSync(join(dir, "starts"), "utf8"), "start\nstart\n"); }); -test("Codex persists an OAuth refresh before discarding a failed startup", async (t) => { +test("Codex persists a bound OAuth access update before discarding a failed startup", async (t) => { const dir = mkdtempSync(join(tmpdir(), "qm-codex-startup-oauth-test-")); const authFile = join(dir, "auth.json"); writeFileSync( @@ -1158,7 +1235,10 @@ test("Codex persists an OAuth refresh before discarding a failed startup", async (error: unknown) => /timed out|exited|closed/i.test(error instanceof Error ? error.message : String(error)), ); const persisted = JSON.parse(readFileSync(authFile, "utf8")) as Record; - assert.equal((persisted.tokens as Record).access_token, "startup-access-after"); + assert.equal( + (persisted.tokens as Record).access_token, + oauthAccessToken("startup-account", "startup-after"), + ); }); test("cancelling an OAuth startup lock wait prevents the provider from starting", async (t) => { @@ -1350,7 +1430,7 @@ test("OAuth turns serialize shared auth ownership and cancel a waiting contender assert.equal((await firstTurn).reply, "first"); assert.equal(readFileSync(join(dir, "oauth-events"), "utf8"), "first\n"); const persisted = JSON.parse(readFileSync(authFile, "utf8")) as Record; - assert.equal((persisted.tokens as Record).access_token, "first"); + assert.equal((persisted.tokens as Record).access_token, oauthAccessToken("shared-account", "first")); }); test("Codex fails closed when OAuth auth is removed after startup", async (t) => { From 978915a56437061b89b88ca9744bb61962d7ae29 Mon Sep 17 00:00:00 2001 From: "open-swe[bot]" Date: Mon, 3 Aug 2026 00:06:36 +1000 Subject: [PATCH 19/24] fix: verify rotated Codex OAuth JWTs --- src/harness/codex-auth.ts | 57 +++++++++++++++++++++++++++++++----- src/harness/codex-harness.ts | 36 +++++++++++++---------- test/codex-harness.test.ts | 35 ++++++++++------------ 3 files changed, 86 insertions(+), 42 deletions(-) diff --git a/src/harness/codex-auth.ts b/src/harness/codex-auth.ts index 508abd3fe..83964d211 100644 --- a/src/harness/codex-auth.ts +++ b/src/harness/codex-auth.ts @@ -16,12 +16,15 @@ import { } from "node:fs"; import { homedir } from "node:os"; import { dirname, join, resolve } from "node:path"; +import { createRemoteJWKSet, jwtVerify } from "jose"; import { swallow } from "../util/errors.ts"; type JsonObject = Record; const CODEX_OAUTH_MODES = new Set(["chatgpt", "chatgptAuthTokens"]); const heldOAuthLockPaths = new Set(); +const CODEX_OAUTH_ISSUER = "https://auth.openai.com"; +const CODEX_OAUTH_JWKS = createRemoteJWKSet(new URL(`${CODEX_OAUTH_ISSUER}/.well-known/jwks.json`)); function asObject(value: unknown): JsonObject | null { return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonObject) : null; @@ -40,12 +43,38 @@ function codexOAuthJwtAccountIdFromToken(value: unknown): string | undefined { } } +function isCodexOAuthJwt(value: unknown): boolean { + if (typeof value !== "string" || value.split(".").length !== 3) return false; + try { + const header = asObject(JSON.parse(Buffer.from(value.split(".")[0] ?? "", "base64url").toString("utf8"))); + const payload = asObject(JSON.parse(Buffer.from(value.split(".")[1] ?? "", "base64url").toString("utf8"))); + return header?.alg === "RS256" && payload?.iss === CODEX_OAUTH_ISSUER; + } catch { + return false; + } +} + function codexOAuthJwtAccountId(value: unknown): string | undefined { const auth = asObject(value); const tokens = auth ? asObject(auth.tokens) : null; return codexOAuthJwtAccountIdFromToken(tokens?.id_token); } +async function verifiedCodexOAuthJwtAccountId(token: string): Promise { + try { + const { payload } = await jwtVerify(token, CODEX_OAUTH_JWKS, { + issuer: CODEX_OAUTH_ISSUER, + algorithms: ["RS256"], + }); + const claims = asObject(payload["https://api.openai.com/auth"]); + return typeof claims?.chatgpt_account_id === "string" && claims.chatgpt_account_id + ? claims.chatgpt_account_id + : undefined; + } catch { + return undefined; + } +} + function readJsonFile(path: string): JsonObject | null { try { return asObject(JSON.parse(readFileSync(path, "utf8"))); @@ -301,14 +330,14 @@ function lockFile(sourcePath: string): SyncLock { throw new Error("timed out acquiring the Codex OAuth auth lock"); } -export function syncCodexOAuthAuthFile( +export async function syncCodexOAuthAuthFile( sourcePath: string | undefined, childPath: string, heldLockPath?: string, expectedRefreshToken?: string, expectedAccessToken?: string, expectedSourceAuth?: JsonObject, -): boolean { +): Promise { if (!sourcePath) return true; const child = readCodexOAuthAuthFile(childPath); if (!child) return false; @@ -316,7 +345,7 @@ export function syncCodexOAuthAuthFile( let result: boolean; let cleanupError: unknown; try { - result = (() => { + result = await (async () => { const source = readJsonFile(sourcePath); if (!source) return false; if (source.auth_mode !== child.auth_mode) return false; @@ -336,12 +365,11 @@ export function syncCodexOAuthAuthFile( !childTokens || typeof sourceTokens.id_token !== "string" || typeof childTokens.id_token !== "string" || - sourceTokens.id_token !== childTokens.id_token || !sourceAccountId || sourceAccountId !== childAccountId || (sourceDeclaredAccountId && sourceDeclaredAccountId !== sourceAccountId) || (childDeclaredAccountId && childDeclaredAccountId !== sourceAccountId) || - (sourceAccessToken?.split(".").length === 3 && sourceAccessAccountId !== sourceAccountId) + (isCodexOAuthJwt(sourceAccessToken) && sourceAccessAccountId !== sourceAccountId) ) return false; if (expectedSourceAuth && JSON.stringify(source) !== JSON.stringify(expectedSourceAuth)) return false; @@ -349,10 +377,22 @@ export function syncCodexOAuthAuthFile( if (expectedAccessToken && codexOAuthAccessToken(source) !== expectedAccessToken) return false; const sourceRefreshToken = codexOAuthRefreshToken(source); const childRefreshToken = codexOAuthRefreshToken(child); + const refreshTokenChanged = childRefreshToken !== sourceRefreshToken; + const childIdTokenChanged = childTokens.id_token !== sourceTokens.id_token; + const verifiedChildIdAccount = childIdTokenChanged + ? await verifiedCodexOAuthJwtAccountId(childTokens.id_token) + : sourceAccountId; + if (childIdTokenChanged && verifiedChildIdAccount !== sourceAccountId) return false; + const childAccessTokenChanged = childAccessToken !== sourceAccessToken; + const verifiedChildAccessAccount = + childAccessTokenChanged && childAccessToken && isCodexOAuthJwt(childAccessToken) + ? await verifiedCodexOAuthJwtAccountId(childAccessToken) + : childAccessAccountId; const childAccessTokenBound = childAccessToken === sourceAccessToken || - (childAccessToken?.split(".").length === 3 && childAccessAccountId === sourceAccountId); + (isCodexOAuthJwt(childAccessToken) && verifiedChildAccessAccount === sourceAccountId); const sanitized = sanitizedCodexOAuthAuth(child); + const persistChildTokens = !refreshTokenChanged; const next = { ...source, ...sanitized, @@ -361,8 +401,9 @@ export function syncCodexOAuthAuthFile( tokens: { ...sourceTokens, ...childTokens, - access_token: childAccessTokenBound ? childAccessToken : sourceAccessToken, - refresh_token: childRefreshToken === sourceRefreshToken ? childRefreshToken : sourceRefreshToken, + access_token: persistChildTokens && childAccessTokenBound ? childAccessToken : sourceAccessToken, + refresh_token: sourceRefreshToken, + id_token: persistChildTokens ? childTokens.id_token : sourceTokens.id_token, ...(typeof sourceTokens.account_id === "string" ? { account_id: sourceTokens.account_id } : {}), }, } diff --git a/src/harness/codex-harness.ts b/src/harness/codex-harness.ts index db2aa1eed..62be76485 100644 --- a/src/harness/codex-harness.ts +++ b/src/harness/codex-harness.ts @@ -170,7 +170,7 @@ type Runtime = { expectedAccessToken?: string, heldLockPath?: string, expectedSourceAuth?: Record, - ): boolean; + ): Promise; }; type StartingRuntime = { promise: Promise; @@ -537,12 +537,12 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { const lock = activeAuthLock; if (lock) { if (lock.isHeld()) - persistenceFailed = !stale.persistAuth( + persistenceFailed = !(await stale.persistAuth( activeExpectedRefreshToken, activeExpectedAccessToken, lock.path, activeExpectedSourceAuth, - ); + )); if (activeAuthLock === lock) { activeAuthLock = undefined; activeExpectedRefreshToken = undefined; @@ -692,7 +692,7 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { let persistenceFailed = false; if (authLock) { try { - syncCodexOAuthAuthFile( + await syncCodexOAuthAuthFile( authPath, join(jail, "codex-home", "auth.json"), authLock.path, @@ -712,16 +712,16 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { throw error; } const childAuthPath = join(jail, "codex-home", "auth.json"); - const persistAuth = ( + const persistAuth = async ( expectedRefresh = expectedRefreshToken, expectedAccess = expectedAccessToken, heldLockPath?: string, expectedSource = expectedSourceAuth, - ): boolean => { + ): Promise => { for (let attempt = 0; attempt < 3; attempt += 1) { try { if ( - syncCodexOAuthAuthFile( + await syncCodexOAuthAuthFile( authPath, childAuthPath, heldLockPath, @@ -758,13 +758,16 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { ); }), ]); - if (authLock && !persistAuth(expectedRefreshToken, expectedAccessToken, authLock.path, expectedSourceAuth)) + if ( + authLock && + !(await persistAuth(expectedRefreshToken, expectedAccessToken, authLock.path, expectedSourceAuth)) + ) throw new Error("Codex OAuth auth persistence failed"); await authLock?.release(); authLock = undefined; } catch (error) { const persistenceFailed = authLock - ? !persistAuth(expectedRefreshToken, expectedAccessToken, authLock.path, expectedSourceAuth) + ? !(await persistAuth(expectedRefreshToken, expectedAccessToken, authLock.path, expectedSourceAuth)) : false; await server.close().catch(() => undefined); await authLock?.release(); @@ -777,17 +780,17 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { } runtime = { server, jail, persistAuth }; runtimeCleanupRequested = false; - server.process.once("close", () => { + server.process.once("close", async () => { const currentRuntime = runtime?.server === server; let persistenceFailed = false; const lock = currentRuntime ? activeAuthLock : undefined; if (lock?.isHeld()) - persistenceFailed = !persistAuth( + persistenceFailed = !(await persistAuth( activeExpectedRefreshToken, activeExpectedAccessToken, lock.path, activeExpectedSourceAuth, - ); + )); const closeError = persistenceFailed ? new Error("Codex OAuth auth persistence failed", { cause: server.error() }) : (server.error() ?? new Error("Codex app-server exited during a turn")); @@ -921,7 +924,10 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { const lock = turnAuthLock; if (!lock) return; try { - if (lock.isHeld() && !rt.persistAuth(expectedRefreshToken, expectedAccessToken, lock.path, expectedSourceAuth)) + if ( + lock.isHeld() && + !(await rt.persistAuth(expectedRefreshToken, expectedAccessToken, lock.path, expectedSourceAuth)) + ) throw new NonRetryableTurnError("Codex OAuth auth persistence failed"); } finally { if (activeAuthLock === lock) { @@ -1481,12 +1487,12 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { let persistenceFailed = false; if (lock) { if (lock.isHeld()) - persistenceFailed = !current.persistAuth( + persistenceFailed = !(await current.persistAuth( activeExpectedRefreshToken, activeExpectedAccessToken, lock.path, activeExpectedSourceAuth, - ); + )); if (!persistenceFailed) { await lock.release(); activeAuthLock = undefined; diff --git a/test/codex-harness.test.ts b/test/codex-harness.test.ts index b74b470bb..ad09c787f 100644 --- a/test/codex-harness.test.ts +++ b/test/codex-harness.test.ts @@ -557,7 +557,7 @@ test("Codex materializes API-key auth into its isolated home, and never an ambie ); }); -test("Codex materializes ChatGPT OAuth auth without an API-key override and persists bound access updates", async (t) => { +test("Codex materializes ChatGPT OAuth auth without an API-key override and preserves unverified replacements", async (t) => { const source = mkdtempSync(join(tmpdir(), "qm-codex-oauth-source-")); const jail = mkdtempSync(join(tmpdir(), "qm-codex-oauth-jail-")); t.after(() => { @@ -612,12 +612,12 @@ test("Codex materializes ChatGPT OAuth auth without an API-key override and pers ); const lock = await acquireCodexOAuthAuthLock(authFile); try { - syncCodexOAuthAuthFile(authFile, childAuthFile, lock.path); + await syncCodexOAuthAuthFile(authFile, childAuthFile, lock.path); const persisted = JSON.parse(readFileSync(authFile, "utf8")) as Record; assert.equal(persisted.OPENAI_API_KEY, "ambient-api-key"); assert.equal( (persisted.tokens as Record).access_token, - oauthAccessToken("account-before", "after"), + oauthAccessToken("account-before", "before"), ); assert.equal((persisted.tokens as Record).refresh_token, "refresh-before"); assert.equal((persisted.tokens as Record).id_token, oauthIdToken("account-before")); @@ -648,7 +648,7 @@ test("Codex materializes ChatGPT OAuth auth without an API-key override and pers }, }), ); - syncCodexOAuthAuthFile(authFile, childAuthFile, undefined, "refresh-before"); + await syncCodexOAuthAuthFile(authFile, childAuthFile, undefined, "refresh-before"); const latest = JSON.parse(readFileSync(authFile, "utf8")) as Record; assert.equal((latest.tokens as Record).access_token, "access-latest"); writeFileSync( @@ -675,7 +675,7 @@ test("Codex materializes ChatGPT OAuth auth without an API-key override and pers }, }), ); - syncCodexOAuthAuthFile(authFile, childAuthFile, undefined, "refresh-stable", "access-before"); + await syncCodexOAuthAuthFile(authFile, childAuthFile, undefined, "refresh-stable", "access-before"); const stable = JSON.parse(readFileSync(authFile, "utf8")) as Record; assert.equal((stable.tokens as Record).access_token, "access-newest"); const liveLock = `${authFile}.lock`; @@ -810,7 +810,7 @@ test("Codex rejects OAuth auth files without a trusted account claim", (t) => { assert.equal(readCodexOAuthAuthFile(authFile), null); }); -test("Codex does not persist OAuth refreshes without a trusted account claim", (t) => { +test("Codex does not persist OAuth refreshes without a trusted account claim", async (t) => { const dir = mkdtempSync(join(tmpdir(), "qm-codex-oauth-unbound-refresh-test-")); const source = join(dir, "source.json"); const child = join(dir, "child.json"); @@ -831,14 +831,14 @@ test("Codex does not persist OAuth refreshes without a trusted account claim", ( }), { mode: 0o600 }, ); - syncCodexOAuthAuthFile(source, child); + await syncCodexOAuthAuthFile(source, child); assert.equal( (JSON.parse(readFileSync(source, "utf8")).tokens as Record).access_token, "source-access", ); }); -test("Codex does not persist OAuth tokens for a different declared account", (t) => { +test("Codex does not persist OAuth tokens for a different declared account", async (t) => { const dir = mkdtempSync(join(tmpdir(), "qm-codex-oauth-unbound-token-test-")); const source = join(dir, "source.json"); const child = join(dir, "child.json"); @@ -869,14 +869,14 @@ test("Codex does not persist OAuth tokens for a different declared account", (t) }), { mode: 0o600 }, ); - assert.equal(syncCodexOAuthAuthFile(source, child), false); + assert.equal(await syncCodexOAuthAuthFile(source, child), false); assert.equal( (JSON.parse(readFileSync(source, "utf8")).tokens as Record).refresh_token, "source-refresh", ); }); -test("Codex does not persist an access token for a different access-token account", (t) => { +test("Codex does not persist an access token for a different access-token account", async (t) => { const dir = mkdtempSync(join(tmpdir(), "qm-codex-oauth-unbound-access-test-")); const source = join(dir, "source.json"); const child = join(dir, "child.json"); @@ -905,13 +905,13 @@ test("Codex does not persist an access token for a different access-token accoun }), { mode: 0o600 }, ); - assert.equal(syncCodexOAuthAuthFile(source, child), true); + assert.equal(await syncCodexOAuthAuthFile(source, child), true); const persisted = JSON.parse(readFileSync(source, "utf8")).tokens as Record; assert.equal(persisted.access_token, oauthAccessToken("same")); assert.equal(persisted.refresh_token, "source-refresh"); }); -test("Codex does not persist an opaque refresh-token replacement", (t) => { +test("Codex does not persist an opaque refresh-token replacement", async (t) => { const dir = mkdtempSync(join(tmpdir(), "qm-codex-oauth-unbound-refresh-token-test-")); const source = join(dir, "source.json"); const child = join(dir, "child.json"); @@ -940,7 +940,7 @@ test("Codex does not persist an opaque refresh-token replacement", (t) => { }), { mode: 0o600 }, ); - assert.equal(syncCodexOAuthAuthFile(source, child), true); + assert.equal(await syncCodexOAuthAuthFile(source, child), true); const persisted = JSON.parse(readFileSync(source, "utf8")).tokens as Record; assert.equal(persisted.refresh_token, "source-refresh"); }); @@ -1193,7 +1193,7 @@ test("Codex discards a nonresponsive startup so a later turn can retry", async ( assert.equal(readFileSync(join(dir, "starts"), "utf8"), "start\nstart\n"); }); -test("Codex persists a bound OAuth access update before discarding a failed startup", async (t) => { +test("Codex preserves OAuth auth before discarding a failed startup", async (t) => { const dir = mkdtempSync(join(tmpdir(), "qm-codex-startup-oauth-test-")); const authFile = join(dir, "auth.json"); writeFileSync( @@ -1235,10 +1235,7 @@ test("Codex persists a bound OAuth access update before discarding a failed star (error: unknown) => /timed out|exited|closed/i.test(error instanceof Error ? error.message : String(error)), ); const persisted = JSON.parse(readFileSync(authFile, "utf8")) as Record; - assert.equal( - (persisted.tokens as Record).access_token, - oauthAccessToken("startup-account", "startup-after"), - ); + assert.equal((persisted.tokens as Record).access_token, "startup-access-before"); }); test("cancelling an OAuth startup lock wait prevents the provider from starting", async (t) => { @@ -1430,7 +1427,7 @@ test("OAuth turns serialize shared auth ownership and cancel a waiting contender assert.equal((await firstTurn).reply, "first"); assert.equal(readFileSync(join(dir, "oauth-events"), "utf8"), "first\n"); const persisted = JSON.parse(readFileSync(authFile, "utf8")) as Record; - assert.equal((persisted.tokens as Record).access_token, oauthAccessToken("shared-account", "first")); + assert.equal((persisted.tokens as Record).access_token, "shared-access"); }); test("Codex fails closed when OAuth auth is removed after startup", async (t) => { From 55990057cd980ce815745169a413bfc7f9ad3851 Mon Sep 17 00:00:00 2001 From: "open-swe[bot]" Date: Mon, 3 Aug 2026 00:11:31 +1000 Subject: [PATCH 20/24] fix: clean up Codex OAuth state on close failure --- src/harness/codex-harness.ts | 35 ++++++++++++++++++++++------------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/src/harness/codex-harness.ts b/src/harness/codex-harness.ts index 62be76485..2c816372d 100644 --- a/src/harness/codex-harness.ts +++ b/src/harness/codex-harness.ts @@ -1486,24 +1486,33 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { const lock = activeAuthLock; let persistenceFailed = false; if (lock) { - if (lock.isHeld()) - persistenceFailed = !(await current.persistAuth( - activeExpectedRefreshToken, - activeExpectedAccessToken, - lock.path, - activeExpectedSourceAuth, - )); - if (!persistenceFailed) { + try { + if ( + lock.isHeld() && + !(await current.persistAuth( + activeExpectedRefreshToken, + activeExpectedAccessToken, + lock.path, + activeExpectedSourceAuth, + )) + ) + persistenceFailed = true; + } catch { + persistenceFailed = true; + } + try { await lock.release(); - activeAuthLock = undefined; - activeExpectedRefreshToken = undefined; - activeExpectedAccessToken = undefined; - activeExpectedSourceAuth = undefined; + } catch { + persistenceFailed = true; } + activeAuthLock = undefined; + activeExpectedRefreshToken = undefined; + activeExpectedAccessToken = undefined; + activeExpectedSourceAuth = undefined; } - if (persistenceFailed) throw new Error("Codex OAuth auth persistence failed"); rmSync(current.jail, { recursive: true, force: true }); if (runtime === current) runtime = null; + if (persistenceFailed) throw new Error("Codex OAuth auth persistence failed"); } }, resetSession: () => {}, From 05f631a188373ed7496cd8c9805b8738d2ccb424 Mon Sep 17 00:00:00 2001 From: "open-swe[bot]" Date: Mon, 3 Aug 2026 00:15:50 +1000 Subject: [PATCH 21/24] fix: allow Codex OAuth lock recovery after release errors --- src/harness/codex-auth.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/harness/codex-auth.ts b/src/harness/codex-auth.ts index 83964d211..8507631bb 100644 --- a/src/harness/codex-auth.ts +++ b/src/harness/codex-auth.ts @@ -272,8 +272,10 @@ export async function acquireCodexOAuthAuthLock( heldOAuthLockPaths.delete(path); return; } catch (error) { - if (attempt === 2) throw error; - else await new Promise((resolveWait) => setTimeout(resolveWait, 10)); + if (attempt === 2) { + heldOAuthLockPaths.delete(path); + throw error; + } else await new Promise((resolveWait) => setTimeout(resolveWait, 10)); } } }, @@ -431,7 +433,7 @@ export async function syncCodexOAuthAuthFile( } catch (error) { cleanupError ??= error; } - if (!cleanupError) heldOAuthLockPaths.delete(path); + heldOAuthLockPaths.delete(path); } } if (cleanupError) throw cleanupError; From 0ed7431f1561be1f9f1f3dec60fa43a267e3bff1 Mon Sep 17 00:00:00 2001 From: "open-swe[bot]" Date: Mon, 3 Aug 2026 00:26:40 +1000 Subject: [PATCH 22/24] fix: cancel timed out durable Codex records --- src/core/orchestrator.ts | 16 +-- src/core/orchestrator/security-screen.ts | 2 +- src/harness/codex-harness.ts | 125 +++++++++++++---------- src/harness/harness.ts | 4 +- src/persistence/pg-pool.ts | 20 ++-- src/sessions/postgres-session-store.ts | 3 +- src/sessions/session-store.ts | 2 +- 7 files changed, 98 insertions(+), 74 deletions(-) diff --git a/src/core/orchestrator.ts b/src/core/orchestrator.ts index 0e9802b2b..3ec6890bc 100644 --- a/src/core/orchestrator.ts +++ b/src/core/orchestrator.ts @@ -340,8 +340,8 @@ export function createOrchestrator(deps: OrchestratorDeps): Orchestrator { actor.id, scopeLabel, sessionId - ? async (rec) => { - await deps.sessions.recordLlmRequest(sessionId, { ...rec, scopeLabel }); + ? async (rec, signal) => { + await deps.sessions.recordLlmRequest(sessionId, { ...rec, scopeLabel }, signal); } : undefined, { hook: "user_input", surface: "steer", origin: "ambient" }, @@ -503,13 +503,13 @@ export function createOrchestrator(deps: OrchestratorDeps): Orchestrator { const securityPolicy = resolution.securityPolicy; const screenSession: { id?: string } = {}; const pendingScreenRequests: HarnessLlmRequestRecord[] = []; - const recordScreenRequest = async (rec: HarnessLlmRequestRecord): Promise => { + const recordScreenRequest = async (rec: HarnessLlmRequestRecord, signal?: AbortSignal): Promise => { if (!screenSession.id) { pendingScreenRequests.push(rec); return; } try { - await deps.sessions.recordLlmRequest(screenSession.id, { ...rec, scopeLabel: scopeId }); + await deps.sessions.recordLlmRequest(screenSession.id, { ...rec, scopeLabel: scopeId }, signal); } catch (err) { console.error("[orchestrator] failed to persist security screen request snapshot:", err); } @@ -688,7 +688,7 @@ export function createOrchestrator(deps: OrchestratorDeps): Orchestrator { try { await withManagedRosterVersion(async () => { await reconcileSessionParticipants(session.id); - await Promise.all(pendingScreenRequests.splice(0).map(recordScreenRequest)); + await Promise.all(pendingScreenRequests.splice(0).map((rec) => recordScreenRequest(rec))); for (const overheard of screenedOverheard) { const imported = await deps.sessions.append(lease, { type: "user", @@ -1291,7 +1291,7 @@ export function createOrchestrator(deps: OrchestratorDeps): Orchestrator { try { await withManagedRosterVersion(async () => { await reconcileSessionParticipants(session.id); - await Promise.all(pendingScreenRequests.splice(0).map(recordScreenRequest)); + await Promise.all(pendingScreenRequests.splice(0).map((rec) => recordScreenRequest(rec))); return true; }); if (input.approval) { @@ -2272,9 +2272,9 @@ export function createOrchestrator(deps: OrchestratorDeps): Orchestrator { deps.modelGateway.recordCall({ at: Date.now(), scopeLabel: scopeId, ...rec }); void deps.budget?.record(actor.id, estimateCostUsd(rec.inputTokens)); }, - recordLlmRequest: async (rec) => { + recordLlmRequest: async (rec, signal) => { try { - await deps.sessions.recordLlmRequest(session.id, { ...rec, scopeLabel: scopeId }); + await deps.sessions.recordLlmRequest(session.id, { ...rec, scopeLabel: scopeId }, signal); } catch (err) { console.error("[orchestrator] failed to persist LLM request snapshot:", err); } diff --git a/src/core/orchestrator/security-screen.ts b/src/core/orchestrator/security-screen.ts index 845d3fbd5..ae0d3437e 100644 --- a/src/core/orchestrator/security-screen.ts +++ b/src/core/orchestrator/security-screen.ts @@ -21,7 +21,7 @@ export type SecurityClassifier = ( payload: string, actorId: string, scopeLabel: ScopeId, - recordLlmRequest?: (rec: HarnessLlmRequestRecord) => void | Promise, + recordLlmRequest?: (rec: HarnessLlmRequestRecord, signal?: AbortSignal) => void | Promise, context?: { hook?: SecurityScreenHook; surface?: string; diff --git a/src/harness/codex-harness.ts b/src/harness/codex-harness.ts index 2c816372d..42b7980df 100644 --- a/src/harness/codex-harness.ts +++ b/src/harness/codex-harness.ts @@ -692,14 +692,17 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { let persistenceFailed = false; if (authLock) { try { - await syncCodexOAuthAuthFile( - authPath, - join(jail, "codex-home", "auth.json"), - authLock.path, - expectedRefreshToken, - expectedAccessToken, - expectedSourceAuth, - ); + if ( + !(await syncCodexOAuthAuthFile( + authPath, + join(jail, "codex-home", "auth.json"), + authLock.path, + expectedRefreshToken, + expectedAccessToken, + expectedSourceAuth, + )) + ) + persistenceFailed = true; } catch (persistenceError) { persistenceFailed = true; swallow("codex: oauth auth persistence", persistenceError); @@ -780,41 +783,46 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { } runtime = { server, jail, persistAuth }; runtimeCleanupRequested = false; - server.process.once("close", async () => { - const currentRuntime = runtime?.server === server; - let persistenceFailed = false; - const lock = currentRuntime ? activeAuthLock : undefined; - if (lock?.isHeld()) - persistenceFailed = !(await persistAuth( - activeExpectedRefreshToken, - activeExpectedAccessToken, - lock.path, - activeExpectedSourceAuth, - )); - const closeError = persistenceFailed - ? new Error("Codex OAuth auth persistence failed", { cause: server.error() }) - : (server.error() ?? new Error("Codex app-server exited during a turn")); - for (const [threadId, state] of active) { - if (state.server !== server) continue; - state.reject(closeError); - active.delete(threadId); - } - if (!currentRuntime) { + server.process.once("close", () => { + void (async () => { + const currentRuntime = runtime?.server === server; + let persistenceFailed = false; + const lock = currentRuntime ? activeAuthLock : undefined; + if (lock?.isHeld()) + persistenceFailed = !(await persistAuth( + activeExpectedRefreshToken, + activeExpectedAccessToken, + lock.path, + activeExpectedSourceAuth, + )); + const closeError = persistenceFailed + ? new Error("Codex OAuth auth persistence failed", { cause: server.error() }) + : (server.error() ?? new Error("Codex app-server exited during a turn")); + for (const [threadId, state] of active) { + if (state.server !== server) continue; + state.reject(closeError); + active.delete(threadId); + } + if (!currentRuntime) { + rmSync(jail, { recursive: true, force: true }); + return; + } + runtime = null; + runtimeCleanupRequested = false; + if (closeAbort.signal.aborted) { + if (persistenceFailed) swallow("codex: oauth auth persistence", closeError); + } else if (lock) { + activeAuthLock = undefined; + void lock.release().catch((error) => swallow("codex: oauth lock release", error)); + activeExpectedRefreshToken = undefined; + activeExpectedAccessToken = undefined; + activeExpectedSourceAuth = undefined; + } + if (!closeAbort.signal.aborted) rmSync(jail, { recursive: true, force: true }); + })().catch((error) => { + swallow("codex: provider close cleanup", error); rmSync(jail, { recursive: true, force: true }); - return; - } - runtime = null; - runtimeCleanupRequested = false; - if (closeAbort.signal.aborted) { - if (persistenceFailed) swallow("codex: oauth auth persistence", closeError); - } else if (lock) { - activeAuthLock = undefined; - void lock.release().catch((error) => swallow("codex: oauth lock release", error)); - activeExpectedRefreshToken = undefined; - activeExpectedAccessToken = undefined; - activeExpectedSourceAuth = undefined; - } - if (!closeAbort.signal.aborted) rmSync(jail, { recursive: true, force: true }); + }); }); return runtime; })(); @@ -966,7 +974,7 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { turnAuthLock = await awaitSetup(authLockPromise); } catch (error) { void authLockPromise.then( - (lock) => lock.release(), + (lock) => lock.release().catch((releaseError) => swallow("codex: oauth lock release", releaseError)), () => undefined, ); throw error; @@ -1211,22 +1219,29 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { const startedAt = Date.now(); const recordRequest = async (): Promise => { if (!turn.recordLlmRequest) return; + const recordAbort = new AbortController(); let recordTimer: NodeJS.Timeout | undefined; try { await Promise.race([ - turn.recordLlmRequest({ - turnSeq: userEntry.seq, - step: 0, - model: selectedModel, - request: requestPayload, - truncated: Boolean(turn.images?.length), - transport: { modelId: selectedModel }, - ttftMs: state.firstOutputAt ? state.firstOutputAt - startedAt : null, - durationMs: Date.now() - startedAt, - usage: sumUsage(state.usageByThread), - }), + turn.recordLlmRequest( + { + turnSeq: userEntry.seq, + step: 0, + model: selectedModel, + request: requestPayload, + truncated: Boolean(turn.images?.length), + transport: { modelId: selectedModel }, + ttftMs: state.firstOutputAt ? state.firstOutputAt - startedAt : null, + durationMs: Date.now() - startedAt, + usage: sumUsage(state.usageByThread), + }, + recordAbort.signal, + ), new Promise((_, reject) => { - recordTimer = setTimeout(() => reject(new Error("Codex llm request recording timed out")), 5_000); + recordTimer = setTimeout(() => { + recordAbort.abort(); + reject(new Error("Codex llm request recording timed out")); + }, 5_000); }), ]); } catch (error) { diff --git a/src/harness/harness.ts b/src/harness/harness.ts index 809909c73..86770722e 100644 --- a/src/harness/harness.ts +++ b/src/harness/harness.ts @@ -38,7 +38,7 @@ interface HarnessSecurityScreenInput { payload: string; signal: AbortSignal; recordModelCall(rec: { model: string; inputTokens: number; entryCount: number }): void; - recordLlmRequest?(rec: HarnessLlmRequestRecord): void | Promise; + recordLlmRequest?(rec: HarnessLlmRequestRecord, signal?: AbortSignal): void | Promise; } export interface HarnessTurnInput { @@ -80,7 +80,7 @@ export interface HarnessTurnInput { scopeLabel: ScopeId; orgScopeId: ScopeId; recordModelCall(rec: { model: string; inputTokens: number; entryCount: number }): void; - recordLlmRequest?(rec: HarnessLlmRequestRecord): void | Promise; + recordLlmRequest?(rec: HarnessLlmRequestRecord, signal?: AbortSignal): void | Promise; onProgress?(p: { toolCalls: number; tokens?: number }): void; onGapWork?(sink: (work: GapWork) => void): void; onDelta?(chunk: string): void; diff --git a/src/persistence/pg-pool.ts b/src/persistence/pg-pool.ts index 976bd8e14..eed793980 100644 --- a/src/persistence/pg-pool.ts +++ b/src/persistence/pg-pool.ts @@ -5,10 +5,14 @@ export type { Pool, PoolClient }; export type Rows = Record[]; +export interface PgQueryOptions { + signal?: AbortSignal; +} + export interface PgPool { pool(): Promise; - q(text: string, params?: unknown[]): Promise; - query(text: string, params?: unknown[]): Promise<{ rows: Rows; rowCount: number }>; + q(text: string, params?: unknown[], options?: PgQueryOptions): Promise; + query(text: string, params?: unknown[], options?: PgQueryOptions): Promise<{ rows: Rows; rowCount: number }>; schema?(schemaSql: string): Promise; close(): Promise; } @@ -78,12 +82,16 @@ export function createPgPool(connectionString: string, statements: string[]): Pg } return poolP; } - async function query(text: string, params: unknown[] = []): Promise<{ rows: Rows; rowCount: number }> { - const res = await (await pool()).query(text, params); + async function query( + text: string, + params: unknown[] = [], + options: PgQueryOptions = {}, + ): Promise<{ rows: Rows; rowCount: number }> { + const res = await (await pool()).query({ text, values: params, ...options }); return { rows: res.rows as Rows, rowCount: res.rowCount ?? 0 }; } - async function q(text: string, params: unknown[] = []): Promise { - return (await query(text, params)).rows; + async function q(text: string, params: unknown[] = [], options?: PgQueryOptions): Promise { + return (await query(text, params, options)).rows; } async function close(): Promise { if (poolP) await (await poolP).end(); diff --git a/src/sessions/postgres-session-store.ts b/src/sessions/postgres-session-store.ts index 7506f25b5..08d615fe3 100644 --- a/src/sessions/postgres-session-store.ts +++ b/src/sessions/postgres-session-store.ts @@ -473,7 +473,7 @@ export function createPostgresSessionStore(connectionString: string, opts: Store return rows.map(rowToEntry); }, - async recordLlmRequest(sessionId, rec: NewLlmRequest): Promise { + async recordLlmRequest(sessionId, rec: NewLlmRequest, signal?: AbortSignal): Promise { const full: LlmRequestRecord = { id: randomUUID(), sessionId, @@ -512,6 +512,7 @@ export function createPostgresSessionStore(connectionString: string, opts: Store full.transport ? JSON.stringify(full.transport) : null, full.gapPhases ? JSON.stringify(full.gapPhases) : null, ], + { signal }, ); return full; }, diff --git a/src/sessions/session-store.ts b/src/sessions/session-store.ts index ec08712ff..890c40f7b 100644 --- a/src/sessions/session-store.ts +++ b/src/sessions/session-store.ts @@ -346,7 +346,7 @@ export interface SessionStore { getTape(sessionId: string, opts?: GetTapeOptions): Promise; tapeCoverage(sessionId: string): Promise; - recordLlmRequest(sessionId: string, rec: NewLlmRequest): Promise; + recordLlmRequest(sessionId: string, rec: NewLlmRequest, signal?: AbortSignal): Promise; listLlmRequests(sessionId: string, opts?: ListLlmRequestsOptions): Promise; addParticipant(sessionId: string, principalId: string, title?: string, opts?: AddParticipantOptions): Promise; From f1e8cd6144ff5b55a27c0fc514b77fe8677de846 Mon Sep 17 00:00:00 2001 From: "open-swe[bot]" Date: Mon, 3 Aug 2026 00:37:41 +1000 Subject: [PATCH 23/24] fix: cancel PostgreSQL LLM records on timeout --- src/harness/codex-harness.ts | 6 ++++- src/persistence/pg-pool.ts | 29 ++++++++++++++++++++-- test/codex-harness.test.ts | 47 ++++++++++++++++++++++++++++++++++++ 3 files changed, 79 insertions(+), 3 deletions(-) diff --git a/src/harness/codex-harness.ts b/src/harness/codex-harness.ts index 42b7980df..6c07b86b9 100644 --- a/src/harness/codex-harness.ts +++ b/src/harness/codex-harness.ts @@ -821,7 +821,11 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { if (!closeAbort.signal.aborted) rmSync(jail, { recursive: true, force: true }); })().catch((error) => { swallow("codex: provider close cleanup", error); - rmSync(jail, { recursive: true, force: true }); + try { + rmSync(jail, { recursive: true, force: true }); + } catch (cleanupError) { + swallow("codex: provider close jail cleanup", cleanupError); + } }); }); return runtime; diff --git a/src/persistence/pg-pool.ts b/src/persistence/pg-pool.ts index eed793980..adaf86e1f 100644 --- a/src/persistence/pg-pool.ts +++ b/src/persistence/pg-pool.ts @@ -87,8 +87,33 @@ export function createPgPool(connectionString: string, statements: string[]): Pg params: unknown[] = [], options: PgQueryOptions = {}, ): Promise<{ rows: Rows; rowCount: number }> { - const res = await (await pool()).query({ text, values: params, ...options }); - return { rows: res.rows as Rows, rowCount: res.rowCount ?? 0 }; + const p = await pool(); + if (!options.signal) { + const res = await p.query(text, params); + return { rows: res.rows as Rows, rowCount: res.rowCount ?? 0 }; + } + if (options.signal.aborted) throw new DOMException("Postgres query cancelled", "AbortError"); + const client = await p.connect(); + let queryError: Error | undefined; + const cancellableClient = client as PoolClient & { + activeQuery?: object | null; + cancel?: (client: PoolClient, query: object) => void; + }; + const cancel = () => { + const activeQuery = cancellableClient.activeQuery; + if (activeQuery && cancellableClient.cancel) cancellableClient.cancel(client, activeQuery); + }; + options.signal.addEventListener("abort", cancel, { once: true }); + try { + const res = await client.query({ text, values: params }); + return { rows: res.rows as Rows, rowCount: res.rowCount ?? 0 }; + } catch (error) { + queryError = error instanceof Error ? error : new Error(String(error)); + throw error; + } finally { + options.signal?.removeEventListener("abort", cancel); + client.release(queryError); + } } async function q(text: string, params: unknown[] = [], options?: PgQueryOptions): Promise { return (await query(text, params, options)).rows; diff --git a/test/codex-harness.test.ts b/test/codex-harness.test.ts index ad09c787f..4fe9c0c1c 100644 --- a/test/codex-harness.test.ts +++ b/test/codex-harness.test.ts @@ -1741,6 +1741,53 @@ test("Codex waits for the bounded durable llm record before completing a turn", assert.ok(Date.now() - startedAt >= 45); }); +test("Codex aborts a durable llm record that exceeds its bound", async (t) => { + const dir = mkdtempSync(join(tmpdir(), "qm-codex-telemetry-timeout-test-")); + const scope = { kind: "org", id: "test" } as unknown as ScopeId; + const harness = createCodexHarness({ + binaryPath: fakeCodexBinary(dir), + env: testHarnessEnv(dir), + turnWallClockMs: 12_000, + }); + t.after(async () => { + await harness.turns.close?.(); + rmSync(dir, { recursive: true, force: true }); + }); + let aborted = false; + const result = await harness.turns.runTurn({ + session: { id: "telemetry-timeout" } as Session, + input: "hi", + systemPrompt: "be concise", + history: [], + tools: {} as HarnessTurnInput["tools"], + scopeLabel: scope, + orgScopeId: scope, + emit: async (entry) => + ({ ...entry, sessionId: "telemetry-timeout", seq: 1, createdAt: Date.now() }) as SessionEntry, + recordModelCall: () => {}, + recordLlmRequest: async (_record, signal) => { + if (!signal) throw new Error("missing record cancellation signal"); + await new Promise((resolve) => { + if (signal.aborted) { + aborted = true; + resolve(); + return; + } + signal.addEventListener( + "abort", + () => { + aborted = true; + resolve(); + }, + { once: true }, + ); + }); + }, + }); + assert.equal(result.reply, "hello"); + assert.equal(aborted, true); +}); + const realCodexBinary = (() => { try { return join(dirname(createRequire(import.meta.url).resolve("@openai/codex/package.json")), "bin/codex.js"); From 420afb8ec1d42c0befb8df7dc5a608a9121e8d43 Mon Sep 17 00:00:00 2001 From: "open-swe[bot]" Date: Mon, 3 Aug 2026 00:47:58 +1000 Subject: [PATCH 24/24] fix: safely cancel queued Postgres records --- src/persistence/pg-pool.ts | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/src/persistence/pg-pool.ts b/src/persistence/pg-pool.ts index adaf86e1f..b8d7c93a6 100644 --- a/src/persistence/pg-pool.ts +++ b/src/persistence/pg-pool.ts @@ -93,18 +93,36 @@ export function createPgPool(connectionString: string, statements: string[]): Pg return { rows: res.rows as Rows, rowCount: res.rowCount ?? 0 }; } if (options.signal.aborted) throw new DOMException("Postgres query cancelled", "AbortError"); - const client = await p.connect(); + const connectPromise = p.connect(); + let connectAbort: (() => void) | undefined; + const connectAbortPromise = new Promise((_, reject) => { + connectAbort = () => reject(new DOMException("Postgres query cancelled", "AbortError")); + options.signal!.addEventListener("abort", connectAbort, { once: true }); + }); + let client: PoolClient; + try { + client = await Promise.race([connectPromise, connectAbortPromise]); + } catch (error) { + void connectPromise + .then( + (lateClient) => lateClient.release(error instanceof Error ? error : new Error(String(error))), + () => undefined, + ) + .catch(() => undefined); + throw error; + } finally { + if (connectAbort) options.signal.removeEventListener("abort", connectAbort); + } let queryError: Error | undefined; - const cancellableClient = client as PoolClient & { - activeQuery?: object | null; - cancel?: (client: PoolClient, query: object) => void; - }; + let released = false; const cancel = () => { - const activeQuery = cancellableClient.activeQuery; - if (activeQuery && cancellableClient.cancel) cancellableClient.cancel(client, activeQuery); + if (released) return; + released = true; + client.release(new Error("Postgres query cancelled")); }; options.signal.addEventListener("abort", cancel, { once: true }); try { + if (released) throw new Error("Postgres query cancelled"); const res = await client.query({ text, values: params }); return { rows: res.rows as Rows, rowCount: res.rowCount ?? 0 }; } catch (error) { @@ -112,7 +130,7 @@ export function createPgPool(connectionString: string, statements: string[]): Pg throw error; } finally { options.signal?.removeEventListener("abort", cancel); - client.release(queryError); + if (!released) client.release(queryError); } } async function q(text: string, params: unknown[] = [], options?: PgQueryOptions): Promise {