merge(codex-ma): integrate PR-B identity switcher

This commit is contained in:
Brennan Benson
2026-07-17 03:22:48 -07:00
11 changed files with 373 additions and 14 deletions
+183
View File
@@ -1830,4 +1830,187 @@ describe('CodexAccountService config sync', () => {
vi.doUnmock('../codex-cli/command')
}
})
describe('system-default identity', () => {
const systemCodexHomeDir = () => join(testState.fakeHomeDir, '.codex')
const systemAuthPath = () => join(systemCodexHomeDir(), 'auth.json')
async function newService() {
const store = createStore(createSettings())
const rateLimits = createRateLimits()
const runtimeHome = createRuntimeHome()
const { CodexAccountService } = await import('./service')
return new CodexAccountService(store as never, rateLimits as never, runtimeHome as never)
}
function withoutEnvApiKey<T>(run: () => T): T {
const previous = process.env.OPENAI_API_KEY
delete process.env.OPENAI_API_KEY
try {
return run()
} finally {
if (previous === undefined) {
delete process.env.OPENAI_API_KEY
} else {
process.env.OPENAI_API_KEY = previous
}
}
}
it('reports the real ~/.codex OAuth identity for the system default', async () => {
writeFileSync(
systemAuthPath(),
createCodexAuthJson('real@home.dev', 'acct-real', 'refresh-real'),
'utf-8'
)
const service = await newService()
const state = withoutEnvApiKey(() => service.listAccounts())
expect(state.activeAccountId).toBeNull()
expect(state.systemDefault).toEqual({
hasAuth: true,
authKind: 'oauth',
email: 'real@home.dev',
providerAccountId: 'acct-real',
workspaceLabel: null
})
})
it('reports an api-key auth.json as a custom provider with no identity', async () => {
writeFileSync(systemAuthPath(), JSON.stringify({ OPENAI_API_KEY: 'sk-live-test' }), 'utf-8')
const service = await newService()
const state = withoutEnvApiKey(() => service.listAccounts())
expect(state.systemDefault).toEqual({
hasAuth: true,
authKind: 'api-key',
email: null,
providerAccountId: null,
workspaceLabel: null
})
})
it('reports signed-out when no auth.json and no env key is present', async () => {
const service = await newService()
const state = withoutEnvApiKey(() => service.listAccounts())
expect(state.systemDefault).toEqual({
hasAuth: false,
authKind: 'none',
email: null,
providerAccountId: null,
workspaceLabel: null
})
})
it('degrades safely when auth.json contains valid JSON with the wrong shape', async () => {
writeFileSync(systemAuthPath(), 'null', 'utf-8')
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const service = await newService()
try {
const state = withoutEnvApiKey(() => service.listAccounts())
expect(state.systemDefault).toEqual({
hasAuth: true,
authKind: 'none',
email: null,
providerAccountId: null,
workspaceLabel: null
})
expect(warn).toHaveBeenCalledWith(
'[codex-accounts] System-default Codex auth has an unexpected format'
)
} finally {
warn.mockRestore()
}
})
it('does not log auth contents when auth.json is malformed', async () => {
const secret = 'sk-secret-review-never-log'
writeFileSync(systemAuthPath(), secret, 'utf-8')
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const service = await newService()
try {
const state = withoutEnvApiKey(() => service.listAccounts())
expect(state.systemDefault?.authKind).toBe('none')
expect(warn).toHaveBeenCalledWith(
'[codex-accounts] System-default Codex auth is not valid JSON'
)
expect(JSON.stringify(warn.mock.calls)).not.toContain(secret)
} finally {
warn.mockRestore()
}
})
it('reports an env OPENAI_API_KEY (no auth.json) as a custom provider', async () => {
const service = await newService()
const previous = process.env.OPENAI_API_KEY
process.env.OPENAI_API_KEY = 'sk-env-test'
try {
const state = service.listAccounts()
expect(state.systemDefault).toEqual({
hasAuth: false,
authKind: 'api-key',
email: null,
providerAccountId: null,
workspaceLabel: null
})
} finally {
if (previous === undefined) {
delete process.env.OPENAI_API_KEY
} else {
process.env.OPENAI_API_KEY = previous
}
}
})
it('never mutates ~/.codex when selecting or deselecting a managed account', async () => {
const systemAuth = createCodexAuthJson('real@home.dev', 'acct-real', 'refresh-real')
writeFileSync(systemAuthPath(), systemAuth, 'utf-8')
const managedHomePath = createManagedHome(
testState.userDataDir,
'account-1',
'approval_policy = "on-request"\n',
createCodexAuthJson('managed@example.com', 'acct-managed', 'refresh-managed')
)
const store = createStore(
createSettings({
codexManagedAccounts: [
{
id: 'account-1',
email: 'managed@example.com',
managedHomePath,
providerAccountId: 'acct-managed',
workspaceLabel: null,
workspaceAccountId: 'acct-managed',
createdAt: 1,
updatedAt: 1,
lastAuthenticatedAt: 1
}
]
})
)
const rateLimits = createRateLimits()
const runtimeHome = createRuntimeHome()
const { CodexAccountService } = await import('./service')
const service = new CodexAccountService(
store as never,
rateLimits as never,
runtimeHome as never
)
await service.selectAccount('account-1')
await service.selectAccount(null)
expect(readFileSync(systemAuthPath(), 'utf-8')).toBe(systemAuth)
const state = withoutEnvApiKey(() => service.listAccounts())
expect(state.systemDefault?.email).toBe('real@home.dev')
})
})
})
+108 -4
View File
@@ -11,7 +11,8 @@ import { getSpawnArgsForWindows } from '../win32-utils'
import type {
CodexManagedAccount,
CodexManagedAccountSummary,
CodexRateLimitAccountsState
CodexRateLimitAccountsState,
CodexSystemDefaultIdentity
} from '../../shared/types'
import type { CodexRuntimeHomeService } from './runtime-home-service'
import { writeFileAtomically } from './fs-utils'
@@ -309,10 +310,104 @@ export class CodexAccountService {
.map((account) => this.toSummary(account))
.sort((a, b) => b.updatedAt - a.updatedAt),
activeAccountId: normalizeCodexRuntimeSelection(settings).host,
activeAccountIdsByRuntime: normalizeCodexRuntimeSelection(settings)
activeAccountIdsByRuntime: normalizeCodexRuntimeSelection(settings),
systemDefault: this.resolveSystemDefaultIdentity()
}
}
// Why: the system-default (activeAccountId:null) account has no stored
// identity — its effective login is whatever the real ~/.codex/auth.json is
// right now. Read it live and read-only so the switcher can display who the
// system default is and attribute usage, without ever mutating ~/.codex.
private resolveSystemDefaultIdentity(): CodexSystemDefaultIdentity {
const authFilePath = join(homedir(), '.codex', 'auth.json')
let contents: string
try {
// Why: a single read avoids an exists/read race and halves filesystem
// probes whenever an accounts snapshot resolves this live identity.
contents = readFileSync(authFilePath, 'utf-8')
} catch (error) {
const code = (error as NodeJS.ErrnoException | null)?.code
if (code === 'ENOENT' || code === 'ENOTDIR') {
// Why: no auth.json means either a signed-out home or an env-key/custom
// provider that authenticates via OPENAI_API_KEY instead of a token file.
return {
hasAuth: false,
authKind: this.hasEnvApiKey() ? 'api-key' : 'none',
email: null,
providerAccountId: null,
workspaceLabel: null
}
}
console.warn(
'[codex-accounts] Failed to read system-default Codex identity',
code ?? 'unknown-error'
)
return {
hasAuth: true,
authKind: 'none',
email: null,
providerAccountId: null,
workspaceLabel: null
}
}
let parsed: unknown
try {
parsed = JSON.parse(contents)
} catch {
// Why: SyntaxError messages can echo malformed input; never let auth
// contents or token fragments reach logs while degrading safely.
console.warn('[codex-accounts] System-default Codex auth is not valid JSON')
return {
hasAuth: true,
authKind: 'none',
email: null,
providerAccountId: null,
workspaceLabel: null
}
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
// Why: valid JSON can still have the wrong shape; account listing must
// degrade to an unknown identity instead of crashing the settings pane.
console.warn('[codex-accounts] System-default Codex auth has an unexpected format')
return {
hasAuth: true,
authKind: 'none',
email: null,
providerAccountId: null,
workspaceLabel: null
}
}
const raw = parsed as Record<string, unknown>
if (typeof raw.OPENAI_API_KEY === 'string' && raw.OPENAI_API_KEY.trim() !== '') {
// 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 {
hasAuth: true,
authKind: 'api-key',
email: null,
providerAccountId: null,
workspaceLabel: null
}
}
const identity = this.resolveIdentityFromCredentials(this.extractOAuthCredentials(raw))
return {
hasAuth: true,
authKind: 'oauth',
email: identity.email,
providerAccountId: identity.providerAccountId,
workspaceLabel: identity.workspaceLabel
}
}
private hasEnvApiKey(): boolean {
const key = process.env.OPENAI_API_KEY
return typeof key === 'string' && key.trim() !== ''
}
private toSummary(account: CodexManagedAccount): CodexManagedAccountSummary {
return {
id: account.id,
@@ -945,7 +1040,12 @@ export class CodexAccountService {
}
private readIdentityFromHome(managedHomePath: string): ResolvedCodexIdentity {
const credentials = this.loadOAuthCredentials(managedHomePath)
return this.resolveIdentityFromCredentials(this.loadOAuthCredentials(managedHomePath))
}
private resolveIdentityFromCredentials(
credentials: CodexOAuthCredentials
): ResolvedCodexIdentity {
const payload = credentials.idToken ? this.parseJwtPayload(credentials.idToken) : null
const authClaims = this.readRecordClaim(payload, 'https://api.openai.com/auth')
const profileClaims = this.readRecordClaim(payload, 'https://api.openai.com/profile')
@@ -973,8 +1073,12 @@ export class CodexAccountService {
private loadOAuthCredentials(managedHomePath: string): CodexOAuthCredentials {
const authFilePath = join(this.assertManagedHomePath(managedHomePath), 'auth.json')
const raw = JSON.parse(readFileSync(authFilePath, 'utf-8')) as Record<string, unknown>
return this.extractOAuthCredentials(
JSON.parse(readFileSync(authFilePath, 'utf-8')) as Record<string, unknown>
)
}
private extractOAuthCredentials(raw: Record<string, unknown>): CodexOAuthCredentials {
// Why: API-key-based auth files have no OAuth tokens or JWT identity
// claims. Returning nulls causes the caller to fail with a clear
// "could not resolve the account email" error rather than crashing
@@ -7,6 +7,7 @@ import { useEffect, useRef, useState } from 'react'
import type {
ClaudeRateLimitAccountsState,
CodexRateLimitAccountsState,
CodexSystemDefaultIdentity,
GlobalSettings
} from '../../../../shared/types'
import { Badge } from '../ui/badge'
@@ -175,6 +176,29 @@ function getCodexAccountLabel(
return state.accounts.find((account) => account.id === accountId)?.email ?? 'Codex account'
}
// Why: the system-default row has no stored identity, so surface the real
// ~/.codex login live — the OAuth email when signed in, a clear custom-provider
// note for env-key logins, and the generic fallback when signed out.
function getCodexSystemDefaultSubtitle(
identity: CodexSystemDefaultIdentity | undefined,
runtimeSentenceLabel: string
): string {
if (identity?.authKind === 'oauth' && identity.email) {
return identity.email
}
if (identity?.authKind === 'api-key') {
return translate(
'auto.components.settings.AccountsPane.codexSystemDefaultCustomProvider',
'Custom provider — no usage tracked.'
)
}
return translate(
'auto.components.settings.AccountsPane.fcc4093fc1',
'Use your current {{value0}} Codex login.',
{ value0: runtimeSentenceLabel }
)
}
function getClaudeAccountLabel(
state: ClaudeRateLimitAccountsState,
accountId: string | null | undefined
@@ -385,6 +409,11 @@ export function AccountsPane({
).some((account) =>
providerAccountIsActiveInView(account, claudeAccounts, accountRuntime, accountVisibilityOptions)
)
// Why: the system default's real identity is host-scoped (it reflects the
// runtime's own ~/.codex), so only surface it in the host view. Per-distro
// WSL falls back to the generic label.
const systemCodexIdentity =
accountRuntime.runtime === 'host' ? codexAccounts.systemDefault : undefined
// Why: the auth warning is derived from the desktop's own rate-limit poll;
// with a remote owner it would misattribute local auth state to the server.
const activeCodexAuthWarning =
@@ -394,7 +423,8 @@ export function AccountsPane({
target: codexRateLimitTarget,
runtime: accountRuntime,
activeAccountId: activeCodexAccountId,
accountId: activeCodexAccountId
accountId: activeCodexAccountId,
authKind: activeCodexAccountId === null ? systemCodexIdentity?.authKind : undefined
})
: null
const systemCodexNeedsReauthentication =
@@ -1172,10 +1202,9 @@ export function AccountsPane({
'Codex reported this {{value0}} login is out of date.',
{ value0: accountRuntimeSentenceLabel }
)
: translate(
'auto.components.settings.AccountsPane.fcc4093fc1',
'Use your current {{value0}} Codex login.',
{ value0: accountRuntimeSentenceLabel }
: getCodexSystemDefaultSubtitle(
systemCodexIdentity,
accountRuntimeSentenceLabel
)}
</span>
</div>
@@ -77,6 +77,19 @@ describe('codex account auth warning', () => {
).toBeNull()
})
it('does not mislabel an API-key system default as needing re-authentication', () => {
expect(
getCodexAccountAuthWarning({
limits: codexLimits('chatgpt authentication required to read rate limits'),
target: { runtime: 'host', wslDistro: null },
runtime: { runtime: 'host' },
activeAccountId: null,
accountId: null,
authKind: 'api-key'
})
).toBeNull()
})
it('allows a WSL default account location to receive the active WSL target warning', () => {
expect(
codexRateLimitTargetMatchesAccountRuntime(
@@ -2,6 +2,7 @@ import type {
ProviderRateLimits,
RateLimitRuntimeTarget
} from '../../../../shared/rate-limit-types'
import type { CodexSystemDefaultIdentity } from '../../../../shared/types'
import { isCodexAuthError } from '../../../../shared/codex-auth-errors'
type AccountRuntime = {
@@ -28,7 +29,13 @@ export function getCodexAccountAuthWarning(args: {
runtime: AccountRuntime
activeAccountId: string | null
accountId: string | null
authKind?: CodexSystemDefaultIdentity['authKind']
}): string | null {
// Why: app-server reports API-key homes as a ChatGPT-auth error because
// usage is unsupported; that is not a stale sign-in the user can re-auth.
if (args.accountId === null && args.authKind === 'api-key') {
return null
}
if (args.accountId !== args.activeAccountId) {
return null
}
+2 -1
View File
@@ -4957,7 +4957,8 @@
"remoteScopeAccounts": "Showing accounts managed by {{value0}}. Add or re-authenticate accounts on that server.",
"remoteScopeAuthContext": "Each account keeps its own sign-in context on {{value0}}.",
"remoteEmptyClaudeAccounts": "No managed Claude accounts on {{value0}}. It uses its system default Claude login; add accounts on that server.",
"remoteEmptyCodexAccounts": "No managed Codex accounts on {{value0}}. It uses its system default Codex login; add accounts on that server."
"remoteEmptyCodexAccounts": "No managed Codex accounts on {{value0}}. It uses its system default Codex login; add accounts on that server.",
"codexSystemDefaultCustomProvider": "Custom provider — no usage tracked."
},
"AdvancedPane": {
"40b29e0bf3": "Restart",
+2 -1
View File
@@ -4934,7 +4934,8 @@
"remoteScopeAccounts": "Mostrando las cuentas administradas por {{value0}}. Agrega o vuelve a autenticar cuentas en ese servidor.",
"remoteScopeAuthContext": "Cada cuenta mantiene su propio contexto de inicio de sesión en {{value0}}.",
"remoteEmptyClaudeAccounts": "No hay cuentas de Claude administradas en {{value0}}. Usa su inicio de sesión de Claude predeterminado del sistema; agrega cuentas en ese servidor.",
"remoteEmptyCodexAccounts": "No hay cuentas de Codex administradas en {{value0}}. Usa su inicio de sesión de Codex predeterminado del sistema; agrega cuentas en ese servidor."
"remoteEmptyCodexAccounts": "No hay cuentas de Codex administradas en {{value0}}. Usa su inicio de sesión de Codex predeterminado del sistema; agrega cuentas en ese servidor.",
"codexSystemDefaultCustomProvider": "Proveedor personalizado — no se registra el uso."
},
"AdvancedPane": {
"40b29e0bf3": "Reiniciar",
+2 -1
View File
@@ -4919,7 +4919,8 @@
"remoteScopeAccounts": "{{value0}} が管理するアカウントを表示しています。アカウントの追加や再認証はそのサーバー上で行ってください。",
"remoteScopeAuthContext": "各アカウントは {{value0}} 上に独自のサインインコンテキストを保持します。",
"remoteEmptyClaudeAccounts": "{{value0}} に管理対象の Claude アカウントはありません。システム既定の Claude ログインを使用します。アカウントの追加はそのサーバー上で行ってください。",
"remoteEmptyCodexAccounts": "{{value0}} に管理対象の Codex アカウントはありません。システム既定の Codex ログインを使用します。アカウントの追加はそのサーバー上で行ってください。"
"remoteEmptyCodexAccounts": "{{value0}} に管理対象の Codex アカウントはありません。システム既定の Codex ログインを使用します。アカウントの追加はそのサーバー上で行ってください。",
"codexSystemDefaultCustomProvider": "カスタムプロバイダー — 使用量は追跡されません。"
},
"AdvancedPane": {
"40b29e0bf3": "再起動",
+2 -1
View File
@@ -4919,7 +4919,8 @@
"remoteScopeAccounts": "{{value0}}에서 관리하는 계정을 표시하고 있습니다. 계정 추가나 재인증은 해당 서버에서 진행하세요.",
"remoteScopeAuthContext": "각 계정은 {{value0}}에 자체 로그인 컨텍스트를 유지합니다.",
"remoteEmptyClaudeAccounts": "{{value0}}에 관리되는 Claude 계정이 없습니다. 해당 서버의 시스템 기본 Claude 로그인을 사용하며, 계정 추가는 그 서버에서 진행하세요.",
"remoteEmptyCodexAccounts": "{{value0}}에 관리되는 Codex 계정이 없습니다. 해당 서버의 시스템 기본 Codex 로그인을 사용하며, 계정 추가는 그 서버에서 진행하세요."
"remoteEmptyCodexAccounts": "{{value0}}에 관리되는 Codex 계정이 없습니다. 해당 서버의 시스템 기본 Codex 로그인을 사용하며, 계정 추가는 그 서버에서 진행하세요.",
"codexSystemDefaultCustomProvider": "사용자 지정 제공업체 — 사용량을 추적하지 않습니다."
},
"AdvancedPane": {
"40b29e0bf3": "다시 시작",
+2 -1
View File
@@ -4919,7 +4919,8 @@
"remoteScopeAccounts": "正在显示由 {{value0}} 管理的账户。请在该服务器上添加或重新验证账户。",
"remoteScopeAuthContext": "每个账户在 {{value0}} 上保留自己的登录上下文。",
"remoteEmptyClaudeAccounts": "{{value0}} 上没有受管理的 Claude 账户。它使用其系统默认的 Claude 登录;请在该服务器上添加账户。",
"remoteEmptyCodexAccounts": "{{value0}} 上没有受管理的 Codex 账户。它使用其系统默认的 Codex 登录;请在该服务器上添加账户。"
"remoteEmptyCodexAccounts": "{{value0}} 上没有受管理的 Codex 账户。它使用其系统默认的 Codex 登录;请在该服务器上添加账户。",
"codexSystemDefaultCustomProvider": "自定义提供商 — 不跟踪使用情况。"
},
"AdvancedPane": {
"40b29e0bf3": "重新启动",
+18
View File
@@ -2342,10 +2342,28 @@ export type CodexManagedAccountSummary = {
lastAuthenticatedAt: number
}
/** Live, read-only identity of the user's real ~/.codex used by the
* system-default (activeAccountId:null) Codex account. Orca reads this to
* display and attribute the system default; it never writes ~/.codex. */
export type CodexSystemDefaultIdentity = {
/** True when ~/.codex/auth.json exists (signed in via a token file). */
hasAuth: boolean
/** 'oauth' = ChatGPT sign-in with an id token (has ChatGPT usage);
* 'api-key' = env-key/custom provider (no ChatGPT usage);
* 'none' = signed out or identity could not be resolved. */
authKind: 'oauth' | 'api-key' | 'none'
email: string | null
providerAccountId: string | null
workspaceLabel: string | null
}
export type CodexRateLimitAccountsState = {
accounts: CodexManagedAccountSummary[]
activeAccountId: string | null
activeAccountIdsByRuntime?: CodexManagedAccountRuntimeSelection
/** Resolved identity of the host system-default (real ~/.codex) account.
* Omitted for runtimes where it is not resolved (e.g. per-distro WSL). */
systemDefault?: CodexSystemDefaultIdentity
}
export type CodexManagedAccountRuntimeSelection = {