diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index 9b185e4c357..2e313f6bd2b 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -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(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') + }) + }) }) diff --git a/src/main/codex-accounts/service.ts b/src/main/codex-accounts/service.ts index 2a0fb54bdc4..563be27f006 100644 --- a/src/main/codex-accounts/service.ts +++ b/src/main/codex-accounts/service.ts @@ -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 + try { + raw = JSON.parse(readFileSync(authFilePath, 'utf-8')) as Record + } 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 + return this.extractOAuthCredentials( + JSON.parse(readFileSync(authFilePath, 'utf-8')) as Record + ) + } + private extractOAuthCredentials(raw: Record): 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 diff --git a/src/renderer/src/components/settings/AccountsPane.tsx b/src/renderer/src/components/settings/AccountsPane.tsx index 5268a890e36..ac97b30d74a 100644 --- a/src/renderer/src/components/settings/AccountsPane.tsx +++ b/src/renderer/src/components/settings/AccountsPane.tsx @@ -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 )} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 0b6f41f8173..41f33c0cbe8 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 2c5a35ddbb4..6e78c6739f8 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -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", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index f622982f00e..b6971e916aa 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -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": "再起動", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index ea0603e7c97..1c56d236b94 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -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": "다시 시작", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index f14765dabb1..3eb7a6e963c 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -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": "重新启动", diff --git a/src/shared/types.ts b/src/shared/types.ts index 1b728e2ba66..57dc0da6679 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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 = {