Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 58 additions & 15 deletions src/backends/codex/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> | undefined,
logWriter: LogWriter,
): Promise<string | undefined> {
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;
}

/**
Expand All @@ -433,6 +472,10 @@ async function captureRefreshedToken(
originalJson: string | undefined,
logWriter: LogWriter,
): Promise<void> {
// 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;
Expand Down
108 changes: 108 additions & 0 deletions tests/unit/backends/codex.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof vi.fn>).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
Expand Down
Loading