From 681e60f90b0b565bfcfb604568e798f56413bc0b Mon Sep 17 00:00:00 2001 From: Zbigniew Sobiecki Date: Mon, 22 Jun 2026 16:15:16 +0200 Subject: [PATCH] fix(codex): authenticate a bare OPENAI_API_KEY via synthesized auth.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex authenticates only from ~/.codex/auth.json — it does not read OPENAI_API_KEY from the environment — so the bare-API-key path never sent a bearer (401 "Missing bearer or basic authentication"). Only the CODEX_AUTH_JSON subscription path worked. writeCodexAuthFile now synthesizes the apikey auth.json that codex login --with-api-key writes ({auth_mode:apikey,OPENAI_API_KEY:...}) when only OPENAI_API_KEY is set, mirroring claude-code's ANTHROPIC_API_KEY support. CODEX_AUTH_JSON keeps precedence and stays refreshable; the API-key path returns undefined so its synthesized blob is never persisted back into the subscription credential slot. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/backends/codex/index.ts | 73 +++++++++++++++----- tests/unit/backends/codex.test.ts | 108 ++++++++++++++++++++++++++++++ 2 files changed, 166 insertions(+), 15 deletions(-) diff --git a/src/backends/codex/index.ts b/src/backends/codex/index.ts index f20bbb57..f5ff6177 100644 --- a/src/backends/codex/index.ts +++ b/src/backends/codex/index.ts @@ -397,31 +397,70 @@ export function buildArgs( } /** - * Write ~/.codex/auth.json for Codex subscription auth (ChatGPT Plus/Pro). - * Returns the written JSON string so callers can detect post-run token refreshes. - * Returns undefined if CODEX_AUTH_JSON is not present (API key auth path — no-op). + * Build the auth.json contents that `codex login --with-api-key` writes for a + * bare OpenAI API key. Codex authenticates ONLY from ~/.codex/auth.json — it does + * NOT read OPENAI_API_KEY from the environment — so a bare API key must be + * materialised into this file for codex to send a bearer token. + */ +function synthesizeApiKeyAuthJson(apiKey: string): string { + return JSON.stringify({ auth_mode: 'apikey', OPENAI_API_KEY: apiKey }); +} + +/** + * Write ~/.codex/auth.json so Codex can authenticate. Supports BOTH auth modes, + * mirroring claude-code's ANTHROPIC_API_KEY / CLAUDE_CODE_OAUTH_TOKEN duality: + * - subscription token via CODEX_AUTH_JSON (ChatGPT Plus/Pro), written verbatim; + * - bare API key via OPENAI_API_KEY, synthesized into the apikey auth.json shape. + * + * Returns the written JSON string ONLY for the subscription path, so the caller + * (captureRefreshedToken) can persist a token the Codex CLI rotated mid-run. The + * API-key path returns undefined: API keys never rotate, and returning undefined + * guarantees the synthesized blob is never written back into the CODEX_AUTH_JSON + * credential slot. CODEX_AUTH_JSON takes precedence when both are configured. */ async function writeCodexAuthFile( projectSecrets: Record | undefined, logWriter: LogWriter, ): Promise { const authJson = projectSecrets?.CODEX_AUTH_JSON; - if (!authJson) { - logWriter('DEBUG', 'No CODEX_AUTH_JSON credential — using API key auth', {}); - return undefined; + const apiKey = projectSecrets?.OPENAI_API_KEY; + + // 1. Subscription token wins when present and valid JSON. + if (authJson) { + let valid = true; + try { + JSON.parse(authJson); + } catch { + valid = false; + } + if (valid) { + await mkdir(CODEX_AUTH_DIR, { recursive: true }); + await writeFile(CODEX_AUTH_FILE, authJson, { mode: 0o600 }); + logWriter('INFO', 'Writing ~/.codex/auth.json for subscription auth', {}); + return authJson; + } + logWriter( + 'WARN', + 'CODEX_AUTH_JSON is not valid JSON — falling back to OPENAI_API_KEY if present', + {}, + ); } - try { - JSON.parse(authJson); - } catch { - logWriter('WARN', 'CODEX_AUTH_JSON is not valid JSON — skipping subscription auth', {}); - return undefined; + // 2. Bare OpenAI API key — synthesize the apikey auth.json codex requires. + if (apiKey) { + await mkdir(CODEX_AUTH_DIR, { recursive: true }); + await writeFile(CODEX_AUTH_FILE, synthesizeApiKeyAuthJson(apiKey), { mode: 0o600 }); + logWriter('INFO', 'Writing ~/.codex/auth.json for API key auth', {}); + return undefined; // API keys do not rotate — nothing to capture } - await mkdir(CODEX_AUTH_DIR, { recursive: true }); - await writeFile(CODEX_AUTH_FILE, authJson, { mode: 0o600 }); - logWriter('INFO', 'Writing ~/.codex/auth.json for subscription auth', {}); - return authJson; + // 3. Neither configured. + logWriter( + 'DEBUG', + 'No CODEX_AUTH_JSON or OPENAI_API_KEY credential — codex auth not configured', + {}, + ); + return undefined; } /** @@ -433,6 +472,10 @@ async function captureRefreshedToken( originalJson: string | undefined, logWriter: LogWriter, ): Promise { + // originalJson is undefined on the API-key path (writeCodexAuthFile returns + // undefined there) and when no auth was configured — so a synthesized apikey + // auth.json is never persisted back into the CODEX_AUTH_JSON credential slot. + // Only a rotated subscription token is captured. if (!originalJson) return; let newJson: string; diff --git a/tests/unit/backends/codex.test.ts b/tests/unit/backends/codex.test.ts index 37405c12..2fe30beb 100644 --- a/tests/unit/backends/codex.test.ts +++ b/tests/unit/backends/codex.test.ts @@ -1252,6 +1252,114 @@ describe('Codex subscription auth', () => { ); }); + // --- Bare OpenAI API key auth (synthesized auth.json) --- + // Codex does NOT read OPENAI_API_KEY from the environment; it authenticates + // only from ~/.codex/auth.json. cascade must synthesize the apikey auth.json + // shape that `codex login --with-api-key` writes so a bare key authenticates. + const API_KEY = 'sk-proj-test-key-123'; + const SYNTHESIZED_AUTH = JSON.stringify({ auth_mode: 'apikey', OPENAI_API_KEY: API_KEY }); + + it('synthesizes apikey auth.json from OPENAI_API_KEY when CODEX_AUTH_JSON is absent', async () => { + const engine = new CodexEngine(); + const input = makeInput({ + repoDir: workspaceDir, + projectSecrets: { OPENAI_API_KEY: API_KEY }, + }); + + await engine.execute(input); + + expect(mockWriteFile).toHaveBeenCalledWith( + expect.stringContaining('auth.json'), + SYNTHESIZED_AUTH, + { mode: 0o600 }, + ); + }); + + it('never persists the synthesized API-key auth.json back into CODEX_AUTH_JSON', async () => { + // Even if the on-disk auth.json "changes" after the run, the API-key path + // must never write it into the subscription credential slot. + mockReadFile.mockResolvedValue( + JSON.stringify({ auth_mode: 'apikey', OPENAI_API_KEY: 'sk-proj-rotated' }), + ); + + const engine = new CodexEngine(); + const input = makeInput({ + repoDir: workspaceDir, + projectSecrets: { OPENAI_API_KEY: API_KEY }, + }); + + await engine.execute(input); + + expect(mockWriteProjectCredential).not.toHaveBeenCalled(); + }); + + it('prefers CODEX_AUTH_JSON over OPENAI_API_KEY when both are set', async () => { + const engine = new CodexEngine(); + const input = makeInput({ + repoDir: workspaceDir, + projectSecrets: { OPENAI_API_KEY: API_KEY, CODEX_AUTH_JSON: AUTH_JSON }, + }); + + await engine.execute(input); + + expect(mockWriteFile).toHaveBeenCalledWith(expect.stringContaining('auth.json'), AUTH_JSON, { + mode: 0o600, + }); + expect(mockWriteFile).not.toHaveBeenCalledWith( + expect.stringContaining('auth.json'), + SYNTHESIZED_AUTH, + { mode: 0o600 }, + ); + }); + + it('writes no auth.json when neither CODEX_AUTH_JSON nor OPENAI_API_KEY is set', async () => { + const engine = new CodexEngine(); + const input = makeInput({ repoDir: workspaceDir, projectSecrets: {} }); + + await engine.execute(input); + + expect(mockWriteFile).not.toHaveBeenCalledWith( + expect.stringContaining('auth.json'), + expect.anything(), + expect.anything(), + ); + }); + + it('falls back to the synthesized API-key auth.json when CODEX_AUTH_JSON is invalid JSON', async () => { + const engine = new CodexEngine(); + const input = makeInput({ + repoDir: workspaceDir, + projectSecrets: { OPENAI_API_KEY: API_KEY, CODEX_AUTH_JSON: 'sk-proj-not-json' }, + }); + + await engine.execute(input); + + expect(mockWriteFile).toHaveBeenCalledWith( + expect.stringContaining('auth.json'), + SYNTHESIZED_AUTH, + { mode: 0o600 }, + ); + expect(input.logWriter).toHaveBeenCalledWith( + 'WARN', + expect.stringContaining('not valid JSON'), + {}, + ); + }); + + it('never logs the API key in the clear', async () => { + const engine = new CodexEngine(); + const input = makeInput({ + repoDir: workspaceDir, + projectSecrets: { OPENAI_API_KEY: API_KEY }, + }); + + await engine.execute(input); + + for (const call of (input.logWriter as ReturnType).mock.calls) { + expect(JSON.stringify(call)).not.toContain(API_KEY); + } + }); + // Prod regression 2026-05-09 (runs 8b000cd6 + d8e31665, both // cascade/implementation/codex): codex's persistent bash session breaks // with `ERROR codex_core::tools::router: error=write_stdin failed: stdin