diff --git a/.codex/skills/dev-instance/SKILL.md b/.codex/skills/dev-instance/SKILL.md index 6f1c1b655..3bcf5ba14 100644 --- a/.codex/skills/dev-instance/SKILL.md +++ b/.codex/skills/dev-instance/SKILL.md @@ -119,8 +119,14 @@ 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. Core + refreshes OAuth tokens centrally and hands the Codex child ephemeral material (no + refresh token). Pass `DEV_INSTANCE_ALLOW_MOCK=1` for a deliberate no-model wiring + check. The auth-file path is for local dev instances; deployed production processes + use an API key or a keychain credential (`CODEX_AUTH_CREDENTIAL` / + `CLAUDE_AUTH_CREDENTIAL`), whose secret lives encrypted in its owner's keychain. - 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 b5d83f83a..7fb0a747f 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,13 @@ HARNESS=pi +# Subscription-backed harness auth. Production path: point at a keychain +# credential id (the login lives encrypted in its owner's keychain; core +# refreshes it centrally and hands harnesses ephemeral derived material). +#CODEX_AUTH_CREDENTIAL= +#CLAUDE_AUTH_CREDENTIAL= +# Local-dev fallback only: a Codex CLI auth.json on this machine +# (defaults to ~/.codex/auth.json when HARNESS=codex). Not allowed in production. +CODEX_AUTH_FILE= HARNESS_SECURITY_POSTURE=auto #ANTHROPIC_API_KEY=sk-ant-... diff --git a/plugins/admin/public/index.html b/plugins/admin/public/index.html index becbb6f75..12f9a246a 100644 --- a/plugins/admin/public/index.html +++ b/plugins/admin/public/index.html @@ -7253,19 +7253,20 @@

Confirm governance change

const baseModel = config.data.baseModel || config.data.baseModelDefault || ""; const baseProvider = onboardingProviderForModel(baseModel); const baseStatus = onboardingModelStatuses.find((item) => item.provider === baseProvider); - onboardingBadge( - "onboarding-model-badge", - baseStatus?.configured ? "Ready" : "Needs a key", - Boolean(baseStatus?.configured), - ); + const harnessAuth = models.data.harnessAuth; + const baseHarnessAuth = Boolean(harnessAuth && harnessAuth.provider === baseProvider); + const baseReady = Boolean(baseModel) && (Boolean(baseStatus?.configured) || baseHarnessAuth); + onboardingBadge("onboarding-model-badge", baseReady ? "Ready" : "Needs a key", baseReady); $("onboarding-model-summary").textContent = !baseModel ? "No base model is configured yet — pick a provider and model below." : baseStatus?.configured ? baseModel + " · " + (baseStatus.source === "admin" ? "admin-managed key" : "deployment key") - : baseModel + - " cannot run until its " + - (MODEL_PROVIDER_LABELS[baseProvider] || connectorName(baseProvider)) + - " key is configured."; + : baseHarnessAuth + ? baseModel + " · authenticated by the " + harnessAuth.harnessId + " harness — no API key needed." + : baseModel + + " cannot run until its " + + (MODEL_PROVIDER_LABELS[baseProvider] || connectorName(baseProvider)) + + " key is configured."; renderOnboardingProviderOptions(baseProvider); renderOnboardingModelOptions(baseModel); diff --git a/plugins/admin/test/onboarding-view.test.ts b/plugins/admin/test/onboarding-view.test.ts index c7fe9b4fc..406390c21 100644 --- a/plugins/admin/test/onboarding-view.test.ts +++ b/plugins/admin/test/onboarding-view.test.ts @@ -28,6 +28,98 @@ function resolveView(pathname: string, search: string): string { return vm.runInContext(src, context); } +interface FakeElement { + textContent: string; + className: string; + value: string; + placeholder: string; + disabled: boolean; + href: string; + options: Array<{ value?: string; textContent?: string }>; + appendChild(option: { value?: string; textContent?: string }): void; +} + +async function runLoadOnboarding(modelProviders: unknown): Promise> { + const src = slice("let onboardingModels = {};", '$("onboarding-model-provider").onchange') + "\nloadOnboarding();"; + const elements: Record = {}; + const fixtures: Record = { + "/api/model-providers": modelProviders, + "/api/slack-installation": { configured: false }, + "/api/connector-catalog": { catalog: [] }, + "/api/scopes/org%3Adefault-org": { baseModel: "claude-opus-5" }, + }; + const context = vm.createContext({ + $: (id: string) => + (elements[id] ??= { + textContent: "", + className: "", + value: "", + placeholder: "", + disabled: false, + href: "", + options: [], + appendChild(option) { + this.options.push(option); + }, + }), + api: async (_method: string, path: string) => ({ ok: true, data: fixtures[path] ?? {} }), + orgScope: () => "org:default-org", + encodeURIComponent, + setStatus: () => {}, + connectorName: (id: string) => id, + viewLoadedAt: {}, + Date, + document: { createElement: () => ({}) }, + }); + await vm.runInContext(src, context); + return elements; +} + +const UNCONFIGURED_PROVIDERS = [ + { provider: "anthropic", configured: false, source: "absent" }, + { provider: "openai", configured: false, source: "absent" }, + { provider: "openrouter", configured: false, source: "absent" }, +]; +const ANTHROPIC_MODELS = [{ id: "claude-opus-5", name: "Claude Opus 5", provider: "anthropic" }]; + +test("harness-carried auth shows the model step as ready without a stored key", async () => { + const elements = await runLoadOnboarding({ + providers: UNCONFIGURED_PROVIDERS, + models: ANTHROPIC_MODELS, + harnessAuth: { harnessId: "claude", provider: "anthropic" }, + }); + assert.equal(elements["onboarding-model-badge"]!.textContent, "Ready"); + assert.equal(elements["onboarding-model-badge"]!.className, "badge ok"); + assert.equal( + elements["onboarding-model-summary"]!.textContent, + "claude-opus-5 · authenticated by the claude harness — no API key needed.", + ); +}); + +test("without harness auth an unconfigured provider still needs a key", async () => { + const elements = await runLoadOnboarding({ + providers: UNCONFIGURED_PROVIDERS, + models: ANTHROPIC_MODELS, + }); + assert.equal(elements["onboarding-model-badge"]!.textContent, "Needs a key"); + assert.equal(elements["onboarding-model-badge"]!.className, "badge warn"); + assert.match(elements["onboarding-model-summary"]!.textContent, /cannot run until its Anthropic key is configured/); +}); + +test("a stored key keeps its summary even when the harness also carries auth", async () => { + const elements = await runLoadOnboarding({ + providers: [ + { provider: "anthropic", configured: true, source: "admin" }, + { provider: "openai", configured: false, source: "absent" }, + { provider: "openrouter", configured: false, source: "absent" }, + ], + models: ANTHROPIC_MODELS, + harnessAuth: { harnessId: "claude", provider: "anthropic" }, + }); + assert.equal(elements["onboarding-model-badge"]!.textContent, "Ready"); + assert.equal(elements["onboarding-model-summary"]!.textContent, "claude-opus-5 · admin-managed key"); +}); + test("onboarding is a navigable view", () => { assert.match(html, /\{ label: "Admin", views: \["onboarding",/); }); diff --git a/scripts/dev/lib/envctx.ts b/scripts/dev/lib/envctx.ts index e22d460ed..e2be0aa51 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-file.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 cd057da87..56c5d3f61 100644 --- a/scripts/dev/supervisor/main.ts +++ b/scripts/dev/supervisor/main.ts @@ -318,7 +318,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 33c0b364c..5d8867f52 100644 --- a/scripts/dev/supervisor/specs.ts +++ b/scripts/dev/supervisor/specs.ts @@ -21,6 +21,10 @@ export interface SpecInputs { export function buildChildSpecs(i: SpecInputs): ChildSpec[] { const watchArgs = i.watch ? ["--watch"] : []; const base = { ...i.baseEnv, ...i.sandboxEnv }; + 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 [ @@ -58,7 +62,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}`, @@ -78,7 +82,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}`, @@ -95,7 +99,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/deps.ts b/src/api/deps.ts index 7e0b60d0c..2ab61d689 100644 --- a/src/api/deps.ts +++ b/src/api/deps.ts @@ -1,4 +1,4 @@ -import type { ModelProviderAvailability } from "../model/pi-models.ts"; +import type { ModelProvider, ModelProviderAvailability } from "../model/pi-models.ts"; import type { ModelCredentialStore } from "../model/model-credential-store.ts"; import type { CustomProviderStore } from "../model/custom-provider-store.ts"; import type { McpServerStore } from "../mcp/mcp-server-store.ts"; @@ -94,6 +94,7 @@ export interface ServerDeps { refreshCustomProviders?: () => Promise; brandingDefault?: OrgBranding; harnessId?: string; + harnessCarriedModelAuth?: ModelProvider; admin?: AdminService; rateLimiter?: RateLimiter; sessions?: SessionStore; diff --git a/src/api/routes/admin/model-providers.ts b/src/api/routes/admin/model-providers.ts index a43806558..692a66a0c 100644 --- a/src/api/routes/admin/model-providers.ts +++ b/src/api/routes/admin/model-providers.ts @@ -61,6 +61,9 @@ export async function getModelProviders(ctx: ApiCtx): Promise { return sendJson(ctx.res, 200, { providers: await ctx.deps.modelCredentials.statuses(), models: await selectableModelCatalog(ctx.deps.modelCredentialFetch), + ...(ctx.deps.harnessCarriedModelAuth + ? { harnessAuth: { harnessId: ctx.deps.harnessId ?? "pi", provider: ctx.deps.harnessCarriedModelAuth } } + : {}), }); } diff --git a/src/api/routes/surface.ts b/src/api/routes/surface.ts index 39690ed4a..5172511cb 100644 --- a/src/api/routes/surface.ts +++ b/src/api/routes/surface.ts @@ -1062,6 +1062,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(); @@ -1079,7 +1081,9 @@ 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) || Boolean(deps.harnessCarriedModelAuth), + }), externalSlackParticipants, ...(Object.keys(resolvedBranding).length ? { branding: resolvedBranding } : {}), }); diff --git a/src/config.ts b/src/config.ts index 4d1fb16e5..f127ca399 100644 --- a/src/config.ts +++ b/src/config.ts @@ -14,6 +14,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-file.ts"; import { MODEL_PROVIDERS, defaultModelForProvider, @@ -44,6 +45,11 @@ export interface Config { opencodeModel?: string; codexModel?: string; codexBinPath?: string; + codexAuthFile?: string; + /** Keychain credential id holding the Codex ChatGPT OAuth auth.json (production path). */ + codexAuthCredential?: string; + /** Keychain credential id holding a Claude Code subscription token (production path). */ + claudeAuthCredential?: string; codexProcessEnv: NodeJS.ProcessEnv; claudeModel?: string; claudeBinPath?: string; @@ -163,6 +169,9 @@ export function providerKeysPresent(config: Config): ModelProviderAvailability { anthropic: Boolean(config.anthropicApiKey), openai: Boolean(config.openaiApiKey), openrouter: Boolean(config.openrouterApiKey), + ...(config.harness === "codex" && (config.codexAuthFile || config.codexAuthCredential) + ? { codexOAuth: true } + : {}), }; } @@ -170,6 +179,19 @@ export function baseModelProviders(config: Config): ModelProviderAvailability | return config.modelProvider ? onlyProvider(config.modelProvider) : undefined; } +export function harnessCarriedModelAuth(config: Config): ModelProvider | undefined { + if ( + config.harness === "claude" && + (config.claudeAuthCredential || + config.claudeProcessEnv.CLAUDE_CODE_OAUTH_TOKEN || + config.claudeProcessEnv.ANTHROPIC_AUTH_TOKEN) + ) + return "anthropic"; + if (config.harness === "codex" && (config.codexAuthCredential || config.codexProcessEnv.CODEX_ACCESS_TOKEN)) + return "openai"; + return undefined; +} + interface AwsSandboxEnv { region: string; profile?: string; @@ -608,10 +630,30 @@ 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 codexAuthCredential = env.CODEX_AUTH_CREDENTIAL?.trim() || undefined; + const claudeAuthCredential = env.CLAUDE_AUTH_CREDENTIAL?.trim() || undefined; + const codexAuthCandidate = + harness === "codex" && !codexAuthCredential ? 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 && !codexAuthCredential) { + throw new Error( + "HARNESS=codex needs OPENAI_API_KEY, a keychain credential via CODEX_AUTH_CREDENTIAL, 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; production must use CODEX_AUTH_CREDENTIAL (keychain custody)", + ); + } const modelProvider = modelProviderEnvStrict(env); for (const key of ["SESSION_STORE", "RUN_STORE", "ARTIFACT_STORE"] as const) { if (env[key] === "sqlite") { @@ -698,6 +740,9 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { } 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 providerBaseUrls = providerBaseUrlsFromEnv(env); const codexProcessEnv = Object.fromEntries( [ @@ -716,7 +761,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( [ @@ -754,7 +800,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { ...(env.DATABASE_URL ? { databaseUrl: env.DATABASE_URL } : {}), ...(env.DATABASE_CA_CERT ? { databaseCaCert: env.DATABASE_CA_CERT } : {}), ...(env.DATABASE_CA_CERT_FILE ? { databaseCaCertFile: env.DATABASE_CA_CERT_FILE } : {}), - harness: harnessEnvStrict(env.HARNESS), + harness, securityPosture: securityPostureEnvStrict(env.HARNESS_SECURITY_POSTURE), securityScreenBackend, ...(securityScreenBackend === "proxy" @@ -782,6 +828,9 @@ 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 } : {}), + ...(codexAuthCredential ? { codexAuthCredential } : {}), + ...(claudeAuthCredential ? { claudeAuthCredential } : {}), codexProcessEnv, ...(env.CLAUDE_MODEL ? { claudeModel: env.CLAUDE_MODEL } : {}), ...(env.CLAUDE_BIN ? { claudeBinPath: env.CLAUDE_BIN } : {}), diff --git a/src/core/orchestrator.ts b/src/core/orchestrator.ts index 4d487c18f..5a6f44aa0 100644 --- a/src/core/orchestrator.ts +++ b/src/core/orchestrator.ts @@ -347,8 +347,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" }, @@ -526,13 +526,13 @@ export function createOrchestrator(deps: OrchestratorDeps): Orchestrator { ); 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:", errMessage(err)); } @@ -714,7 +714,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", @@ -1404,7 +1404,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) { @@ -2576,9 +2576,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:", errMessage(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/credentials/harness-auth-env.ts b/src/credentials/harness-auth-env.ts new file mode 100644 index 000000000..a475b10d0 --- /dev/null +++ b/src/credentials/harness-auth-env.ts @@ -0,0 +1,33 @@ +import type { Keychain } from "./keychain.ts"; + +/** + * Resolve a keychain env credential (e.g. a CLAUDE_CODE_OAUTH_TOKEN saved by + * `claude setup-token`) into the env vars a harness child should receive. + * Resolved fresh on every call, so a rotated or revoked credential takes + * effect on the next session with no restart. Returns {} when the credential + * is missing, expired, or not env-shaped, so the harness falls back to + * whatever static configuration it has. + */ +export function keychainHarnessAuthEnv( + keychain: Keychain, + credentialId: string, + allowedEnvKeys: readonly string[], +): () => Promise { + return async () => { + try { + const meta = await keychain.getCredential(credentialId); + if (!meta || meta.kind !== "env") return {}; + if (typeof meta.expiresAt === "number" && meta.expiresAt < Date.now()) return {}; + const creds = await keychain.materializeOwn(meta.ownerId); + const cred = creds.find((c) => c.credentialId === credentialId); + if (!cred) return {}; + const env: NodeJS.ProcessEnv = {}; + for (const { key, value } of cred.env) { + if (allowedEnvKeys.includes(key)) env[key] = value; + } + return env; + } catch { + return {}; + } + }; +} diff --git a/src/deployment/secret-schema.ts b/src/deployment/secret-schema.ts index 265cc4258..7daf82c89 100644 --- a/src/deployment/secret-schema.ts +++ b/src/deployment/secret-schema.ts @@ -43,7 +43,8 @@ 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() && !env.CODEX_AUTH_CREDENTIAL?.trim(), postgres: (env) => env.SESSION_STORE === "postgres" || env.RUN_STORE === "postgres", sprites: (env) => env.SANDBOX_BACKEND === "sprites" || env.SANDBOX_SECONDARY_BACKEND === "sprites", smolmachines: (env) => env.SANDBOX_BACKEND === "smolmachines" || env.SANDBOX_SECONDARY_BACKEND === "smolmachines", @@ -54,7 +55,9 @@ 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() || env.CODEX_AUTH_CREDENTIAL?.trim())), "model-openrouter": (env) => env.MODEL_PROVIDER?.trim() === "openrouter", }; diff --git a/src/harness/claude-harness.ts b/src/harness/claude-harness.ts index 2edbb26eb..ffa8a3d2e 100644 --- a/src/harness/claude-harness.ts +++ b/src/harness/claude-harness.ts @@ -59,6 +59,12 @@ export interface ClaudeHarnessOptions { execTimeoutCeilingMs?: number; backgroundJobTtlMs?: number; backgroundJobTtlMaxMs?: number; + /** + * Custodian of subscription auth (e.g. a keychain-held CLAUDE_CODE_OAUTH_TOKEN). + * Resolved fresh per session start; merged over static env so the secret + * never lives in process env or on the core host's disk. + */ + authEnv?: () => Promise; signals?: RunSignalStore; tasks?: TaskStore; } @@ -433,12 +439,13 @@ export function createClaudeHarness(opts: ClaudeHarnessOptions = {}): Harness { swallow("claude: tape append", error); } }; + const authEnv = opts.authEnv ? await opts.authEnv() : undefined; const sdkQuery = query({ prompt: queue, options: { abortController: controller, cwd: jail, - env: claudeChildEnv(opts.env ?? {}, jail), + env: claudeChildEnv(authEnv ? { ...(opts.env ?? {}), ...authEnv } : (opts.env ?? {}), jail), tools: allowSubagents ? ["Agent"] : [], skills: [], settingSources: [], diff --git a/src/harness/codex-app-server.ts b/src/harness/codex-app-server.ts index 6eb7607e6..e65f3182e 100644 --- a/src/harness/codex-app-server.ts +++ b/src/harness/codex-app-server.ts @@ -18,6 +18,104 @@ type JsonRpcMessage = { error?: { code?: number; message?: string; data?: unknown }; }; +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)); +} + +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", + "credential", + "credentials", + "password", + "passphrase", + "secret", + "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|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|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|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|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_-]*\d)[A-Za-z0-9_-]{32,}\b/g, "[redacted]"); +} + export interface CodexAppServerOptions { binaryPath: string; cwd: string; @@ -30,7 +128,11 @@ 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 readonly cancelledRequestIds = new Set(); private writeTail = Promise.resolve(); private eventTail = Promise.resolve(); private stderr = ""; @@ -69,8 +171,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,12 +192,47 @@ export class CodexAppServer { await this.notify("initialized"); } - request(method: string, params?: unknown): 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; + 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++; - const result = new Promise((resolve, reject) => { - this.pending.set(id, { resolve: (value) => resolve(value as T), reject }); + 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; + 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 }); + 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); @@ -120,21 +258,38 @@ 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) { + 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 (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; @@ -165,5 +320,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-auth-file.ts b/src/harness/codex-auth-file.ts new file mode 100644 index 000000000..34a552059 --- /dev/null +++ b/src/harness/codex-auth-file.ts @@ -0,0 +1,119 @@ +import { readFileSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; + +export type JsonObject = Record; + +const CODEX_OAUTH_MODES = new Set(["chatgpt", "chatgptAuthTokens"]); +export const CODEX_OAUTH_ISSUER = "https://auth.openai.com"; + +export function asObject(value: unknown): JsonObject | null { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonObject) : null; +} + +export function codexOAuthJwtAccountIdFromToken(value: unknown): string | undefined { + if (typeof value !== "string" || value.split(".").length !== 3) return undefined; + try { + 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 + : undefined; + } catch { + return undefined; + } +} + +export 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; + } +} + +export function codexOAuthJwtAccountId(value: unknown): string | undefined { + const auth = asObject(value); + const tokens = auth ? asObject(auth.tokens) : null; + return codexOAuthJwtAccountIdFromToken(tokens?.id_token); +} + +export 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; +} diff --git a/src/harness/codex-auth-store.ts b/src/harness/codex-auth-store.ts new file mode 100644 index 000000000..b0d1768d2 --- /dev/null +++ b/src/harness/codex-auth-store.ts @@ -0,0 +1,269 @@ +import { CODEX_OAUTH_ISSUER, asObject, codexOAuthJwtAccountId, type JsonObject } from "./codex-auth-file.ts"; +import { + codexOAuthAccessToken, + codexOAuthRefreshToken, + readCodexOAuthAuthFile, + sanitizedCodexOAuthAuth, +} from "./codex-auth-file.ts"; +import type { CredentialFile, Keychain } from "../credentials/keychain.ts"; +import { swallow } from "../util/errors.ts"; +import { acquireCodexOAuthAuthLock, writeCodexOAuthAuthFile } from "./codex-auth.ts"; + +/** + * A CodexAuthStore is the custodian of a ChatGPT-subscription Codex login. + * + * The store — not the harness, and never the child process — owns the + * refresh token and the refresh loop. `load()` returns auth that is fresh + * enough to hand to a child; the store refreshes centrally (and persists the + * rotated tokens back to its backing storage) before returning when the + * access token is near expiry. Children receive derived, ephemeral material + * only (see `childCodexOAuthAuth`), so nothing a child does can rotate or + * leak the long-lived credential. + */ +export interface CodexAuthStore { + /** Where the credential lives, for logs and errors. Never includes secrets. */ + readonly description: string; + /** Current auth, centrally refreshed when the access token is stale. Null when unavailable. */ + load(): Promise; +} + +/** The Codex CLI's public OAuth client id (auth.openai.com device/PKCE client). */ +export const CODEX_OAUTH_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"; + +/** Refresh when the access token has less than this long to live. */ +const REFRESH_SKEW_MS = 5 * 60_000; + +const CODEX_AUTH_FILE_PATHS = [".codex/auth.json", "codex/auth.json"]; + +function jwtExpiryMs(token: unknown): number | undefined { + if (typeof token !== "string" || token.split(".").length !== 3) return undefined; + try { + const payload = asObject(JSON.parse(Buffer.from(token.split(".")[1] ?? "", "base64url").toString("utf8"))); + return typeof payload?.exp === "number" ? payload.exp * 1000 : undefined; + } catch { + return undefined; + } +} + +/** Epoch ms when this auth's access token expires, if it carries an exp claim. */ +export function codexOAuthAccessTokenExpiresAt(auth: JsonObject | null): number | undefined { + return jwtExpiryMs(asObject(auth?.tokens)?.access_token); +} + +/** Validate an already-parsed auth.json value the same way readCodexOAuthAuthFile validates a file. */ +export function codexOAuthAuthFromValue(value: unknown): JsonObject | null { + const auth = asObject(value); + if (!auth) return null; + const tokens = asObject(auth.tokens); + const mode = typeof auth.auth_mode === "string" ? auth.auth_mode : ""; + if (!["chatgpt", "chatgptAuthTokens"].includes(mode)) return null; + if ( + !tokens || + typeof tokens.access_token !== "string" || + !tokens.access_token || + typeof tokens.refresh_token !== "string" || + !tokens.refresh_token || + !codexOAuthJwtAccountId(auth) + ) + return null; + return auth; +} + +/** + * The material a Codex child process receives: the sanitized auth WITHOUT the + * refresh token. The child can use the access token until it expires; only the + * store may refresh. The next turn's `load()` re-materializes fresh tokens. + */ +export function childCodexOAuthAuth(auth: JsonObject): JsonObject { + const sanitized = sanitizedCodexOAuthAuth(auth); + const tokens = asObject(sanitized.tokens); + if (tokens) { + const { refresh_token: _refresh, ...rest } = tokens; + sanitized.tokens = rest; + } + return sanitized; +} + +async function refreshCodexOAuth(auth: JsonObject, fetchImpl: typeof fetch): Promise { + const refreshToken = codexOAuthRefreshToken(auth); + if (!refreshToken) return null; + const response = await fetchImpl(`${CODEX_OAUTH_ISSUER}/oauth/token`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + client_id: CODEX_OAUTH_CLIENT_ID, + grant_type: "refresh_token", + refresh_token: refreshToken, + scope: "openid profile email", + }), + }); + if (!response.ok) throw new Error(`Codex OAuth refresh failed: HTTP ${response.status}`); + const body = asObject(await response.json().catch(() => null)); + if (!body || typeof body.access_token !== "string" || !body.access_token) { + throw new Error("Codex OAuth refresh returned no access token"); + } + const tokens = asObject(auth.tokens) ?? {}; + const next: JsonObject = { + ...auth, + last_refresh: new Date().toISOString(), + tokens: { + ...tokens, + access_token: body.access_token, + ...(typeof body.id_token === "string" && body.id_token ? { id_token: body.id_token } : {}), + ...(typeof body.refresh_token === "string" && body.refresh_token ? { refresh_token: body.refresh_token } : {}), + }, + }; + // The refreshed identity must stay on the same ChatGPT account. + return codexOAuthAuthFromValue(next) && codexOAuthJwtAccountId(next) === codexOAuthJwtAccountId(auth) ? next : null; +} + +function authNeedsRefresh(auth: JsonObject, now: number): boolean { + const expiresAt = codexOAuthAccessTokenExpiresAt(auth); + return typeof expiresAt === "number" && expiresAt - now < REFRESH_SKEW_MS; +} + +interface KeychainCodexAuthStoreDeps { + keychain: Keychain; + /** Keychain credential id of the user's Codex ChatGPT login (a file credential holding auth.json). */ + credentialId: string; + fetchImpl?: typeof fetch; + now?: () => number; +} + +function codexAuthFromFiles(files: CredentialFile[]): { path: string; auth: JsonObject } | null { + for (const file of files) { + const normalized = file.path.replace(/^\.\//, ""); + if (!CODEX_AUTH_FILE_PATHS.includes(normalized) && !normalized.endsWith("/auth.json")) continue; + try { + const auth = codexOAuthAuthFromValue(JSON.parse(Buffer.from(file.contentBase64, "base64").toString("utf8"))); + if (auth) return { path: file.path, auth }; + } catch { + // fall through to the next candidate file + } + } + return null; +} + +/** + * Keychain-backed Codex subscription auth. The credential (a file bundle + * holding the Codex CLI's auth.json) lives encrypted in its owner's keychain; + * core is the single writer. Refreshed tokens are persisted back to the + * keychain with a compare-and-set against the refresh token they replaced, so + * a concurrent rotation loses cleanly instead of clobbering. + */ +export function keychainCodexAuthStore(deps: KeychainCodexAuthStoreDeps): CodexAuthStore { + const fetchImpl = deps.fetchImpl ?? fetch; + const now = deps.now ?? Date.now; + let refreshing: Promise | null = null; + + const readCurrent = async (): Promise<{ + ownerId: string; + service: string; + path: string; + auth: JsonObject; + } | null> => { + const meta = await deps.keychain.getCredential(deps.credentialId); + if (!meta || meta.kind !== "file") return null; + const bundles = await deps.keychain.materializeOwnFiles(meta.ownerId); + const bundle = bundles.find((b) => b.credentialId === deps.credentialId); + if (!bundle) return null; + const found = codexAuthFromFiles(bundle.files); + return found ? { ownerId: meta.ownerId, service: meta.service, ...found } : null; + }; + + const persist = async ( + current: { ownerId: string; service: string; path: string }, + replacedRefreshToken: string | undefined, + next: JsonObject, + ): Promise => { + // Compare-and-set: re-read and refuse if someone else rotated first. + const latest = await readCurrent(); + if (!latest || codexOAuthRefreshToken(latest.auth) !== replacedRefreshToken) return false; + await deps.keychain.save({ + ownerId: current.ownerId, + service: current.service, + files: [{ path: current.path, contentBase64: Buffer.from(JSON.stringify(next), "utf8").toString("base64") }], + ...(codexOAuthAccessTokenExpiresAt(next) !== undefined + ? { expiresAt: codexOAuthAccessTokenExpiresAt(next) } + : {}), + }); + return true; + }; + + return { + description: `keychain credential ${deps.credentialId}`, + async load(): Promise { + const current = await readCurrent(); + if (!current) return null; + if (!authNeedsRefresh(current.auth, now())) return current.auth; + // Single refresh in flight per store; concurrent loads share it. + refreshing ??= (async () => { + try { + const next = await refreshCodexOAuth(current.auth, fetchImpl); + if (!next) return null; + await persist(current, codexOAuthRefreshToken(current.auth), next); + return next; + } finally { + refreshing = null; + } + })(); + try { + const refreshed = await refreshing; + if (refreshed) return refreshed; + } catch (error) { + swallow("codex: central oauth refresh", error); + } + // A stale access token is still worth handing out: the provider decides. + return (await readCurrent())?.auth ?? current.auth; + }, + }; +} + +/** + * File-backed store for local development: the operator's own + * ~/.codex/auth.json (or CODEX_AUTH_FILE). Core refreshes centrally and writes + * the rotated tokens back atomically under the file lock; children never see + * the refresh token, so no child state ever syncs back. + */ +export function fileCodexAuthStore( + path: string, + fetchImpl: typeof fetch = fetch, + now: () => number = Date.now, +): CodexAuthStore { + let refreshing: Promise | null = null; + return { + description: `auth file ${path}`, + async load(): Promise { + const current = readCodexOAuthAuthFile(path); + if (!current) return null; + if (!authNeedsRefresh(current, now())) return current; + refreshing ??= (async () => { + try { + const next = await refreshCodexOAuth(current, fetchImpl); + if (!next) return null; + const lock = await acquireCodexOAuthAuthLock(path, undefined, 10_000, 25); + try { + const latest = readCodexOAuthAuthFile(path); + // Compare-and-set: refuse if another process rotated first. + if (!latest || codexOAuthRefreshToken(latest) !== codexOAuthRefreshToken(current)) return latest; + writeCodexOAuthAuthFile(path, next); + } finally { + await lock.release(); + } + return next; + } finally { + refreshing = null; + } + })(); + try { + const refreshed = await refreshing; + if (refreshed) return refreshed; + } catch (error) { + swallow("codex: file oauth refresh", error); + } + return readCodexOAuthAuthFile(path) ?? current; + }, + }; +} + +export { codexOAuthAccessToken, codexOAuthRefreshToken }; diff --git a/src/harness/codex-auth.ts b/src/harness/codex-auth.ts new file mode 100644 index 000000000..d0ce7f137 --- /dev/null +++ b/src/harness/codex-auth.ts @@ -0,0 +1,182 @@ +import { randomBytes } from "node:crypto"; +import { open as openFile } from "node:fs/promises"; +import { + chmodSync, + existsSync, + mkdirSync, + readFileSync, + renameSync, + rmSync, + statSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { dirname, join } from "node:path"; +import { swallow } from "../util/errors.ts"; +import type { JsonObject } from "./codex-auth-file.ts"; + +export { + codexAuthFileForEnv, + codexOAuthAccessToken, + codexOAuthRefreshToken, + readCodexOAuthAuthFile, + sanitizedCodexOAuthAuth, +} from "./codex-auth-file.ts"; + +const heldOAuthLockPaths = new Set(); + +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 }); + } +} + +/** Atomically replace a Codex auth.json with 0600 permissions. */ +export function writeCodexOAuthAuthFile(path: string, auth: JsonObject): void { + writeJsonAtomically(path, auth); +} + +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 (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; + } + 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, + pollMs = 100, +): 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")}`; + try { + await handle.writeFile(owner); + heldOAuthLockPaths.add(path); + } 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, + 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); + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + if (readFileSync(path, "utf8") !== owner) { + heldOAuthLockPaths.delete(path); + return; + } + unlinkSync(path); + heldOAuthLockPaths.delete(path); + return; + } catch (error) { + if (attempt === 2) { + heldOAuthLockPaths.delete(path); + throw error; + } else await new Promise((resolveWait) => setTimeout(resolveWait, 10)); + } + } + }, + }; + } 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(); + }, pollMs); + signal?.addEventListener("abort", onAbort, { once: true }); + }); + } + } + throw new Error("timed out acquiring the Codex OAuth auth lock"); +} + diff --git a/src/harness/codex-harness.ts b/src/harness/codex-harness.ts index 46d79a12b..752673d8d 100644 --- a/src/harness/codex-harness.ts +++ b/src/harness/codex-harness.ts @@ -13,7 +13,9 @@ 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, readCodexOAuthAuthFile } from "./codex-auth.ts"; +import { childCodexOAuthAuth, fileCodexAuthStore, type CodexAuthStore } from "./codex-auth-store.ts"; import { defineHarness, type Harness, type HarnessTurnInput, type HarnessTurnResult } from "./harness.ts"; import { coreToolOptions, createPiTools, type PiToolsOptions, type ToolContextRef } from "./pi-tools.ts"; import type { McpToolDescriptor } from "../mcp/mcp-tool-service.ts"; @@ -36,6 +38,8 @@ export interface CodexHarnessOptions { backgroundJobTtlMs?: number; backgroundJobTtlMaxMs?: number; appServerStartTimeoutMs?: number; + /** Custodian of the ChatGPT-subscription Codex login (keychain-backed in production). */ + authStore?: CodexAuthStore; signals?: RunSignalStore; tasks?: TaskStore; } @@ -80,7 +84,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; @@ -102,7 +158,15 @@ type ActiveTurn = { stopped: boolean; }; -type Runtime = { server: CodexAppServer; jail: string }; +type Runtime = { + server: CodexAppServer; + jail: string; +}; +type StartingRuntime = { + promise: Promise; + abort: AbortController; + waiters: number; +}; const CODEX_START_TIMEOUT_MS = 30_000; const CODEX_NON_RETRYABLE_PATTERN = @@ -113,7 +177,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"]); @@ -187,20 +252,41 @@ const CODEX_ENV_PASSTHROUGH = [ "CODEX_ACCESS_TOKEN", ] as const; -export function codexChildEnv(source: NodeJS.ProcessEnv, jail: string): NodeJS.ProcessEnv { +export function codexChildEnv( + source: NodeJS.ProcessEnv, + jail: string, + auth?: Record | null, +): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = { HOME: jail, CODEX_HOME: join(jail, "codex-home"), }; + const authPath = codexAuthFileForEnv(source, true); + const fileAuth = () => (authPath ? readCodexOAuthAuthFile(authPath) : null); + const oauthAuth = auth !== undefined ? auth : fileAuth(); 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; } -export function prepareCodexHome(source: NodeJS.ProcessEnv, jail: string): string { +export function prepareCodexHome( + source: NodeJS.ProcessEnv, + jail: string, + auth?: Record | null, +): string { const target = join(jail, "codex-home"); mkdirSync(target, { recursive: true }); + const authPath = codexAuthFileForEnv(source, true); + const fileAuth = () => (authPath ? readCodexOAuthAuthFile(authPath) : null); + const oauthAuth = auth !== undefined ? auth : fileAuth(); + if (oauthAuth) { + // The child receives derived, ephemeral material only: no refresh token. + writeFileSync(join(target, "auth.json"), JSON.stringify(childCodexOAuthAuth(oauthAuth)), { mode: 0o600 }); + return target; + } if (source.OPENAI_API_KEY) { writeFileSync( join(target, "auth.json"), @@ -356,9 +442,17 @@ 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 authStore: CodexAuthStore | undefined = + opts.authStore ?? (authPath && readCodexOAuthAuthFile(authPath) ? fileCodexAuthStore(authPath) : undefined); + const oauthConfigured = Boolean(authStore); + const closeAbort = new AbortController(); 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,231 +515,411 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { } }; - const ensureRuntime = async (): Promise => { + const ensureRuntime = async ( + registerCancel?: (release: () => void) => void, + startupDeadline = 0, + ): 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 ((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); + if (runtime) { + 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); + } + await stale.server.close().catch(() => undefined); + rmSync(stale.jail, { recursive: true, force: true }); + } + let startup = starting; + if (startup?.abort.signal.aborted) { + if (starting === startup) starting = null; + startup = null; + } + if (!startup) { + const startupAbort = new AbortController(); + const promise = (async () => { + const jail = mkdtempSync(join(tmpdir(), "qm-codex-")); + const sourceAuth = authStore ? await authStore.load() : null; + let server!: CodexAppServer; + try { + if (oauthConfigured && !sourceAuth) + throw new Error(`Codex OAuth auth is unavailable (${authStore!.description})`); + prepareCodexHome(sourceEnv, jail, oauthConfigured ? sourceAuth : undefined); + 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, oauthConfigured ? sourceAuth : undefined), + onNotification: async (method, params) => { + const p = (params ?? {}) as Record; + const threadId = typeof p.threadId === "string" ? p.threadId : ""; + const state = active.get(threadId); + if (!state || state.server !== server) 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 || 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)) + 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 }; - } 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 }; - } + 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); + rmSync(jail, { recursive: true, force: true }); + throw error; + } + 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")), + initializationTimeout, + ); + }), + ]); + } 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; + } + runtime = { server, jail }; + runtimeCleanupRequested = false; + server.process.once("close", () => { + void (async () => { + const currentRuntime = runtime?.server === server; + const closeError = 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) rmSync(jail, { recursive: true, force: true }); + })().catch((error) => { + swallow("codex: provider close cleanup", error); + try { + rmSync(jail, { recursive: true, force: true }); + } catch (cleanupError) { + swallow("codex: provider close jail cleanup", cleanupError); + } + }); + }); + 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 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; 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); turn.cancel?.addEventListener("abort", onSetupCancel, { once: true }); const setupTimer = wallMs > 0 ? setTimeout(() => stopSetup(setupTimedOut), wallMs) : undefined; + const finishSetup = () => { + setupSettled = true; + if (setupTimer) clearTimeout(setupTimer); + turn.cancel?.removeEventListener("abort", onSetupCancel); + releaseSetupUser(); + }; 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); + finishSetup(); + 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 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, - }, - }, + const failSetup = async (error: unknown): Promise => { + finishSetup(); + await closeIdleRuntime(); + if (error === setupCancelled) return { reply: "", stopped: true }; + throw error; }; + try { + if (oauthConfigured) { + // Re-materialize fresh, centrally refreshed tokens for this turn. The + // store owns the refresh token; the child jail only ever holds + // short-lived derived material. + const sourceAuth = await awaitSetup(authStore!.load()); + if (!sourceAuth) { + rmSync(join(rt.jail, "codex-home", "auth.json"), { force: true }); + throw new NonRetryableTurnError(`Codex OAuth auth is unavailable (${authStore!.description})`); + } + if (runtime !== rt || rt.server.process.exitCode !== null) { + if (Date.now() >= runtimeRecoveryDeadline) + throw new NonRetryableTurnError("Codex OAuth runtime recovery timed out"); + rt = await awaitSetup( + ensureRuntime((release) => { + releaseStartupWaiter = release; + }, runtimeRecoveryDeadline), + ); + } else { + prepareCodexHome(sourceEnv, rt.jail, sourceAuth); + } + } + } catch (error) { + return failSetup(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 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) { + return failSetup(error); + } let started: { thread: { id: string }; model?: string }; try { - started = await awaitSetup(rt.server.request("thread/start", threadStartRequest)); + 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, + AbortSignal.any([authAcquireAbort.signal, closeAbort.signal, requestAbort.signal]), + ), + new Promise((_, reject) => { + requestTimer = setTimeout(() => { + requestAbort.abort(); + reject(new NonRetryableTurnError("Codex thread/start request timed out")); + }, requestTimeoutMs); + }), + ]).finally(() => { + if (requestTimer) clearTimeout(requestTimer); + }), + ); } catch (error) { - setupSettled = true; - if (setupTimer) clearTimeout(setupTimer); - turn.cancel?.removeEventListener("abort", onSetupCancel); - if (error === setupCancelled) return { reply: "", stopped: true }; - throw error; + return failSetup(error); } - const threadId = started.thread.id; - const replay = replayItems(reconstructMessagesFromHistory(turn.history)); - let userEntry: SessionEntry; + 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 +933,56 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { }), ); } catch (error) { - setupSettled = true; - if (setupTimer) clearTimeout(setupTimer); - turn.cancel?.removeEventListener("abort", onSetupCancel); - if (error === setupCancelled) return { reply: "", stopped: true }; - throw error; + return failSetup(error); } - 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, - }; + 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) { + return failSetup(error); + } active.set(threadId, state); + finishSetup(); const promptEnvelope = { threadStart: { ...threadStartRequest, @@ -713,20 +992,35 @@ 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 turn.recordLlmRequest({ - turnSeq: userEntry.seq, - step: 0, - model: selectedModel, - promptEnvelope, - 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, + promptEnvelope, + 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(() => { + recordAbort.abort(); + 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) { @@ -766,7 +1060,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(); @@ -792,10 +1087,40 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { ) : null; let timer: NodeJS.Timeout | undefined; + const turnStartAbort = new AbortController(); + let turnStartTimedOut = false; + const cleanupErrors: unknown[] = []; + let turnResult: HarnessTurnResult | undefined; try { - const response = await rt.server - .request<{ turn: CodexTurn }>("turn/start", { threadId, input, ...(model ? { model } : {}) }) + 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(() => { + turnStartTimedOut = true; + runtimeCleanupRequested = true; + turnStartAbort.abort(); + reject(new NonRetryableTurnError("Codex turn/start request timed out")); + }, turnStartTimeoutMs); + }), + ]) + .finally(() => { + 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; @@ -807,6 +1132,7 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { completed, new Promise((_, reject) => { timer = setTimeout(() => { + runtimeCleanupRequested = true; void interrupt(false); reject(setupTimedOut); }, remainingWallMs); @@ -822,39 +1148,63 @@ 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; + turnResult = { reply: "", stopped: true }; + } else { + throw error; + } } finally { if (timer) clearTimeout(timer); - await stopSignals?.(); - await recordRequest(); + try { + await stopSignals?.(); + } catch (error) { + cleanupErrors.push(error); + } 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) { + cleanupErrors.push(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 ( @@ -908,8 +1258,9 @@ 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")); diff --git a/src/harness/harness.ts b/src/harness/harness.ts index 77940e80a..19f211c89 100644 --- a/src/harness/harness.ts +++ b/src/harness/harness.ts @@ -43,7 +43,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 { @@ -86,7 +86,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/model/pi-models.ts b/src/model/pi-models.ts index b23d86c01..40ee057e0 100644 --- a/src/model/pi-models.ts +++ b/src/model/pi-models.ts @@ -234,6 +234,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 { @@ -257,9 +262,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/src/persistence/pg-pool.ts b/src/persistence/pg-pool.ts index 8c5ef21b1..5ad4f3d7e 100644 --- a/src/persistence/pg-pool.ts +++ b/src/persistence/pg-pool.ts @@ -7,10 +7,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; } @@ -127,12 +131,59 @@ 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); - return { rows: res.rows as Rows, rowCount: res.rowCount ?? 0 }; + async function query( + text: string, + params: unknown[] = [], + options: PgQueryOptions = {}, + ): Promise<{ rows: Rows; rowCount: number }> { + 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 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; + let released = false; + const cancel = () => { + 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) { + queryError = error instanceof Error ? error : new Error(String(error)); + throw error; + } finally { + options.signal?.removeEventListener("abort", cancel); + if (!released) client.release(queryError); + } } - 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 7c1abafbb..1c0cdc2bb 100644 --- a/src/sessions/postgres-session-store.ts +++ b/src/sessions/postgres-session-store.ts @@ -544,14 +544,14 @@ 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 envelope = promptEnvelopeBody(rec.promptEnvelope); if (envelope) { - await q("INSERT INTO llm_prompt_envelopes(hash, body, created_at) VALUES ($1,$2,$3) ON CONFLICT DO NOTHING", [ - envelope.hash, - envelope.body, - now(), - ]); + await q( + "INSERT INTO llm_prompt_envelopes(hash, body, created_at) VALUES ($1,$2,$3) ON CONFLICT DO NOTHING", + [envelope.hash, envelope.body, now()], + { signal }, + ); } const full: LlmRequestRecord = { id: randomUUID(), @@ -593,6 +593,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 9724fe26a..36675e995 100644 --- a/src/sessions/session-store.ts +++ b/src/sessions/session-store.ts @@ -457,7 +457,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; diff --git a/src/wiring.ts b/src/wiring.ts index 3499722b3..760109c77 100644 --- a/src/wiring.ts +++ b/src/wiring.ts @@ -1,7 +1,13 @@ import { mkdirSync } from "node:fs"; import { randomBytes, randomUUID } from "node:crypto"; import { join, resolve } from "node:path"; -import { baseModelProviders, configuredModelForHarness, providerKeysPresent, type Config } from "./config.ts"; +import { + baseModelProviders, + configuredModelForHarness, + harnessCarriedModelAuth, + providerKeysPresent, + type Config, +} from "./config.ts"; import type { ServerDeps } from "./api/deps.ts"; import { createIdentityService, type DeactivationRecord, type IdentityService } from "./identity/identity-service.ts"; import { @@ -179,6 +185,8 @@ import type { SessionStore } from "./sessions/session-store.ts"; import { createMockHarness } from "./harness/mock-harness.ts"; import { createOpenCodeHarness, openCodeHarnessConfigOptions } from "./harness/opencode-harness.ts"; import { createCodexHarness, codexHarnessConfigOptions } from "./harness/codex-harness.ts"; +import { keychainCodexAuthStore } from "./harness/codex-auth-store.ts"; +import { keychainHarnessAuthEnv } from "./credentials/harness-auth-env.ts"; import { createClaudeHarness, claudeHarnessConfigOptions } from "./harness/claude-harness.ts"; import { createPiHarness, piHarnessConfigOptions } from "./harness/pi-harness.ts"; import { createHarnessRouter, resolveRuntimeChoiceDurable } from "./harness/harness-router.ts"; @@ -804,8 +812,39 @@ export function buildApp( }, }), ], - ["codex", createCodexHarness({ ...codexHarnessConfigOptions(config), signals: runSignals, tasks, mcpTools })], - ["claude", createClaudeHarness({ ...claudeHarnessConfigOptions(config), signals: runSignals, tasks, mcpTools })], + [ + "codex", + createCodexHarness({ + ...codexHarnessConfigOptions(config), + // Keychain custody: the subscription login lives encrypted in its + // owner's keychain; core refreshes it centrally and hands the harness + // ephemeral derived material. The credential can be (re)registered at + // runtime — resolution happens on every load. + ...(config.codexAuthCredential && keychain + ? { authStore: keychainCodexAuthStore({ keychain, credentialId: config.codexAuthCredential }) } + : {}), + signals: runSignals, + tasks, + mcpTools, + }), + ], + [ + "claude", + createClaudeHarness({ + ...claudeHarnessConfigOptions(config), + ...(config.claudeAuthCredential && keychain + ? { + authEnv: keychainHarnessAuthEnv(keychain, config.claudeAuthCredential, [ + "CLAUDE_CODE_OAUTH_TOKEN", + "ANTHROPIC_AUTH_TOKEN", + ]), + } + : {}), + signals: runSignals, + tasks, + mcpTools, + }), + ], ["mock", createMockHarness()], ]); const fallbackHarness = config.harness as HarnessId; @@ -1567,6 +1606,7 @@ export function serverDeps( slackEnvBotToken?: string, ): Omit { const configuredModel = configuredModelForHarness(config, config.harness); + const carriedModelAuth = harnessCarriedModelAuth(config); return { production: config.production, allowUnauthenticatedCore: config.allowUnauthenticatedCore, @@ -1577,6 +1617,7 @@ export function serverDeps( ...(built.replayDedupe ? { replayDedupe: built.replayDedupe } : {}), config: built.config, ...(configuredModel ? { baseModelDefault: configuredModel } : {}), + ...(carriedModelAuth ? { harnessCarriedModelAuth: carriedModelAuth } : {}), modelProviders: modelProviderAvailabilityFor(config.harness, providerKeysPresent(config)), providerKeys: providerKeysPresent(config), modelCredentials: built.modelCredentials, 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-auth-store.test.ts b/test/codex-auth-store.test.ts new file mode 100644 index 000000000..83b7cf658 --- /dev/null +++ b/test/codex-auth-store.test.ts @@ -0,0 +1,232 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + childCodexOAuthAuth, + codexOAuthAccessTokenExpiresAt, + codexOAuthAuthFromValue, + fileCodexAuthStore, + keychainCodexAuthStore, +} from "../src/harness/codex-auth-store.ts"; +import type { CredentialFile, Keychain, KeychainCredentialMeta } from "../src/credentials/keychain.ts"; + +function jwt(payload: Record, header: Record = { alg: "RS256" }): string { + const enc = (v: unknown) => Buffer.from(JSON.stringify(v)).toString("base64url"); + return `${enc(header)}.${enc(payload)}.sig`; +} + +function idToken(accountId: string): string { + return jwt({ + iss: "https://auth.openai.com", + "https://api.openai.com/auth": { chatgpt_account_id: accountId }, + }); +} + +function accessToken(accountId: string, expSec: number, marker = "a"): string { + return jwt({ + iss: "https://auth.openai.com", + exp: expSec, + marker, + "https://api.openai.com/auth": { chatgpt_account_id: accountId }, + }); +} + +function authJson( + accountId: string, + expSec: number, + refreshToken = "refresh-1", + marker = "a", +): Record { + return { + auth_mode: "chatgpt", + tokens: { + access_token: accessToken(accountId, expSec, marker), + refresh_token: refreshToken, + id_token: idToken(accountId), + account_id: accountId, + }, + }; +} + +const NOW = 1_900_000_000_000; +const FRESH_EXP = Math.floor(NOW / 1000) + 3600; +const STALE_EXP = Math.floor(NOW / 1000) + 60; + +interface FakeKeychainState { + meta: KeychainCredentialMeta; + files: CredentialFile[]; + saves: Array<{ ownerId: string; service: string; files?: CredentialFile[] }>; +} + +function fakeKeychain(state: FakeKeychainState): Keychain { + return { + async getCredential(id: string) { + return id === state.meta.id ? state.meta : null; + }, + async materializeOwnFiles(ownerId: string) { + if (ownerId !== state.meta.ownerId) return []; + return [{ credentialId: state.meta.id, ownerId, service: state.meta.service, files: state.files }]; + }, + async save(input: { ownerId: string; service: string; files?: CredentialFile[] }) { + state.saves.push(input); + if (input.files) state.files = input.files; + return state.meta; + }, + } as unknown as Keychain; +} + +function credFiles(auth: Record): CredentialFile[] { + return [{ path: ".codex/auth.json", contentBase64: Buffer.from(JSON.stringify(auth)).toString("base64") }]; +} + +const META = { + id: "cred-1", + ownerId: "owner@example.com", + service: "codex-chatgpt", + kind: "file", + fingerprint: "f", + createdAt: 0, + updatedAt: 0, +} as KeychainCredentialMeta; + +test("child auth material never includes the refresh token", () => { + const auth = authJson("acct", FRESH_EXP); + const child = childCodexOAuthAuth(auth); + const tokens = child.tokens as Record; + assert.equal(tokens.refresh_token, undefined); + assert.equal(tokens.access_token, (auth.tokens as Record).access_token); + assert.equal(tokens.id_token, (auth.tokens as Record).id_token); + assert.equal(child.auth_mode, "chatgpt"); +}); + +test("codexOAuthAuthFromValue validates shape and account binding", () => { + assert.ok(codexOAuthAuthFromValue(authJson("acct", FRESH_EXP))); + assert.equal(codexOAuthAuthFromValue({ auth_mode: "apikey" }), null); + const missingRefresh = authJson("acct", FRESH_EXP); + delete (missingRefresh.tokens as Record).refresh_token; + assert.equal(codexOAuthAuthFromValue(missingRefresh), null); +}); + +test("keychain store returns fresh auth without refreshing", async () => { + const state: FakeKeychainState = { meta: META, files: credFiles(authJson("acct", FRESH_EXP)), saves: [] }; + const store = keychainCodexAuthStore({ + keychain: fakeKeychain(state), + credentialId: "cred-1", + now: () => NOW, + fetchImpl: () => { + throw new Error("must not refresh"); + }, + }); + const auth = await store.load(); + assert.ok(auth); + assert.equal(state.saves.length, 0); +}); + +test("keychain store refreshes a stale access token centrally and persists rotation", async () => { + const state: FakeKeychainState = { + meta: META, + files: credFiles(authJson("acct", STALE_EXP, "refresh-1")), + saves: [], + }; + const calls: Array> = []; + const store = keychainCodexAuthStore({ + keychain: fakeKeychain(state), + credentialId: "cred-1", + now: () => NOW, + fetchImpl: (async (_url: string, init: { body: string }) => { + calls.push(JSON.parse(init.body) as Record); + return { + ok: true, + json: async () => ({ + access_token: accessToken("acct", FRESH_EXP, "refreshed"), + id_token: idToken("acct"), + refresh_token: "refresh-2", + }), + }; + }) as unknown as typeof fetch, + }); + const auth = await store.load(); + assert.ok(auth); + assert.equal(calls.length, 1); + assert.equal(calls[0]?.grant_type, "refresh_token"); + assert.equal(calls[0]?.refresh_token, "refresh-1"); + const tokens = auth!.tokens as Record; + assert.equal(tokens.refresh_token, "refresh-2"); + assert.equal(state.saves.length, 1); + const persisted = JSON.parse( + Buffer.from(state.saves[0]!.files![0]!.contentBase64, "base64").toString("utf8"), + ) as Record; + assert.equal((persisted.tokens as Record).refresh_token, "refresh-2"); +}); + +test("keychain store refuses a refresh that switches accounts", async () => { + const state: FakeKeychainState = { meta: META, files: credFiles(authJson("acct", STALE_EXP)), saves: [] }; + const store = keychainCodexAuthStore({ + keychain: fakeKeychain(state), + credentialId: "cred-1", + now: () => NOW, + fetchImpl: (async () => ({ + ok: true, + json: async () => ({ + access_token: accessToken("other-acct", FRESH_EXP), + id_token: idToken("other-acct"), + refresh_token: "refresh-2", + }), + })) as unknown as typeof fetch, + }); + const auth = await store.load(); + // Falls back to the (stale) stored auth rather than adopting a different account. + assert.ok(auth); + assert.equal((auth!.tokens as Record).refresh_token, "refresh-1"); + assert.equal(state.saves.length, 0); +}); + +test("keychain store surfaces null when the credential is missing", async () => { + const state: FakeKeychainState = { meta: META, files: credFiles(authJson("acct", FRESH_EXP)), saves: [] }; + const store = keychainCodexAuthStore({ keychain: fakeKeychain(state), credentialId: "other", now: () => NOW }); + assert.equal(await store.load(), null); +}); + +test("file store refreshes a stale token and writes back under the lock with compare-and-set", async (t) => { + const dir = mkdtempSync(join(tmpdir(), "qm-codex-store-file-")); + t.after(() => rmSync(dir, { recursive: true, force: true })); + const path = join(dir, "auth.json"); + writeFileSync(path, JSON.stringify(authJson("acct", STALE_EXP, "refresh-1"))); + chmodSync(path, 0o600); + const store = fileCodexAuthStore( + path, + (async () => ({ + ok: true, + json: async () => ({ + access_token: accessToken("acct", FRESH_EXP, "refreshed"), + id_token: idToken("acct"), + refresh_token: "refresh-2", + }), + })) as unknown as typeof fetch, + () => NOW, + ); + const auth = await store.load(); + assert.ok(auth); + assert.equal((auth!.tokens as Record).refresh_token, "refresh-2"); + const persisted = JSON.parse(readFileSync(path, "utf8")) as Record; + assert.equal((persisted.tokens as Record).refresh_token, "refresh-2"); + assert.equal(codexOAuthAccessTokenExpiresAt(persisted), FRESH_EXP * 1000); +}); + +test("file store keeps serving current auth when the refresh endpoint fails", async (t) => { + const dir = mkdtempSync(join(tmpdir(), "qm-codex-store-file-fail-")); + t.after(() => rmSync(dir, { recursive: true, force: true })); + const path = join(dir, "auth.json"); + writeFileSync(path, JSON.stringify(authJson("acct", STALE_EXP, "refresh-1"))); + chmodSync(path, 0o600); + const store = fileCodexAuthStore( + path, + (async () => ({ ok: false, status: 503 })) as unknown as typeof fetch, + () => NOW, + ); + const auth = await store.load(); + assert.ok(auth); + assert.equal((auth!.tokens as Record).refresh_token, "refresh-1"); +}); diff --git a/test/codex-harness.test.ts b/test/codex-harness.test.ts index 9b9cf957e..b075b0213 100644 --- a/test/codex-harness.test.ts +++ b/test/codex-harness.test.ts @@ -1,7 +1,16 @@ 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, + utimesSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { createRequire } from "node:module"; import { @@ -23,8 +32,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 } 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" }] }, @@ -33,6 +44,21 @@ 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, marker = ""): string { + const payload = Buffer.from( + JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: accountId }, marker }), + ).toString("base64url"); + return `header.${payload}.signature`; +} + +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", () => { const longId = "tool-call-".repeat(9); const normalized = codexReplayCallId(longId); @@ -164,6 +190,229 @@ 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 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"); + const accessToken = oauthAccessToken("startup-account", "startup-after"); + 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 = ${JSON.stringify(accessToken)}; +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"); + const accessToken = oauthAccessToken("shared-account", token); + 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(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: [] } } }); + 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", @@ -175,7 +424,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 }); @@ -292,7 +541,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", @@ -300,7 +549,341 @@ 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 as ephemeral child material without the refresh token", 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: oauthAccessToken("account-before", "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, + oauthAccessToken("account-before", "before"), + ); + assert.equal((childAuth.tokens as Record).account_id, "account-before"); + // The child never receives the long-lived credential: only the store refreshes. + assert.equal((childAuth.tokens as Record).refresh_token, undefined); + // Nothing a child writes ever flows back to the source of truth. + writeFileSync( + childAuthFile, + JSON.stringify({ + ...childAuth, + tokens: { + access_token: oauthAccessToken("account-before", "after"), + refresh_token: "refresh-forged", + account_id: "account-before", + id_token: oauthIdToken("account-before"), + }, + }), + ); + const persisted = JSON.parse(readFileSync(authFile, "utf8")) as Record; + assert.equal((persisted.tokens as Record).refresh_token, "refresh-before"); + assert.equal( + (persisted.tokens as Record).access_token, + oauthAccessToken("account-before", "before"), + ); + // A stale lock left behind by a dead process is recovered, not honored forever. + const liveLock = `${authFile}.lock`; + writeFileSync(liveLock, String(process.pid)); + utimesSync(liveLock, new Date(0), new Date(0)); + const recoveredLock = await acquireCodexOAuthAuthLock(authFile, undefined, 1_000); + assert.equal(recoveredLock.isHeld(), true); + await recoveredLock.release(); + assert.equal(existsSync(liveLock), false); + + 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); + 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) => { + 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 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", () => { @@ -315,7 +898,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 () => { @@ -382,7 +965,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, }); @@ -409,11 +992,224 @@ 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 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( + 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-before"); +}); + + + +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 }); + 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("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("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"), /OAuth auth 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 () => { @@ -496,6 +1292,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", () => { @@ -543,7 +1343,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 () => { @@ -573,7 +1373,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, @@ -605,6 +1405,87 @@ 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); +}); + +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"); @@ -623,7 +1504,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); @@ -636,43 +1517,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 3b0dd58b1..8b5271c49 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 { @@ -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`), @@ -214,9 +221,39 @@ 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", + id_token: oauthIdToken("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 +352,31 @@ 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", + id_token: oauthIdToken("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 +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" }, + 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" }, @@ -364,6 +431,12 @@ 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, ""); + 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"); diff --git a/test/model-credential-route.test.ts b/test/model-credential-route.test.ts index a23343525..483f844b1 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, harnessCarriedModelAuth } 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"; @@ -23,23 +24,18 @@ 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), - }, + ...(harnessCarriedModelAuth(appConfig) ? { harnessCarriedModelAuth: harnessCarriedModelAuth(appConfig) } : {}), + providerKeys: providerKeysPresent(appConfig), admin: built.admin, auditLog: built.auditLog, }); @@ -421,6 +417,75 @@ 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("a claude harness with an OAuth token counts as configured without corrupting store statuses", async () => { + const srv = start({ + harness: "claude", + claudeProcessEnv: { CLAUDE_CODE_OAUTH_TOKEN: "sk-ant-oat-test" } as NodeJS.ProcessEnv, + }); + 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); + + const providers = await fetch(`${srv.base}/v1/admin/model-providers`, { headers: ADMIN }); + assert.equal(providers.status, 200); + const body = (await providers.json()) as { + providers: Array<{ provider: string; configured: boolean; source: string }>; + harnessAuth?: { harnessId: string; provider: string }; + }; + assert.deepEqual(body.harnessAuth, { harnessId: "claude", provider: "anthropic" }); + assert.deepEqual( + body.providers.find((item) => item.provider === "anthropic"), + { provider: "anthropic", configured: false, source: "absent" }, + ); + } finally { + await srv.close(); + } +}); + +test("a claude harness without any token stays unconfigured", async () => { + const srv = start({ harness: "claude" }); + try { + const surface = await fetch(`${srv.base}/v1/surface-config`); + assert.equal(surface.status, 200); + assert.equal(((await surface.json()) as { modelProviderConfigured?: boolean }).modelProviderConfigured, false); + + const providers = await fetch(`${srv.base}/v1/admin/model-providers`, { headers: ADMIN }); + assert.equal(providers.status, 200); + assert.equal("harnessAuth" in ((await providers.json()) as Record), false); + } 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" });