From 7f3f676f1c01e6195d367fd1e956194bf229fc0f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 17 Jul 2026 02:36:54 -0700 Subject: [PATCH 1/2] feat(codex): show system-default/real-home account identity in switcher (PR-B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/main/codex-accounts/service.test.ts | 141 ++++++++++++++++++ src/main/codex-accounts/service.ts | 78 +++++++++- .../src/components/settings/AccountsPane.tsx | 36 ++++- src/renderer/src/i18n/locales/en.json | 3 +- src/renderer/src/i18n/locales/es.json | 3 +- src/renderer/src/i18n/locales/ja.json | 3 +- src/renderer/src/i18n/locales/ko.json | 3 +- src/renderer/src/i18n/locales/zh.json | 3 +- src/shared/types.ts | 18 +++ 9 files changed, 275 insertions(+), 13 deletions(-) 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 = { From 9c20fd301beda398ad2a39517cf23c2b5454d12c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 17 Jul 2026 02:47:21 -0700 Subject: [PATCH 2/2] fix(codex): harden system-default identity rendering --- src/main/codex-accounts/service.test.ts | 42 ++++++++++++ src/main/codex-accounts/service.ts | 64 ++++++++++++++----- .../src/components/settings/AccountsPane.tsx | 13 ++-- .../codex-account-auth-warning.test.ts | 13 ++++ .../settings/codex-account-auth-warning.ts | 7 ++ src/renderer/src/i18n/locales/es.json | 2 +- src/renderer/src/i18n/locales/ja.json | 2 +- src/renderer/src/i18n/locales/ko.json | 2 +- src/renderer/src/i18n/locales/zh.json | 2 +- 9 files changed, 122 insertions(+), 25 deletions(-) diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index 2e313f6bd2b..6c94249d4d3 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -1682,6 +1682,48 @@ describe('CodexAccountService config sync', () => { }) }) + 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 diff --git a/src/main/codex-accounts/service.ts b/src/main/codex-accounts/service.ts index 563be27f006..f3749454281 100644 --- a/src/main/codex-accounts/service.ts +++ b/src/main/codex-accounts/service.ts @@ -313,23 +313,28 @@ export class CodexAccountService { // 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 + let contents: string try { - raw = JSON.parse(readFileSync(authFilePath, 'utf-8')) as Record + // 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) { - console.warn('[codex-accounts] Failed to read system-default Codex identity:', 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', @@ -339,6 +344,35 @@ export class CodexAccountService { } } + 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 + 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. diff --git a/src/renderer/src/components/settings/AccountsPane.tsx b/src/renderer/src/components/settings/AccountsPane.tsx index ac97b30d74a..73b6878c2b6 100644 --- a/src/renderer/src/components/settings/AccountsPane.tsx +++ b/src/renderer/src/components/settings/AccountsPane.tsx @@ -409,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 = @@ -418,16 +423,12 @@ export function AccountsPane({ target: codexRateLimitTarget, runtime: accountRuntime, activeAccountId: activeCodexAccountId, - accountId: activeCodexAccountId + accountId: activeCodexAccountId, + authKind: activeCodexAccountId === null ? systemCodexIdentity?.authKind : undefined }) : 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 diff --git a/src/renderer/src/components/settings/codex-account-auth-warning.test.ts b/src/renderer/src/components/settings/codex-account-auth-warning.test.ts index ea8de210bcf..dcb14b039b4 100644 --- a/src/renderer/src/components/settings/codex-account-auth-warning.test.ts +++ b/src/renderer/src/components/settings/codex-account-auth-warning.test.ts @@ -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( diff --git a/src/renderer/src/components/settings/codex-account-auth-warning.ts b/src/renderer/src/components/settings/codex-account-auth-warning.ts index b9d824576c4..cefccac3d5c 100644 --- a/src/renderer/src/components/settings/codex-account-auth-warning.ts +++ b/src/renderer/src/components/settings/codex-account-auth-warning.ts @@ -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 } diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 6e78c6739f8..b8586da2cbe 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -4935,7 +4935,7 @@ "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.", - "codexSystemDefaultCustomProvider": "Custom provider — no usage tracked." + "codexSystemDefaultCustomProvider": "Proveedor personalizado — no se registra el uso." }, "AdvancedPane": { "40b29e0bf3": "Reiniciar", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index b6971e916aa..833ae1ee558 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -4920,7 +4920,7 @@ "remoteScopeAuthContext": "各アカウントは {{value0}} 上に独自のサインインコンテキストを保持します。", "remoteEmptyClaudeAccounts": "{{value0}} に管理対象の Claude アカウントはありません。システム既定の Claude ログインを使用します。アカウントの追加はそのサーバー上で行ってください。", "remoteEmptyCodexAccounts": "{{value0}} に管理対象の Codex アカウントはありません。システム既定の Codex ログインを使用します。アカウントの追加はそのサーバー上で行ってください。", - "codexSystemDefaultCustomProvider": "Custom provider — no usage tracked." + "codexSystemDefaultCustomProvider": "カスタムプロバイダー — 使用量は追跡されません。" }, "AdvancedPane": { "40b29e0bf3": "再起動", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 1c56d236b94..0f6ae97a6a4 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -4920,7 +4920,7 @@ "remoteScopeAuthContext": "각 계정은 {{value0}}에 자체 로그인 컨텍스트를 유지합니다.", "remoteEmptyClaudeAccounts": "{{value0}}에 관리되는 Claude 계정이 없습니다. 해당 서버의 시스템 기본 Claude 로그인을 사용하며, 계정 추가는 그 서버에서 진행하세요.", "remoteEmptyCodexAccounts": "{{value0}}에 관리되는 Codex 계정이 없습니다. 해당 서버의 시스템 기본 Codex 로그인을 사용하며, 계정 추가는 그 서버에서 진행하세요.", - "codexSystemDefaultCustomProvider": "Custom provider — no usage tracked." + "codexSystemDefaultCustomProvider": "사용자 지정 제공업체 — 사용량을 추적하지 않습니다." }, "AdvancedPane": { "40b29e0bf3": "다시 시작", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 3eb7a6e963c..93c93a75a21 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -4920,7 +4920,7 @@ "remoteScopeAuthContext": "每个账户在 {{value0}} 上保留自己的登录上下文。", "remoteEmptyClaudeAccounts": "{{value0}} 上没有受管理的 Claude 账户。它使用其系统默认的 Claude 登录;请在该服务器上添加账户。", "remoteEmptyCodexAccounts": "{{value0}} 上没有受管理的 Codex 账户。它使用其系统默认的 Codex 登录;请在该服务器上添加账户。", - "codexSystemDefaultCustomProvider": "Custom provider — no usage tracked." + "codexSystemDefaultCustomProvider": "自定义提供商 — 不跟踪使用情况。" }, "AdvancedPane": { "40b29e0bf3": "重新启动",