diff --git a/src/main/codex-accounts/codex-account-identity-api-key-guard.test.ts b/src/main/codex-accounts/codex-account-identity-api-key-guard.test.ts new file mode 100644 index 00000000000..c522b892ebc --- /dev/null +++ b/src/main/codex-accounts/codex-account-identity-api-key-guard.test.ts @@ -0,0 +1,89 @@ +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { CodexAccountIdentity } from './codex-account-identity' +import { readCodexAuthIdentity } from './codex-auth-identity' + +/** Encodes a JWT whose payload carries the given claims; only the payload segment is read. */ +function idTokenWithClaims(claims: Record): string { + const payload = Buffer.from(JSON.stringify(claims), 'utf-8').toString('base64url') + return `header.${payload}.signature` +} + +function homeWithAuthJson(auth: Record): string { + const home = mkdtempSync(join(tmpdir(), 'codex-identity-')) + writeFileSync(join(home, 'auth.json'), JSON.stringify(auth), 'utf-8') + return home +} + +describe('managed-home Codex identity', () => { + const identity = new CodexAccountIdentity((candidatePath) => candidatePath) + + it('resolves no identity for an API-key login that still holds a stale tokens blob', () => { + // Why: the stale OAuth email is not who this credential authenticates as, and it is + // the claim used to prove a shared-home auth.json belongs to the selected account. + const home = homeWithAuthJson({ + OPENAI_API_KEY: 'sk-test', + tokens: { + account_id: 'stale-account', + id_token: idTokenWithClaims({ email: 'stale@example.com' }) + } + }) + + expect(identity.readFromHome(home, 'account-1')).toEqual({ + email: null, + providerAccountId: null, + workspaceLabel: null, + workspaceAccountId: null + }) + }) + + it('still resolves an OAuth identity when no API key is declared', () => { + const home = homeWithAuthJson({ + tokens: { + account_id: 'real-account', + id_token: idTokenWithClaims({ email: 'real@example.com' }) + } + }) + + expect(identity.readFromHome(home, 'account-1')).toMatchObject({ + email: 'real@example.com', + providerAccountId: 'real-account' + }) + }) +}) + +describe('readCodexAuthIdentity account-id fallback', () => { + it('falls through to the auth claim for providerAccountId when tokens.account_id is blank', () => { + // Why: a blank token field must not end the fallback chain — an empty string is not nullish. + const contents = JSON.stringify({ + tokens: { + account_id: ' ', + id_token: idTokenWithClaims({ + 'https://api.openai.com/auth': { chatgpt_account_id: 'claim-account' } + }) + } + }) + + expect(readCodexAuthIdentity(contents)).toMatchObject({ + providerAccountId: 'claim-account', + // workspaceAccountId's chain skips the auth claim and ends at the top-level one. + workspaceAccountId: null + }) + }) + + it('falls through to the top-level claim for workspaceAccountId when tokens.account_id is blank', () => { + const contents = JSON.stringify({ + tokens: { + account_id: '', + id_token: idTokenWithClaims({ chatgpt_account_id: 'top-level-account' }) + } + }) + + expect(readCodexAuthIdentity(contents)).toMatchObject({ + providerAccountId: 'top-level-account', + workspaceAccountId: 'top-level-account' + }) + }) +}) diff --git a/src/main/codex-accounts/codex-account-identity.ts b/src/main/codex-accounts/codex-account-identity.ts index 78e465d94dc..30b42743ef7 100644 --- a/src/main/codex-accounts/codex-account-identity.ts +++ b/src/main/codex-accounts/codex-account-identity.ts @@ -8,6 +8,15 @@ import { ManagedCodexHomeTemporarilyUnavailableError } from './host-codex-manage export type ResolvedCodexIdentity = CodexAuthIdentity +/** API-key logins carry no OAuth identity even when a stale `tokens` blob is still present. */ +function declaresApiKeyCredential(parsed: unknown): boolean { + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return false + } + const apiKey = (parsed as Record).OPENAI_API_KEY + return typeof apiKey === 'string' && apiKey.trim() !== '' +} + export class CodexAccountIdentity { constructor( private readonly assertManagedHomePath: ( @@ -33,8 +42,9 @@ export class CodexAccountIdentity { } throw new ManagedCodexHomeTemporarilyUnavailableError(undefined, { cause: error }) } + let parsed: unknown try { - JSON.parse(contents) + parsed = JSON.parse(contents) } catch { // Why: a raw SyntaxError echoes credential bytes into logs/error UI; a // corrupt auth.json must fail loudly but without them (same sanitization @@ -45,6 +55,14 @@ export class CodexAccountIdentity { // claims. Returning nulls causes the caller to fail with a clear // "could not resolve the account email" error rather than crashing // on missing nested token fields. + if (declaresApiKeyCredential(parsed)) { + return { + email: null, + providerAccountId: null, + workspaceLabel: null, + workspaceAccountId: null + } + } const identity = readCodexAuthIdentity(contents) if (!identity) { return { @@ -96,8 +114,7 @@ export class CodexAccountIdentity { console.warn('[codex-accounts] System-default Codex auth has an unexpected format') return this.systemDefaultIdentity(true, 'none') } - const raw = parsed as Record - if (typeof raw.OPENAI_API_KEY === 'string' && raw.OPENAI_API_KEY.trim() !== '') { + if (declaresApiKeyCredential(parsed)) { // Why: API-key/custom-provider logins carry no OAuth identity or ChatGPT // usage. Surface them as a custom provider, not a blank/broken row. return this.systemDefaultIdentity(true, 'api-key') diff --git a/src/main/codex-accounts/codex-auth-identity.ts b/src/main/codex-accounts/codex-auth-identity.ts index 06303c22112..0455b5e481f 100644 --- a/src/main/codex-accounts/codex-auth-identity.ts +++ b/src/main/codex-accounts/codex-auth-identity.ts @@ -167,14 +167,18 @@ export function readCodexAuthIdentity(contents: string): CodexAuthIdentity | nul const payload = idToken ? parseJwtPayload(idToken) : null const authClaims = readRecordClaim(payload, 'https://api.openai.com/auth') const profileClaims = readRecordClaim(payload, 'https://api.openai.com/profile') + // Why: normalize before the fallback chains, not after — a blank tokens.account_id + // must fall through to the JWT claims rather than ending the chain on an empty string. + const tokenAccountId = normalizeField( + readStringClaim(tokens, 'account_id') ?? readStringClaim(tokens, 'accountId') + ) return { email: normalizeField( readStringClaim(payload, 'email') ?? readStringClaim(profileClaims, 'email') ), providerAccountId: normalizeField( - readStringClaim(tokens, 'account_id') ?? - readStringClaim(tokens, 'accountId') ?? + tokenAccountId ?? readStringClaim(authClaims, 'chatgpt_account_id') ?? readStringClaim(payload, 'chatgpt_account_id') ), @@ -184,8 +188,7 @@ export function readCodexAuthIdentity(contents: string): CodexAuthIdentity | nul ), workspaceAccountId: normalizeField( readStringClaim(authClaims, 'workspace_account_id') ?? - readStringClaim(tokens, 'account_id') ?? - readStringClaim(tokens, 'accountId') ?? + tokenAccountId ?? readStringClaim(payload, 'chatgpt_account_id') ) }