Fix F7F8-codex for #17277

This commit is contained in:
Neil
2026-08-29 15:51:49 -07:00
parent 5232c9c135
commit 301ef38fc8
3 changed files with 116 additions and 7 deletions
@@ -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, unknown>): string {
const payload = Buffer.from(JSON.stringify(claims), 'utf-8').toString('base64url')
return `header.${payload}.signature`
}
function homeWithAuthJson(auth: Record<string, unknown>): 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'
})
})
})
@@ -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<string, unknown>).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<string, unknown>
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')
@@ -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')
)
}