mirror of
https://github.com/stablyai/orca.git
synced 2026-10-24 08:03:58 +00:00
Fix F7F8-codex for #17277
This commit is contained in:
@@ -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')
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user