feat(codex): show system-default/real-home account identity in switcher (PR-B)

The account switcher modeled the system-default Codex account as
activeAccountId:null with no identity fields, so the null row rendered
blank ("System default" / generic subtitle) even though its effective
login is whatever ~/.codex/auth.json currently is.

Add a CodexSystemDefaultIdentity descriptor {hasAuth, authKind, email,
providerAccountId, workspaceLabel} to CodexRateLimitAccountsState,
resolved live and READ-ONLY from ~/.codex by the accounts service and
returned from listAccounts()/getSnapshot(). The settings switcher now
renders the null (system-default) row as that real identity: the OAuth
email when signed in, "Custom provider — no usage tracked." for
env-key/custom-provider logins (auth.json with OPENAI_API_KEY, or an
OPENAI_API_KEY env with no auth.json), and the generic fallback when
signed out. Identity is host-scoped (per-distro WSL keeps the generic
label). Orca never writes ~/.codex; managed-account switches only touch
Orca-owned homes, so the system-default identity stays a stable,
displayed source of truth. Usage already routes to the real home via
getSystemCodexHomePath, so the switcher now attributes it to a real face.

Tests (sandboxed temp homes only): OAuth email/provider resolution,
api-key auth.json and env-key (no auth.json) as custom-provider,
signed-out, and select/deselect of a managed account never mutating
~/.codex/auth.json.
This commit is contained in:
Brennan Benson
2026-07-17 02:36:54 -07:00
parent 5880c6ad65
commit 7f3f676f1c
9 changed files with 275 additions and 13 deletions
+141
View File
@@ -1606,4 +1606,145 @@ 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('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')
})
})
})
+74 -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'
@@ -301,10 +302,70 @@ 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')
if (!existsSync(authFilePath)) {
// 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
}
}
let raw: Record<string, unknown>
try {
raw = JSON.parse(readFileSync(authFilePath, 'utf-8')) as Record<string, unknown>
} catch (error) {
console.warn('[codex-accounts] Failed to read system-default Codex identity:', error)
return {
hasAuth: true,
authKind: 'none',
email: null,
providerAccountId: null,
workspaceLabel: null
}
}
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,
@@ -933,7 +994,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')
@@ -961,8 +1027,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
@@ -399,6 +423,11 @@ export function AccountsPane({
: null
const systemCodexNeedsReauthentication =
activeCodexAccountId === null && Boolean(activeCodexAuthWarning)
// 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
const accountRuntimeUnavailable =
accountRuntime.runtime === 'wsl' && !wslAvailable && !wslCapabilitiesLoading
@@ -1172,10 +1201,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>
+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": "Custom provider — no usage tracked."
},
"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": "Custom provider — no usage tracked."
},
"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": "Custom provider — no usage tracked."
},
"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": "Custom provider — no usage tracked."
},
"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 = {