From 42857f73bbb04dd1e75d053bbda9b44ab669ff01 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 29 Aug 2026 20:24:24 -0700 Subject: [PATCH] refactor: split Codex account service modules (#17277) * Split speech session lifecycle * Split terminal output scheduler pipeline * Split mobile browser pane modules * Prune resolved max-lines suppressions * Split pane tree equalization logic * Extract mobile troubleshoot screen styles * Split external automation manager * Split main window service attachments * Split hosted review creation checks * Split automation dispatch event handling * Split settings navigation metadata * Split daemon initialization lifecycle * Split GitLab item dialog * Split relay dispatcher layers * Split mobile host screen * Retarget mobile view settings source test * Split runtime file client layers * Split ports panel layers * Split runtime environments pane layers * Split local PTY provider responsibilities * Split CDP bridge responsibilities * Split relay Git handler responsibilities * Track moved relay Git fetch audit * Split Linear item drawer responsibilities * Split telemetry event schema responsibilities * Split resource usage status responsibilities * Split remote terminal multiplexer responsibilities * Split Git worktree responsibilities * Split Codex hook service responsibilities * Keep mirrored hook trust type private * Split web runtime session responsibilities * Split GitHub project view read path * Split Claude runtime auth responsibilities * Split runtime RPC server responsibilities * Split Settings page responsibilities * Split filesystem watcher responsibilities * refactor codex account service modules * fix duplicate managed home path import * Fix F3-speech for #17123 * Fix F1-cycle for #17131 * Fix F4-navtest for #17157 * Fix F2-allowlist for #17161 * Fix F7F8-codex for #17277 --- config/max-lines-baseline.txt | 1 - ...dex-account-identity-api-key-guard.test.ts | 89 + .../codex-accounts/codex-account-identity.ts | 150 ++ .../codex-account-registration.ts | 232 ++ .../codex-accounts/codex-account-selection.ts | 176 ++ .../codex-account-service-types.ts | 77 + .../codex-accounts/codex-auth-identity.ts | 38 +- .../codex-accounts/codex-config-mirror.ts | 134 ++ .../codex-accounts/codex-login-session.ts | 281 +++ .../codex-managed-home-lifecycle.ts | 225 ++ .../codex-accounts/codex-managed-home-path.ts | 200 ++ .../codex-reset-credit-coordinator.ts | 248 +++ .../codex-reset-credit-ledger.ts | 227 ++ .../codex-reset-credit-scope-validation.ts | 115 + src/main/codex-accounts/service.ts | 1977 +---------------- 15 files changed, 2290 insertions(+), 1880 deletions(-) create mode 100644 src/main/codex-accounts/codex-account-identity-api-key-guard.test.ts create mode 100644 src/main/codex-accounts/codex-account-identity.ts create mode 100644 src/main/codex-accounts/codex-account-registration.ts create mode 100644 src/main/codex-accounts/codex-account-selection.ts create mode 100644 src/main/codex-accounts/codex-account-service-types.ts create mode 100644 src/main/codex-accounts/codex-config-mirror.ts create mode 100644 src/main/codex-accounts/codex-login-session.ts create mode 100644 src/main/codex-accounts/codex-managed-home-lifecycle.ts create mode 100644 src/main/codex-accounts/codex-managed-home-path.ts create mode 100644 src/main/codex-accounts/codex-reset-credit-coordinator.ts create mode 100644 src/main/codex-accounts/codex-reset-credit-ledger.ts create mode 100644 src/main/codex-accounts/codex-reset-credit-scope-validation.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index c6ab3abefa6..08a3f7005d3 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -7,7 +7,6 @@ inline src/main/browser/agent-browser-bridge.ts inline src/main/browser/browser-cookie-import.ts inline src/main/browser/browser-manager.ts inline src/main/codex-accounts/runtime-home-service.ts -inline src/main/codex-accounts/service.ts inline src/main/index.ts inline src/main/ipc/filesystem.ts inline src/main/ipc/worktree-remote.ts diff --git a/src/main/codex-accounts/codex-account-identity-api-key-guard.test.ts b/src/main/codex-accounts/codex-account-identity-api-key-guard.test.ts new file mode 100644 index 00000000000..c522b892ebc --- /dev/null +++ b/src/main/codex-accounts/codex-account-identity-api-key-guard.test.ts @@ -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 { + const payload = Buffer.from(JSON.stringify(claims), 'utf-8').toString('base64url') + return `header.${payload}.signature` +} + +function homeWithAuthJson(auth: Record): 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' + }) + }) +}) diff --git a/src/main/codex-accounts/codex-account-identity.ts b/src/main/codex-accounts/codex-account-identity.ts new file mode 100644 index 00000000000..30b42743ef7 --- /dev/null +++ b/src/main/codex-accounts/codex-account-identity.ts @@ -0,0 +1,150 @@ +import { readFileSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' +import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' +import type { CodexSystemDefaultIdentity } from '../../shared/managed-account-types' +import { readCodexAuthIdentity, type CodexAuthIdentity } from './codex-auth-identity' +import { ManagedCodexHomeTemporarilyUnavailableError } from './host-codex-managed-home-ownership' + +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).OPENAI_API_KEY + return typeof apiKey === 'string' && apiKey.trim() !== '' +} + +export class CodexAccountIdentity { + constructor( + private readonly assertManagedHomePath: ( + candidatePath: string, + expectedAccountId?: string + ) => string + ) {} + + readFromHome(managedHomePath: string, expectedAccountId: string): ResolvedCodexIdentity { + const authFilePath = join( + this.assertManagedHomePath(managedHomePath, expectedAccountId), + 'auth.json' + ) + let contents: string + try { + contents = readFileSync(authFilePath, 'utf-8') + } catch (error) { + // Why: an unreadable auth.json is not a missing credential. Surfacing it as + // a generic failure is what lets the add path's rollback delete a home + // holding freshly authenticated bytes. + if (isDefinitiveAbsence(error)) { + throw error + } + throw new ManagedCodexHomeTemporarilyUnavailableError(undefined, { cause: error }) + } + let parsed: unknown + try { + 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 + // intent as the system-default identity path, which degrades instead). + throw new Error('Codex auth.json is corrupt or not valid JSON') + } + // 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 + // on missing nested token fields. + if (declaresApiKeyCredential(parsed)) { + return { + email: null, + providerAccountId: null, + workspaceLabel: null, + workspaceAccountId: null + } + } + const identity = readCodexAuthIdentity(contents) + if (!identity) { + return { + email: null, + providerAccountId: null, + workspaceLabel: null, + workspaceAccountId: null + } + } + return identity + } + + // 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. + resolveSystemDefault(): CodexSystemDefaultIdentity { + 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(join(homedir(), '.codex', 'auth.json'), '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 this.systemDefaultIdentity(false, this.hasEnvApiKey() ? 'api-key' : 'none') + } + console.warn( + '[codex-accounts] Failed to read system-default Codex identity', + code ?? 'unknown-error' + ) + return this.systemDefaultIdentity(true, 'none') + } + + 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 this.systemDefaultIdentity(true, 'none') + } + 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 this.systemDefaultIdentity(true, 'none') + } + 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') + } + + const identity = readCodexAuthIdentity(contents) + return { + hasAuth: true, + authKind: 'oauth', + email: identity?.email ?? null, + providerAccountId: identity?.providerAccountId ?? null, + workspaceLabel: identity?.workspaceLabel ?? null + } + } + + private systemDefaultIdentity( + hasAuth: boolean, + authKind: CodexSystemDefaultIdentity['authKind'] + ): CodexSystemDefaultIdentity { + return { + hasAuth, + authKind, + email: null, + providerAccountId: null, + workspaceLabel: null + } + } + + private hasEnvApiKey(): boolean { + const key = process.env.OPENAI_API_KEY + return typeof key === 'string' && key.trim() !== '' + } +} diff --git a/src/main/codex-accounts/codex-account-registration.ts b/src/main/codex-accounts/codex-account-registration.ts new file mode 100644 index 00000000000..fbdf0db1a68 --- /dev/null +++ b/src/main/codex-accounts/codex-account-registration.ts @@ -0,0 +1,232 @@ +import { randomUUID } from 'node:crypto' +import type { + CodexManagedAccount, + CodexRateLimitAccountsState +} from '../../shared/managed-account-types' +import type { Store } from '../persistence' +import type { RateLimitService } from '../rate-limits/service' +import type { CodexRuntimeHomeService } from './runtime-home-service' +import type { ResolvedCodexIdentity } from './codex-account-identity' +import type { + CodexAccountAddTarget, + CodexAccountReauthenticateOptions, + ManagedCodexHomeLocation +} from './codex-account-service-types' +import type { CodexAccountSelection } from './codex-account-selection' +import type { CodexConfigMirror } from './codex-config-mirror' +import type { CodexManagedHomeLifecycle } from './codex-managed-home-lifecycle' +import type { CodexManagedHomePath } from './codex-managed-home-path' +import { + getCodexSelectionTargetForAccount, + getSelectedCodexAccountIdForTarget, + normalizeCodexRuntimeSelection, + setSelectedCodexAccountIdForTarget, + type CodexAccountSelectionTarget +} from './runtime-selection' + +type CodexAccountRegistrationDependencies = { + store: Store + rateLimits: RateLimitService + runtimeHome: CodexRuntimeHomeService + readIdentityFromHome: ( + managedHomePath: string, + expectedAccountId: string + ) => ResolvedCodexIdentity + selection: CodexAccountSelection + configMirror: CodexConfigMirror + managedHomePaths: CodexManagedHomePath + managedHomes: CodexManagedHomeLifecycle + login: (managedHomePath: string) => Promise +} + +export class CodexAccountRegistration { + constructor(private readonly dependencies: CodexAccountRegistrationDependencies) {} + + async add(target?: CodexAccountAddTarget): Promise { + const accountId = randomUUID() + const managedHome = await this.dependencies.managedHomes.create(accountId, target) + const { managedHomePath } = managedHome + try { + this.prepareManagedHomeForLogin(managedHomePath, accountId) + await this.dependencies.login(managedHomePath) + return await this.persistCapturedAccount(accountId, managedHome) + } catch (error) { + this.dependencies.managedHomes.removeUnlessUnproven(error, managedHomePath, accountId) + throw error + } + } + + async addFromHome( + sourceHome: string, + target?: CodexAccountAddTarget + ): Promise { + const accountId = randomUUID() + const managedHome = await this.dependencies.managedHomes.create(accountId, target) + const { managedHomePath } = managedHome + try { + this.prepareManagedHomeForLogin(managedHomePath, accountId) + this.dependencies.managedHomes.importAuthFromHome(sourceHome, managedHomePath, accountId) + return await this.persistCapturedAccount(accountId, managedHome) + } catch (error) { + this.dependencies.managedHomes.removeUnlessUnproven(error, managedHomePath, accountId) + throw error + } + } + + async reauthenticate( + accountId: string, + options?: CodexAccountReauthenticateOptions + ): Promise { + const account = this.dependencies.selection.requireAccount(accountId) + const managedHomePath = + await this.dependencies.managedHomePaths.ensureForReauthentication(account) + const accountTarget = getCodexSelectionTargetForAccount(account) + const selectedAccountId = getSelectedCodexAccountIdForTarget( + this.dependencies.store.getSettings(), + accountTarget + ) + // Why: decided from the pre-login capture, never a post-login read — the + // runtime-home poll runs outside the mutation queue and can clear this lane + // while OAuth is open, which would look like an empty selection to activate. + const activateAfterLogin = + options?.activateIfSelectionWasEmpty === true && selectedAccountId === null + + this.dependencies.configMirror.safeSyncIntoManagedHome(managedHomePath, undefined, account.id) + await this.dependencies.login(managedHomePath) + const identity = this.dependencies.readIdentityFromHome(managedHomePath, account.id) + if (!identity.email) { + throw new Error('Codex login completed, but Orca could not resolve the account email.') + } + + const settings = this.dependencies.store.getSettings() + const now = Date.now() + const updatedAccounts = settings.codexManagedAccounts.map((entry) => + entry.id === accountId + ? { + ...entry, + email: identity.email!, + providerAccountId: identity.providerAccountId, + workspaceLabel: identity.workspaceLabel, + workspaceAccountId: identity.workspaceAccountId, + updatedAt: now, + lastAuthenticatedAt: now + } + : entry + ) + const activeSelection = setSelectedCodexAccountIdForTarget( + normalizeCodexRuntimeSelection(settings), + activateAfterLogin ? accountId : selectedAccountId, + accountTarget + ) + + // Why: login can transiently clear this runtime's selection; unrelated runtime validation must remain authoritative. + this.dependencies.store.updateSettings({ + codexManagedAccounts: updatedAccounts, + activeCodexManagedAccountId: activeSelection.host, + activeCodexManagedAccountIdsByRuntime: activeSelection + }) + this.dependencies.configMirror.safeSyncToManagedHomes() + this.dependencies.runtimeHome.clearLastWrittenAuthJson(accountId) + this.dependencies.runtimeHome.syncForCurrentSelection(accountTarget) + // Why: re-auth can change the underlying Codex identity, so force a fresh read to avoid showing stale quota. + this.startQuotaRefresh(undefined, accountTarget) + return this.dependencies.selection.snapshot() + } + + private prepareManagedHomeForLogin(managedHomePath: string, accountId: string): void { + const canonicalConfig = this.dependencies.configMirror.readForManagedHome(managedHomePath) + this.dependencies.configMirror.assertOAuthAccountAddAllowed(canonicalConfig) + this.dependencies.configMirror.safeSyncIntoManagedHome( + managedHomePath, + canonicalConfig, + accountId + ) + } + + private async persistCapturedAccount( + accountId: string, + managedHome: ManagedCodexHomeLocation + ): Promise { + const identity = this.dependencies.readIdentityFromHome(managedHome.managedHomePath, accountId) + if (!identity.email) { + throw new Error('Codex login completed, but Orca could not resolve the account email.') + } + const now = Date.now() + const account: CodexManagedAccount = { + id: accountId, + email: identity.email, + managedHomePath: managedHome.managedHomePath, + managedHomeRuntime: managedHome.managedHomeRuntime, + wslDistro: managedHome.wslDistro, + wslLinuxHomePath: managedHome.wslLinuxHomePath, + providerAccountId: identity.providerAccountId, + workspaceLabel: identity.workspaceLabel, + workspaceAccountId: identity.workspaceAccountId, + createdAt: now, + updatedAt: now, + lastAuthenticatedAt: now + } + + const settings = this.dependencies.store.getSettings() + const selection = normalizeCodexRuntimeSelection(settings) + const targetSelection = getCodexSelectionTargetForAccount(account) + this.dependencies.store.updateSettings({ + codexManagedAccounts: [...settings.codexManagedAccounts, account], + activeCodexManagedAccountId: targetSelection.runtime === 'host' ? account.id : selection.host, + activeCodexManagedAccountIdsByRuntime: setSelectedCodexAccountIdForTarget( + selection, + account.id, + targetSelection + ) + }) + try { + this.dependencies.configMirror.safeSyncToManagedHomes() + this.dependencies.runtimeHome.clearLastWrittenAuthJson(account.id) + // Why: pass the account's selection target so a WSL account syncs the WSL + // runtime home instead of the default host target. + this.dependencies.runtimeHome.syncForCurrentSelection(targetSelection) + } catch (error) { + // Why: settings were already written; if a post-write step fails, restore the + // previous account/selection so the caller's managed-home cleanup cannot leave + // a dangling, broken managed account behind in settings. + this.dependencies.store.updateSettings({ + codexManagedAccounts: settings.codexManagedAccounts, + activeCodexManagedAccountId: settings.activeCodexManagedAccountId, + activeCodexManagedAccountIdsByRuntime: settings.activeCodexManagedAccountIdsByRuntime + }) + // Why: a failed post-write step must restore both persisted selection and + // the runtime home it drives before the new managed home is removed. + try { + this.dependencies.runtimeHome.syncForCurrentSelection(targetSelection) + } catch (rollbackError) { + console.warn( + '[codex-accounts] Failed to restore runtime home during rollback:', + rollbackError + ) + } + throw error + } + + const outgoingAccountId = getSelectedCodexAccountIdForTarget(settings, targetSelection) + // Why: switching activates the new account, so cache the outgoing account's usage for the + // switcher — in the background, since the probe must never block or fail a durable add. + this.startQuotaRefresh(outgoingAccountId, targetSelection) + return this.dependencies.selection.snapshot() + } + + private startQuotaRefresh( + outgoingAccountId: string | null | undefined, + target: CodexAccountSelectionTarget | undefined + ): void { + // Why: quota probes against a cold per-account CODEX_HOME can take 10–25s + // (RPC + PTY fallback) and queue behind an in-flight global usage refresh. + // The refresh synchronously flips usage to "fetching" before its first await, + // so the switcher updates immediately; the probe itself must never block or + // fail the already-durable account mutation. + void this.dependencies.rateLimits + .refreshForCodexAccountChange(outgoingAccountId, target) + .catch((error) => { + console.error('[codex-accounts] Quota refresh after account change failed:', error) + }) + } +} diff --git a/src/main/codex-accounts/codex-account-selection.ts b/src/main/codex-accounts/codex-account-selection.ts new file mode 100644 index 00000000000..ad97d80696b --- /dev/null +++ b/src/main/codex-accounts/codex-account-selection.ts @@ -0,0 +1,176 @@ +import type { + CodexManagedAccount, + CodexRateLimitAccountsState, + CodexSystemDefaultIdentity +} from '../../shared/managed-account-types' +import type { Store } from '../persistence' +import type { RateLimitService } from '../rate-limits/service' +import type { CodexRuntimeHomeService } from './runtime-home-service' +import type { CodexConfigMirror } from './codex-config-mirror' +import type { CodexAccountServiceLifecycle } from './codex-account-service-types' +import { toCodexManagedAccountSummary } from './codex-account-service-types' +import { + getCodexSelectionTargetForAccount, + getSelectedCodexAccountIdForTarget, + normalizeCodexAccountSelectionTarget, + normalizeCodexRuntimeSelection, + pruneInvalidCodexRuntimeSelection, + removeCodexAccountIdFromSelection, + setSelectedCodexAccountIdForTarget, + type CodexAccountSelectionTarget +} from './runtime-selection' + +type CodexAccountSelectionDependencies = { + store: Store + rateLimits: RateLimitService + runtimeHome: CodexRuntimeHomeService + configMirror: CodexConfigMirror + lifecycle: CodexAccountServiceLifecycle + resolveSystemDefault: () => CodexSystemDefaultIdentity + removeManagedHome: (candidatePath: string, expectedAccountId: string) => void + discardResetAttempts: (accountId: string) => void +} + +export class CodexAccountSelection { + constructor(private readonly dependencies: CodexAccountSelectionDependencies) {} + + list(): CodexRateLimitAccountsState { + this.normalizeActiveSelection() + return this.snapshot() + } + + snapshot(): CodexRateLimitAccountsState { + const settings = this.dependencies.store.getSettings() + return { + accounts: settings.codexManagedAccounts + .map(toCodexManagedAccountSummary) + .sort((a, b) => b.updatedAt - a.updatedAt), + activeAccountId: normalizeCodexRuntimeSelection(settings).host, + activeAccountIdsByRuntime: normalizeCodexRuntimeSelection(settings), + systemDefault: this.dependencies.resolveSystemDefault() + } + } + + requireAccount(accountId: string): CodexManagedAccount { + const account = this.dependencies.store + .getSettings() + .codexManagedAccounts.find((entry) => entry.id === accountId) + if (!account) { + throw new Error('That Codex rate limit account no longer exists.') + } + return account + } + + async remove(accountId: string): Promise { + const account = this.requireAccount(accountId) + const settings = this.dependencies.store.getSettings() + const nextAccounts = settings.codexManagedAccounts.filter((entry) => entry.id !== accountId) + const nextSelection = removeCodexAccountIdFromSelection( + normalizeCodexRuntimeSelection(settings), + accountId + ) + const nextActiveId = + settings.activeCodexManagedAccountId === accountId ? null : nextSelection.host + + this.dependencies.store.updateSettings({ + codexManagedAccounts: nextAccounts, + activeCodexManagedAccountId: nextActiveId, + activeCodexManagedAccountIdsByRuntime: nextSelection + }) + this.dependencies.runtimeHome.syncForCurrentSelection() + if (account.managedHomeRuntime === 'host' && nextSelection.host === null) { + this.dependencies.lifecycle.onHostSystemDefaultSelected?.() + } + + this.dependencies.removeManagedHome(account.managedHomePath, account.id) + // Why: a removed account can no longer appear in the switcher dropdown, + // so purge its cached usage to avoid stale entries. + this.dependencies.rateLimits.evictInactiveCodexCache(accountId) + this.dependencies.discardResetAttempts(accountId) + const accountTarget = getCodexSelectionTargetForAccount(account) + this.startQuotaRefresh( + getSelectedCodexAccountIdForTarget(settings, accountTarget) === accountId + ? accountId + : undefined, + accountTarget + ) + return this.snapshot() + } + + async select( + accountId: string | null, + target?: CodexAccountSelectionTarget + ): Promise { + let effectiveTarget = target + if (accountId !== null) { + const accountTarget = getCodexSelectionTargetForAccount(this.requireAccount(accountId)) + const requestedTarget = normalizeCodexAccountSelectionTarget(target ?? accountTarget) + const normalizedAccountTarget = normalizeCodexAccountSelectionTarget(accountTarget) + if ( + requestedTarget.runtime !== normalizedAccountTarget.runtime || + (requestedTarget.wslDistro !== null && + requestedTarget.wslDistro !== normalizedAccountTarget.wslDistro) + ) { + throw new Error('That Codex account belongs to a different runtime.') + } + effectiveTarget = accountTarget + } + + const previousSettings = this.dependencies.store.getSettings() + const outgoingAccountId = getSelectedCodexAccountIdForTarget(previousSettings, effectiveTarget) + const nextSelection = setSelectedCodexAccountIdForTarget( + normalizeCodexRuntimeSelection(previousSettings), + accountId, + effectiveTarget + ) + this.dependencies.store.updateSettings({ + activeCodexManagedAccountId: + effectiveTarget?.runtime === 'wsl' ? nextSelection.host : accountId, + activeCodexManagedAccountIdsByRuntime: nextSelection + }) + this.dependencies.configMirror.safeSyncToManagedHomes() + this.dependencies.runtimeHome.syncForCurrentSelection(effectiveTarget) + if ( + accountId === null && + normalizeCodexAccountSelectionTarget(effectiveTarget).runtime === 'host' + ) { + this.dependencies.lifecycle.onHostSystemDefaultSelected?.() + } + + this.startQuotaRefresh(outgoingAccountId, effectiveTarget) + return this.snapshot() + } + + private normalizeActiveSelection(): void { + const settings = this.dependencies.store.getSettings() + const selection = normalizeCodexRuntimeSelection(settings) + const nextSelection = pruneInvalidCodexRuntimeSelection( + selection, + settings.codexManagedAccounts + ) + const changed = + nextSelection.host !== selection.host || + JSON.stringify(nextSelection.wsl) !== JSON.stringify(selection.wsl) + if (!changed) { + return + } + this.dependencies.store.updateSettings({ + activeCodexManagedAccountId: nextSelection.host, + activeCodexManagedAccountIdsByRuntime: nextSelection + }) + if (selection.host !== null && nextSelection.host === null) { + this.dependencies.lifecycle.onHostSystemDefaultSelected?.() + } + } + + private startQuotaRefresh( + outgoingAccountId: string | null | undefined, + target: CodexAccountSelectionTarget | undefined + ): void { + void this.dependencies.rateLimits + .refreshForCodexAccountChange(outgoingAccountId, target) + .catch((error) => { + console.error('[codex-accounts] Quota refresh after account change failed:', error) + }) + } +} diff --git a/src/main/codex-accounts/codex-account-service-types.ts b/src/main/codex-accounts/codex-account-service-types.ts new file mode 100644 index 00000000000..a4088db4a83 --- /dev/null +++ b/src/main/codex-accounts/codex-account-service-types.ts @@ -0,0 +1,77 @@ +import type { + CodexManagedAccount, + CodexRateLimitAccountsState, + CodexManagedAccountSummary +} from '../../shared/managed-account-types' +import type { CodexResetCreditExpectedScope } from '../../shared/codex-reset-credit-scope' +import type { CodexRateLimitResetOutcome, RateLimitState } from '../../shared/rate-limit-types' + +export type CodexAccountAddTarget = { + runtime?: 'host' | 'wsl' + wslDistro?: string | null +} + +export type CodexAccountReauthenticateOptions = { + /** + * Local-only intent from the status bar's "Sign in to see usage" action: when + * this account's runtime lane had no selection before login, activate the + * account that just signed in instead of restoring the empty selection. + */ + activateIfSelectionWasEmpty?: boolean +} + +export type CodexAccountServiceLifecycle = { + onHostSystemDefaultSelected?: () => void +} + +export type ManagedCodexHomeLocation = { + managedHomePath: string + managedHomeRuntime: 'host' | 'wsl' + wslDistro: string | null + wslLinuxHomePath: string | null +} + +export type CodexResetCreditRejectedBeforeProviderReason = + | 'targetChanged' + | 'accountChanged' + | 'accountRevisionChanged' + | 'accountRuntimeChanged' + | 'offerUnavailable' + | 'offerChanged' + +export type CodexResetCreditConsumedResult = { + outcome: CodexRateLimitResetOutcome + scope: CodexResetCreditExpectedScope + codex: CodexRateLimitAccountsState + rateLimits: RateLimitState +} + +export type CodexResetCreditRejectedBeforeProviderResult = { + status: 'rejectedBeforeProvider' + retryDisposition: 'discardAttempt' + reason: CodexResetCreditRejectedBeforeProviderReason + scope: CodexResetCreditExpectedScope + codex: CodexRateLimitAccountsState + rateLimits: RateLimitState +} + +export type CodexResetCreditConsumeResult = + | CodexResetCreditConsumedResult + | CodexResetCreditRejectedBeforeProviderResult + +export function toCodexManagedAccountSummary( + account: CodexManagedAccount +): CodexManagedAccountSummary { + return { + id: account.id, + email: account.email, + managedHomeRuntime: account.managedHomeRuntime ?? 'host', + wslDistro: account.wslDistro ?? null, + providerAccountId: account.providerAccountId ?? null, + workspaceLabel: account.workspaceLabel ?? null, + workspaceAccountId: account.workspaceAccountId ?? null, + createdAt: account.createdAt, + updatedAt: account.updatedAt, + lastAuthenticatedAt: account.lastAuthenticatedAt + } +} diff --git a/src/main/codex-accounts/codex-auth-identity.ts b/src/main/codex-accounts/codex-auth-identity.ts index 2bad1469536..0455b5e481f 100644 --- a/src/main/codex-accounts/codex-auth-identity.ts +++ b/src/main/codex-accounts/codex-auth-identity.ts @@ -1,11 +1,14 @@ import type { CodexManagedAccount } from '../../shared/managed-account-types' -type CodexAuthIdentity = { +export type CodexAuthIdentity = { email: string | null providerAccountId: string | null + workspaceLabel: string | null workspaceAccountId: string | null } +type CodexAuthOwnershipIdentity = Omit + // Why: stale shared-home PTYs can write after an account switch, so read-back // needs a positive claim match instead of trusting the selected path alone. export function codexAuthMatchesManagedAccount( @@ -13,13 +16,11 @@ export function codexAuthMatchesManagedAccount( account: CodexManagedAccount, managedAuthContents: string | null ): boolean { - const identity = readIdentityFromAuthContents(runtimeAuthContents) + const identity = readCodexAuthIdentity(runtimeAuthContents) if (!identity) { return false } - const managedIdentity = managedAuthContents - ? readIdentityFromAuthContents(managedAuthContents) - : null + const managedIdentity = managedAuthContents ? readCodexAuthIdentity(managedAuthContents) : null const selectedEmail = firstNonNull(normalizeField(account.email), managedIdentity?.email) const selectedProviderId = firstNonNull( normalizeField(account.providerAccountId), @@ -65,7 +66,7 @@ export function codexAuthCouldBelongToManagedAccount( runtimeAuthContents: string, account: CodexManagedAccount ): boolean { - const identity = readIdentityFromAuthContents(runtimeAuthContents) + const identity = readCodexAuthIdentity(runtimeAuthContents) return ( !identity || !identityContradictsSelection( @@ -85,8 +86,8 @@ export function codexAuthMatchesSystemDefaultIdentity( runtimeAuthContents: string, systemDefaultAuthContents: string ): boolean { - const runtimeIdentity = readIdentityFromAuthContents(runtimeAuthContents) - const systemDefaultIdentity = readIdentityFromAuthContents(systemDefaultAuthContents) + const runtimeIdentity = readCodexAuthIdentity(runtimeAuthContents) + const systemDefaultIdentity = readCodexAuthIdentity(systemDefaultAuthContents) if (!runtimeIdentity || !systemDefaultIdentity) { return false } @@ -154,7 +155,7 @@ export function codexAuthIsFresher( return compareCodexAuthFreshness(candidateAuthContents, baselineAuthContents) === 1 } -function readIdentityFromAuthContents(contents: string): CodexAuthIdentity | null { +export function readCodexAuthIdentity(contents: string): CodexAuthIdentity | null { const raw = parseJsonRecord(contents) if (!raw) { return null @@ -166,21 +167,28 @@ function readIdentityFromAuthContents(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') ), + workspaceLabel: normalizeField( + readStringClaim(authClaims, 'workspace_name') ?? + readStringClaim(profileClaims, 'workspace_name') + ), workspaceAccountId: normalizeField( readStringClaim(authClaims, 'workspace_account_id') ?? - readStringClaim(tokens, 'account_id') ?? - readStringClaim(tokens, 'accountId') ?? + tokenAccountId ?? readStringClaim(payload, 'chatgpt_account_id') ) } @@ -274,8 +282,8 @@ function identityFieldMatches(selectedField: string | null, runtimeField: string // no identity claims (api key, PAT, bedrock) contradicts nothing. A ChatGPT // rename leaves the record email stale, so a matching account id outranks it. function identityContradictsSelection( - selected: CodexAuthIdentity, - identity: CodexAuthIdentity + selected: CodexAuthOwnershipIdentity, + identity: CodexAuthOwnershipIdentity ): boolean { if ( identityFieldsConflict(selected.providerAccountId, identity.providerAccountId) || diff --git a/src/main/codex-accounts/codex-config-mirror.ts b/src/main/codex-accounts/codex-config-mirror.ts new file mode 100644 index 00000000000..252ffb06d33 --- /dev/null +++ b/src/main/codex-accounts/codex-config-mirror.ts @@ -0,0 +1,134 @@ +import { existsSync, readFileSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' +import { parseWslUncPath } from '../../shared/wsl-paths' +import { syncSystemConfigIntoManagedCodexHome } from '../codex/codex-config-mirror' +import { readCodexTopLevelModelProvider } from '../codex/codex-model-provider-config' +import type { Store } from '../persistence' +import { toWindowsWslPath } from '../wsl' + +export type CanonicalCodexConfig = { + contents: string + /** Host-readable source home; the mirror resolves WSL UNC paths to their Linux spelling. */ + sourceHomePath: string + /** Preserve Linux path semantics when WSL $HOME is under /mnt/. */ + sourceConfigDir?: string +} + +export class CodexConfigMirror { + constructor( + private readonly store: Store, + private readonly assertManagedHomePath: ( + candidatePath: string, + expectedAccountId?: string + ) => string + ) {} + + safeSyncToManagedHomes(): void { + try { + this.syncToManagedHomes() + } catch (error) { + console.warn('[codex-accounts] Failed to sync canonical config:', error) + } + } + + safeSyncIntoManagedHome( + managedHomePath: string, + canonicalConfig?: CanonicalCodexConfig | null, + expectedAccountId?: string + ): void { + try { + this.syncIntoManagedHome(managedHomePath, canonicalConfig, expectedAccountId) + } catch (error) { + console.warn('[codex-accounts] Failed to seed managed config:', error) + } + } + + readForManagedHome(managedHomePath: string): CanonicalCodexConfig | null { + const wslInfo = parseWslUncPath(managedHomePath) + if (!wslInfo) { + return this.readHostConfig() + } + + const managedRootMarker = '/.local/share/orca/codex-accounts/' + const markerIndex = wslInfo.linuxPath.indexOf(managedRootMarker) + if (markerIndex === -1) { + return null + } + const wslHome = wslInfo.linuxPath.slice(0, markerIndex) + const configPath = toWindowsWslPath(`${wslHome}/.codex/config.toml`, wslInfo.distro) + if (!existsSync(configPath)) { + return null + } + + try { + // Why: the config is read over UNC but consumed by Codex inside WSL, so + // path rewrites must anchor to the Linux-side ~/.codex, not the UNC path. + return { + contents: readFileSync(configPath, 'utf-8'), + sourceHomePath: toWindowsWslPath(`${wslHome}/.codex`, wslInfo.distro), + sourceConfigDir: `${wslHome}/.codex` + } + } catch (error) { + console.warn('[codex-accounts] Failed to read WSL canonical config:', error) + return null + } + } + + assertOAuthAccountAddAllowed(canonicalConfig: CanonicalCodexConfig | null): void { + const modelProvider = canonicalConfig + ? readCodexTopLevelModelProvider(canonicalConfig.contents) + : null + if (!modelProvider || modelProvider === 'openai') { + return + } + + // Why: mirroring a custom-provider pin into an OAuth managed home makes + // the new OAuth credentials inert; fail before login and leave user config intact. + throw new Error( + `Orca cannot add a Codex OAuth account while ~/.codex/config.toml pins the custom provider ${JSON.stringify(modelProvider)}. Keep using the system-default account for this provider, or remove model_provider (or set it to "openai") before adding an OAuth account. Orca left your config unchanged.` + ) + } + + private syncToManagedHomes(): void { + for (const account of this.store.getSettings().codexManagedAccounts) { + try { + this.syncIntoManagedHome(account.managedHomePath, undefined, account.id) + } catch (error) { + console.warn('[codex-accounts] Failed to sync managed config:', error) + } + } + } + + private syncIntoManagedHome( + managedHomePath: string, + canonicalConfig = this.readForManagedHome(managedHomePath), + expectedAccountId?: string + ): void { + if (canonicalConfig === null) { + return + } + const trustedManagedHomePath = this.assertManagedHomePath(managedHomePath, expectedAccountId) + // Why: every account home is Codex's own CODEX_HOME. Preserve trust Codex + // granted there while refreshing ordinary settings from the lane's source. + syncSystemConfigIntoManagedCodexHome({ + runtimeHomePath: trustedManagedHomePath, + systemHomePath: canonicalConfig.sourceHomePath, + systemConfigDir: canonicalConfig.sourceConfigDir + }) + } + + private readHostConfig(): CanonicalCodexConfig | null { + const sourceHomePath = join(homedir(), '.codex') + const primaryConfigPath = join(sourceHomePath, 'config.toml') + if (!existsSync(primaryConfigPath)) { + return null + } + try { + return { contents: readFileSync(primaryConfigPath, 'utf-8'), sourceHomePath } + } catch (error) { + console.warn('[codex-accounts] Failed to read canonical config:', error) + return null + } + } +} diff --git a/src/main/codex-accounts/codex-login-session.ts b/src/main/codex-accounts/codex-login-session.ts new file mode 100644 index 00000000000..6df83c05e18 --- /dev/null +++ b/src/main/codex-accounts/codex-login-session.ts @@ -0,0 +1,281 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import type { WindowsHostInteractiveLoginSpawn } from '../../shared/windows-interactive-login-spawn' +import { buildWindowsHostInteractiveLoginSpawn } from '../../shared/windows-interactive-login-spawn' +import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution' +import { parseWslUncPath } from '../../shared/wsl-paths' +import { resolveCodexCommand } from '../codex-cli/command' +import { getSpawnArgsForWindows } from '../win32-utils' +import { runWslProcess } from '../wsl/wsl-runner' +import { + buildWslCodexAvailabilityScript, + buildWslCodexLoginArgs, + WSL_CODEX_AVAILABILITY_TIMEOUT_MS +} from './wsl-codex-command' + +const LOGIN_TIMEOUT_MS = 120_000 +const MAX_LOGIN_OUTPUT_CHARS = 4_000 +const WINDOWS_LOGIN_AUTH_POLL_INTERVAL_MS = 500 +const WINDOWS_LOGIN_POST_AUTH_EXIT_GRACE_MS = 5_000 + +type LoginOutputStream = { + on(event: 'data', listener: (chunk: Buffer) => void): unknown + off(event: 'data', listener: (chunk: Buffer) => void): unknown +} + +export type CodexLoginChild = { + stdout: LoginOutputStream | null + stderr: LoginOutputStream | null + pid?: number + exitCode: number | null + signalCode: string | null + kill(): boolean + on(event: 'error', listener: (error: Error) => void): unknown + on(event: 'close', listener: (code: number | null) => void): unknown + off(event: 'error', listener: (error: Error) => void): unknown + off(event: 'close', listener: (code: number | null) => void): unknown +} + +export type CodexLoginSpawnRequest = { + command: string + args: string[] + env: NodeJS.ProcessEnv + stdio: WindowsHostInteractiveLoginSpawn['stdio'] | ['ignore', 'pipe', 'pipe'] +} + +type CodexLoginSessionDependencies = { + wslCommand: string + spawn: (request: CodexLoginSpawnRequest) => CodexLoginChild + killProcessTree: ( + child: CodexLoginChild, + interactiveLogin?: WindowsHostInteractiveLoginSpawn | null + ) => void +} + +function readLoginAuthSnapshot(authJsonPath: string): string | null | undefined { + try { + return readFileSync(authJsonPath, 'utf-8') + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + if (code === 'ENOENT' || code === 'ENOTDIR') { + return null + } + // Why: codex can atomically replace auth.json while the poll runs; a later + // poll will observe the stable credential. An unreadable initial file must + // disable the shortcut rather than look like a fresh login. + return undefined + } +} + +function loginAuthChanged( + initial: string | null | undefined, + current: string | null | undefined +): boolean { + // Why: metadata-only touches can happen before OAuth finishes. Requiring new + // credential bytes prevents reauthentication from being killed prematurely. + return initial !== undefined && current !== undefined && current !== null && current !== initial +} + +export async function runCodexLoginSession( + managedHomePath: string, + dependencies: CodexLoginSessionDependencies +): Promise { + const wslInfo = parseWslUncPath(managedHomePath) + if (wslInfo) { + await assertWslCodexCliAvailable(wslInfo) + } + // Why: reauthentication starts with an existing auth.json. Only new auth + // bytes prove this login completed; existence alone would kill the + // Windows OAuth flow five seconds after it opened. + const initialAuthSnapshot = wslInfo + ? null + : readLoginAuthSnapshot(join(managedHomePath, 'auth.json')) + + await new Promise((resolvePromise, rejectPromise) => { + const spawnConfig = wslInfo + ? { + command: dependencies.wslCommand, + args: buildWslCodexLoginArgs(wslInfo.distro, wslInfo.linuxPath), + env: process.env, + codexCommand: 'codex', + interactiveLogin: null + } + : createHostLoginSpawn(managedHomePath) + const child = dependencies.spawn({ + command: spawnConfig.command, + args: spawnConfig.args, + env: spawnConfig.env, + stdio: spawnConfig.interactiveLogin + ? spawnConfig.interactiveLogin.stdio + : ['ignore', 'pipe', 'pipe'] + }) + + let settled = false + let output = '' + const appendOutput = (chunk: Buffer): void => { + output = `${output}${chunk.toString()}` + if (output.length > MAX_LOGIN_OUTPUT_CHARS) { + output = output.slice(-MAX_LOGIN_OUTPUT_CHARS) + } + } + + let timeout: ReturnType | null = null + let authWatchInterval: ReturnType | null = null + let postAuthExitTimeout: ReturnType | null = null + let loginTreeKilledAfterAuth = false + const authJsonPath = join(managedHomePath, 'auth.json') + const cleanupListeners = (): void => { + if (timeout) { + clearTimeout(timeout) + timeout = null + } + if (authWatchInterval) { + clearInterval(authWatchInterval) + authWatchInterval = null + } + if (postAuthExitTimeout) { + clearTimeout(postAuthExitTimeout) + postAuthExitTimeout = null + } + child.stdout?.off('data', appendOutput) + child.stderr?.off('data', appendOutput) + child.off('error', onError) + child.off('close', onClose) + spawnConfig.interactiveLogin?.cleanup?.() + } + + const settle = (callback: () => void): void => { + if (settled) { + return + } + settled = true + cleanupListeners() + callback() + } + + const timeoutError = new Error('Codex sign-in took too long to finish. Please try again.') + timeout = setTimeout(() => { + dependencies.killProcessTree(child, spawnConfig.interactiveLogin) + settle(() => rejectPromise(timeoutError)) + }, LOGIN_TIMEOUT_MS) + + // Why: on Windows the codex login CLI can linger after writing auth.json, + // and its open handles on the managed home (log/codex-login.log) make the + // post-login file operations fail with ENOTEMPTY. Once auth.json exists, + // give the tree a short grace period to exit, then force it down. + if (process.platform === 'win32' && !wslInfo) { + authWatchInterval = setInterval(() => { + if (!loginAuthChanged(initialAuthSnapshot, readLoginAuthSnapshot(authJsonPath))) { + return + } + if (authWatchInterval) { + clearInterval(authWatchInterval) + authWatchInterval = null + } + postAuthExitTimeout = setTimeout(() => { + loginTreeKilledAfterAuth = true + dependencies.killProcessTree(child, spawnConfig.interactiveLogin) + }, WINDOWS_LOGIN_POST_AUTH_EXIT_GRACE_MS) + }, WINDOWS_LOGIN_AUTH_POLL_INTERVAL_MS) + } + + const onError = (error: Error): void => { + settle(() => { + const isEnoent = (error as NodeJS.ErrnoException).code === 'ENOENT' + // Why: ENOENT is ambiguous — missing codex binary or missing node in PATH; a resolved full path implies node is missing. + const isBareCommand = spawnConfig.codexCommand === 'codex' + const message = isEnoent + ? isBareCommand + ? 'Codex CLI not found.' + : 'Codex CLI found but could not run — Node.js may not be in your PATH.' + : error.message + rejectPromise(new Error(message)) + }) + } + + const onClose = (code: number | null): void => { + settle(() => { + // Why: the post-auth tree kill is a success path — auth.json already + // exists and codex only failed to exit on its own, so the forced + // non-zero exit must not surface as a login failure. + // Why: the kill only arms after the watcher observed new credential + // bytes, so an unreadable auth.json here is a lock, not a failed login. + // Only a definitive absence may revoke that verdict — reading a lock as + // failure sends the caller's rollback at a home that just authenticated. + if ( + code === 0 || + (loginTreeKilledAfterAuth && readLoginAuthSnapshot(authJsonPath) !== null) + ) { + resolvePromise() + return + } + const trimmedOutput = output.trim() + rejectPromise( + new Error( + trimmedOutput + ? `Codex login failed: ${trimmedOutput}` + : `Codex login exited with code ${code ?? 'unknown'}.` + ) + ) + }) + } + + child.stdout?.on('data', appendOutput) + child.stderr?.on('data', appendOutput) + child.on('error', onError) + child.on('close', onClose) + }) +} + +function createHostLoginSpawn(managedHomePath: string): { + command: string + args: string[] + env: NodeJS.ProcessEnv + codexCommand: string + interactiveLogin: WindowsHostInteractiveLoginSpawn | null +} { + const codexCommand = resolveCodexCommand() + // Why: Windows host login needs a real console; otherwise inherit/hide + // leaves the child unable to read a paste-code / device-auth prompt. + const interactiveLogin = + process.platform === 'win32' + ? buildWindowsHostInteractiveLoginSpawn(codexCommand, ['login']) + : null + const { spawnCmd, spawnArgs } = interactiveLogin + ? { spawnCmd: interactiveLogin.command, spawnArgs: interactiveLogin.args } + : getSpawnArgsForWindows(codexCommand, ['login']) + return { + command: spawnCmd, + args: spawnArgs, + env: withCliRuntimeOnPath(codexCommand, { ...process.env, CODEX_HOME: managedHomePath }), + codexCommand, + interactiveLogin + } +} + +async function assertWslCodexCliAvailable(wslInfo: { + distro: string + linuxPath: string +}): Promise { + // This is a PATH lookup, so it needs the login PATH: an nvm-installed codex + // lives nowhere else. Marking it 'none' reports a working install as absent. + const result = await runWslProcess({ + distro: wslInfo.distro, + loginPath: 'preferred', + script: buildWslCodexAvailabilityScript(), + // POSIX command lookup; declared because the payload is opaque here. + shell: 'sh', + timeoutMs: WSL_CODEX_AVAILABILITY_TIMEOUT_MS + }) + if (result.code !== 0 && !result.environmentResolved) { + // A miss without the login PATH is "we could not check", not "not + // installed" -- claiming absence here is #9725. + throw new Error('Could not check the Codex CLI in WSL. Try again.') + } + if (result.code !== 0 || result.timedOut) { + throw new Error( + `Codex CLI is not available in WSL ${wslInfo.distro}. Install Codex in that distro or switch Account location to Windows.`, + { cause: new Error(result.stderr.trim() || `codex lookup exited with ${result.code}`) } + ) + } +} diff --git a/src/main/codex-accounts/codex-managed-home-lifecycle.ts b/src/main/codex-accounts/codex-managed-home-lifecycle.ts new file mode 100644 index 00000000000..b86a65762a1 --- /dev/null +++ b/src/main/codex-accounts/codex-managed-home-lifecycle.ts @@ -0,0 +1,225 @@ +import { mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import { dirname, join, resolve, sep } from 'node:path' +import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' +import { quotePosixShell } from '../../shared/wsl-login-shell-command' +import { parseWslUncPath } from '../../shared/wsl-paths' +import { toWindowsWslPath } from '../wsl' +import { runWslProcess } from '../wsl/wsl-runner' +import type { CodexAccountAddTarget, ManagedCodexHomeLocation } from './codex-account-service-types' +import { writeFileAtomically } from './fs-utils' +import { ManagedCodexHomeTemporarilyUnavailableError } from './host-codex-managed-home-ownership' +import type { CodexManagedHomePath } from './codex-managed-home-path' + +// Why: mirrors the Windows rm retry policy in local-worktree-filesystem — a +// just-terminated codex login can briefly keep handles inside a managed home. +const WINDOWS_RM_MAX_RETRIES = 8 +const WINDOWS_RM_RETRY_DELAY_MS = 150 +const WSL_MANAGED_HOME_TIMEOUT_MS = 5_000 + +function removeManagedHomeTreeSync(targetPath: string): void { + // Why: codex login descendants can briefly keep Windows handles on files in + // the managed home (e.g. log/codex-login.log); bounded retries absorb the + // transient lock instead of failing with ENOTEMPTY and orphaning the home. + rmSync(targetPath, { + recursive: true, + force: true, + maxRetries: WINDOWS_RM_MAX_RETRIES, + retryDelay: WINDOWS_RM_RETRY_DELAY_MS + }) +} + +export class CodexManagedHomeLifecycle { + constructor(private readonly paths: CodexManagedHomePath) {} + + async create( + accountId: string, + target?: CodexAccountAddTarget + ): Promise { + const wslHome = await this.tryCreateWslHome(accountId, target) + if (wslHome) { + return wslHome + } + + const managedHomePath = join(this.paths.getRoot(), accountId, 'home') + mkdirSync(managedHomePath, { recursive: true }) + // Why: marker lets future cleanup prove the path belongs to Orca before deleting anything. + writeFileSync(join(managedHomePath, '.orca-managed-home'), `${accountId}\n`, 'utf-8') + return { + managedHomePath: this.paths.assert(managedHomePath, accountId), + managedHomeRuntime: 'host', + wslDistro: null, + wslLinuxHomePath: null + } + } + + // Why: copy the auth.json from an already-authenticated CODEX_HOME (e.g. a temp + // dir the CLI ran `codex login` into) into the managed home. Mirrors the login + // step of doAddAccount without spawning an interactive browser flow. + importAuthFromHome(sourceHome: string, managedHomePath: string, accountId: string): void { + const trimmed = sourceHome.trim() + if (!trimmed) { + throw new Error('A Codex home directory path is required.') + } + const resolvedSourceHome = resolve(trimmed) + let sourceAuthContents: string + try { + sourceAuthContents = readFileSync(join(resolvedSourceHome, 'auth.json'), 'utf-8') + } catch (error) { + // Why: "no credentials here, run codex login" is only true for a definitive + // absence. A locked source file used to produce that advice, which sends the + // user to re-run a login they had already completed. + if (!isDefinitiveAbsence(error)) { + throw new ManagedCodexHomeTemporarilyUnavailableError(undefined, { cause: error }) + } + throw new Error( + `No Codex credentials found in ${resolvedSourceHome}. Run \`codex login\` into this directory first.` + ) + } + const trustedHome = this.paths.assert(managedHomePath, accountId) + writeFileAtomically(join(trustedHome, 'auth.json'), sourceAuthContents, { mode: 0o600 }) + } + + /** + * Rollback deletes the managed home, so it may only run on a *proven* failure. + * An unreadable credential file means the login may well have succeeded, and a + * kept home is a recoverable leak where a deleted one is permanent data loss. + */ + removeUnlessUnproven(error: unknown, managedHomePath: string, accountId: string): void { + if (error instanceof ManagedCodexHomeTemporarilyUnavailableError) { + return + } + this.safeRemove(managedHomePath, accountId) + } + + safeRemove(candidatePath: string, expectedAccountId: string): void { + let managedHomePath: string + try { + managedHomePath = this.paths.assert(candidatePath, expectedAccountId) + } catch (error) { + console.warn('[codex-accounts] Refusing to remove untrusted managed home:', error) + return + } + + try { + removeManagedHomeTreeSync(managedHomePath) + } catch (error) { + // Why: this runs from error-cleanup paths; a still-held Windows handle + // must not mask the original failure with an ENOTEMPTY from rmSync. + console.warn('[codex-accounts] Failed to remove managed home:', error) + return + } + + if (parseWslUncPath(managedHomePath)) { + try { + removeManagedHomeTreeSync(dirname(managedHomePath)) + } catch { + // Best-effort cleanup + } + return + } + + // Why: homes live at //home; removing the home/ leaf leaves an empty / behind. + try { + const parentDir = resolve(managedHomePath, '..') + // Why: canonicalize the root too so the prefix check works on macOS where userData resolves through /private/var. + const root = realpathSync(this.paths.getRoot()) + if (parentDir.startsWith(root + sep) && parentDir !== root) { + removeManagedHomeTreeSync(parentDir) + } + } catch { + // Best-effort cleanup + } + } + + private async tryCreateWslHome( + accountId: string, + target?: CodexAccountAddTarget + ): Promise { + if (process.platform !== 'win32' || target?.runtime !== 'wsl') { + return null + } + const requestedDistro = target.wslDistro?.trim() || undefined + const info = await runWslProcess({ + distro: requestedDistro, + loginPath: 'none', + script: 'printf "%s\\n%s\\n" "$WSL_DISTRO_NAME" "$HOME"', + shell: 'bash', + timeoutMs: WSL_MANAGED_HOME_TIMEOUT_MS + }) + if (info.code !== 0 || info.timedOut) { + throw new Error('Could not resolve the active WSL home directory for Codex login.') + } + const [rawDistro, rawHome] = info.stdout + .replaceAll(String.fromCharCode(0), '') + .split(/\r?\n/) + .map((line) => line.trim()) + const distro = requestedDistro || rawDistro + const home = rawHome + if (!distro || !home?.startsWith('/')) { + throw new Error('Could not resolve the active WSL home directory for Codex login.') + } + + const linuxPath = `${home.replace(/\/$/, '')}/.local/share/orca/codex-accounts/${accountId}/home` + const markerPath = `${linuxPath}/.orca-managed-home` + const created = await runWslProcess({ + distro, + loginPath: 'none', + script: `mkdir -p ${quotePosixShell(linuxPath)} && printf '%s\\n' ${quotePosixShell(accountId)} > ${quotePosixShell(markerPath)}`, + shell: 'bash', + timeoutMs: WSL_MANAGED_HOME_TIMEOUT_MS + }) + if (created.code !== 0 || created.timedOut) { + throw new Error('Could not create the managed Codex home inside WSL.') + } + + const managedHomePath = toWindowsWslPath(linuxPath, distro) + try { + return { + managedHomePath: this.paths.assert(managedHomePath, accountId), + managedHomeRuntime: 'wsl', + wslDistro: distro, + wslLinuxHomePath: linuxPath + } + } catch (error) { + await this.safeRemoveWslCandidate(distro, linuxPath, accountId) + throw error + } + } + + private async safeRemoveWslCandidate( + distro: string, + linuxHomePath: string, + expectedAccountId: string + ): Promise { + // Why: creation can fail after mkdir/marker but before trust, so cleanup must verify the marker/account ID inside WSL. + try { + const result = await runWslProcess({ + distro, + loginPath: 'none', + script: [ + 'set -euo pipefail', + `candidate=${quotePosixShell(linuxHomePath)}`, + `expected_marker=${quotePosixShell(expectedAccountId)}`, + 'managed_root="${HOME%/}/.local/share/orca/codex-accounts"', + 'candidate_real=$(readlink -f -- "$candidate" 2>/dev/null || true)', + 'managed_root_real=$(readlink -f -- "$managed_root" 2>/dev/null || true)', + 'test -n "$candidate_real"', + 'test -n "$managed_root_real"', + 'case "$candidate_real" in "$managed_root_real"/*/home) ;; *) exit 0 ;; esac', + 'test -f "$candidate_real/.orca-managed-home"', + 'test "$(cat "$candidate_real/.orca-managed-home")" = "$expected_marker"', + 'rm -rf -- "$candidate_real"', + 'parent_dir=$(dirname -- "$candidate_real")', + 'case "$parent_dir" in "$managed_root_real"/*) rmdir -- "$parent_dir" 2>/dev/null || true ;; esac' + ].join('\n'), + shell: 'bash', + timeoutMs: WSL_MANAGED_HOME_TIMEOUT_MS + }) + if (result.code !== 0 || result.timedOut) { + throw new Error(`WSL cleanup exited with ${result.timedOut ? 'a timeout' : result.code}`) + } + } catch (error) { + console.warn('[codex-accounts] Failed to clean up WSL managed home candidate:', error) + } + } +} diff --git a/src/main/codex-accounts/codex-managed-home-path.ts b/src/main/codex-accounts/codex-managed-home-path.ts new file mode 100644 index 00000000000..012ef943aa4 --- /dev/null +++ b/src/main/codex-accounts/codex-managed-home-path.ts @@ -0,0 +1,200 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { app } from 'electron' +import { quotePosixShell } from '../../shared/wsl-login-shell-command' +import { parseWslUncPath } from '../../shared/wsl-paths' +import type { CodexManagedAccount } from '../../shared/managed-account-types' +import { getSystemCodexHomePath } from '../codex/codex-home-paths' +import { toWindowsWslPath } from '../wsl' +import { runWslProcess } from '../wsl/wsl-runner' +import { assertOwnedHostCodexManagedHomePath } from './host-codex-managed-home-ownership' + +const WSL_MANAGED_HOME_TIMEOUT_MS = 5_000 + +export class CodexManagedHomePath { + constructor(private readonly validateWslPath: (distro: string, script: string) => string) {} + + getRoot(): string { + const root = join(app.getPath('userData'), 'codex-accounts') + mkdirSync(root, { recursive: true }) + return root + } + + assertHostOwnership(candidatePath: string, expectedAccountId: string): string { + return assertOwnedHostCodexManagedHomePath({ + candidatePath, + managedAccountsRoot: join(app.getPath('userData'), 'codex-accounts'), + systemCodexHomePath: getSystemCodexHomePath(), + expectedAccountId + }) + } + + async ensureForReauthentication(account: CodexManagedAccount): Promise { + const wslInfo = parseWslUncPath(account.managedHomePath) + if (wslInfo && process.platform === 'win32') { + await this.ensureExpectedWslHome(account, wslInfo) + return this.assert(account.managedHomePath, account.id) + } + + try { + return this.assert(account.managedHomePath, account.id) + } catch (error) { + if (!this.isMissingHomeError(error)) { + throw error + } + return this.recreateExpectedHostHome(account, error) + } + } + + assert(candidatePath: string, expectedAccountId?: string): string { + const wslInfo = parseWslUncPath(candidatePath) + if (!wslInfo) { + return assertOwnedHostCodexManagedHomePath({ + candidatePath, + managedAccountsRoot: this.getRoot(), + systemCodexHomePath: getSystemCodexHomePath(), + expectedAccountId + }) + } + if ( + !wslInfo.linuxPath.includes('/.local/share/orca/codex-accounts/') || + !wslInfo.linuxPath.endsWith('/home') + ) { + throw new Error('Managed WSL Codex home is outside Orca account storage.') + } + if ( + expectedAccountId !== undefined && + !wslInfo.linuxPath.endsWith(`/.local/share/orca/codex-accounts/${expectedAccountId}/home`) + ) { + throw new Error('Managed WSL Codex home does not match its persisted account ID.') + } + if (process.platform === 'win32') { + return this.assertWindowsWslPath(wslInfo, expectedAccountId) + } + return this.assertMountedWslPath(candidatePath, wslInfo.linuxPath, expectedAccountId) + } + + private recreateExpectedHostHome(account: CodexManagedAccount, originalError: unknown): string { + const expectedPath = join(this.getRoot(), account.id, 'home') + if (!this.pathsEqual(account.managedHomePath, expectedPath)) { + throw originalError + } + // Why: re-auth may recreate a lost empty home, but only at the exact Orca-owned path persisted for this account. + mkdirSync(expectedPath, { recursive: true }) + writeFileSync(join(expectedPath, '.orca-managed-home'), `${account.id}\n`, 'utf-8') + return this.assert(expectedPath, account.id) + } + + private async ensureExpectedWslHome( + account: CodexManagedAccount, + wslInfo: { distro: string; linuxPath: string } + ): Promise { + if ( + account.managedHomeRuntime !== 'wsl' || + account.wslDistro !== wslInfo.distro || + account.wslLinuxHomePath !== wslInfo.linuxPath || + !wslInfo.linuxPath.endsWith(`/.local/share/orca/codex-accounts/${account.id}/home`) + ) { + return + } + const result = await runWslProcess({ + distro: wslInfo.distro, + loginPath: 'none', + script: [ + 'set -euo pipefail', + `candidate=${quotePosixShell(wslInfo.linuxPath)}`, + `expected_marker=${quotePosixShell(account.id)}`, + 'marker="$candidate/.orca-managed-home"', + 'if [ -e "$candidate" ] && [ ! -f "$marker" ]; then exit 41; fi', + 'if [ -f "$marker" ] && [ "$(cat "$marker")" != "$expected_marker" ]; then exit 42; fi', + 'mkdir -p -- "$candidate"', + 'printf "%s\\n" "$expected_marker" > "$marker"' + ].join('\n'), + shell: 'bash', + timeoutMs: WSL_MANAGED_HOME_TIMEOUT_MS + }) + // Why: 41/42 mean the path is not this account's home; re-auth must refuse + // rather than write credentials into someone else's directory. + if (result.code !== 0 || result.timedOut) { + throw new Error( + `Could not prepare the managed Codex home in WSL ${wslInfo.distro} for re-authentication.` + ) + } + } + + private assertWindowsWslPath( + wslInfo: { distro: string; linuxPath: string }, + expectedAccountId?: string + ): string { + try { + const canonicalLinuxPath = this.validateWslPath( + wslInfo.distro, + [ + 'set -euo pipefail', + `candidate=${quotePosixShell(wslInfo.linuxPath)}`, + 'managed_root="${HOME%/}/.local/share/orca/codex-accounts"', + 'candidate_real=$(readlink -f -- "$candidate")', + 'managed_root_real=$(readlink -f -- "$managed_root")', + 'test -f "$candidate_real/.orca-managed-home"', + ...(expectedAccountId === undefined + ? [ + 'case "$candidate_real" in "$managed_root_real"/*/home) printf "%s\\n" "$candidate_real" ;; *) exit 35 ;; esac' + ] + : [ + `expected_marker=${quotePosixShell(expectedAccountId)}`, + 'test "$candidate_real" = "$managed_root_real/$expected_marker/home"', + 'test "$(cat "$candidate_real/.orca-managed-home")" = "$expected_marker"', + 'printf "%s\\n" "$candidate_real"' + ]) + ].join('\n') + ).trim() + if (!canonicalLinuxPath) { + throw new Error('Managed Codex home directory does not exist on disk.') + } + return toWindowsWslPath(canonicalLinuxPath, wslInfo.distro) + } catch (error) { + throw new Error('Managed WSL Codex home is outside Orca account storage.', { + cause: error + }) + } + } + + private assertMountedWslPath( + candidatePath: string, + linuxPath: string, + expectedAccountId?: string + ): string { + if (linuxPath.split('/').includes('..')) { + throw new Error('Managed WSL Codex home is outside Orca account storage.') + } + if (!existsSync(candidatePath)) { + throw new Error('Managed Codex home directory does not exist on disk.') + } + const markerPath = join(candidatePath, '.orca-managed-home') + if (!existsSync(markerPath)) { + throw new Error('Managed Codex home is missing Orca ownership marker.') + } + if ( + expectedAccountId !== undefined && + readFileSync(markerPath, 'utf-8').trim() !== expectedAccountId + ) { + throw new Error('Managed WSL Codex home ownership marker does not match its account ID.') + } + return candidatePath + } + + private isMissingHomeError(error: unknown): boolean { + return ( + error instanceof Error && + error.message === 'Managed Codex home directory does not exist on disk.' + ) + } + + private pathsEqual(left: string, right: string): boolean { + const resolvedLeft = resolve(left) + const resolvedRight = resolve(right) + return process.platform === 'win32' + ? resolvedLeft.toLowerCase() === resolvedRight.toLowerCase() + : resolvedLeft === resolvedRight + } +} diff --git a/src/main/codex-accounts/codex-reset-credit-coordinator.ts b/src/main/codex-accounts/codex-reset-credit-coordinator.ts new file mode 100644 index 00000000000..5d6233d46eb --- /dev/null +++ b/src/main/codex-accounts/codex-reset-credit-coordinator.ts @@ -0,0 +1,248 @@ +import { randomUUID } from 'node:crypto' +import type { + CodexManagedAccount, + CodexManagedAccountSummary, + CodexRateLimitAccountsState +} from '../../shared/managed-account-types' +import { + buildCodexResetCreditExpectedScope, + type CodexResetCreditExpectedScope +} from '../../shared/codex-reset-credit-scope' +import type { + CodexRateLimitResetResult, + RateLimitState, + RateLimitRuntimeTarget +} from '../../shared/rate-limit-types' +import type { Store } from '../persistence' +import type { RateLimitService } from '../rate-limits/service' +import type { CodexRuntimeHomeService } from './runtime-home-service' +import { getSelectedCodexAccountIdForTarget } from './runtime-selection' +import { ManagedCodexHomeTemporarilyUnavailableError } from './host-codex-managed-home-ownership' +import type { + CodexResetCreditConsumeResult, + CodexResetCreditConsumedResult +} from './codex-account-service-types' +import type { CodexManagedHomePath } from './codex-managed-home-path' +import { + CodexResetCreditLedger, + resetAccountScopeKey, + resetScopeKey, + type CodexResetCreditAttempt +} from './codex-reset-credit-ledger' +import { + CodexResetCreditScopeRejection, + validateCodexResetCreditScope, + type CodexResetCreditScopeValidation +} from './codex-reset-credit-scope-validation' + +function sameTarget(left: RateLimitRuntimeTarget, right: RateLimitRuntimeTarget): boolean { + return left.runtime === right.runtime && left.wslDistro === right.wslDistro +} + +type CodexResetCreditCoordinatorDependencies = { + store: Store + rateLimits: RateLimitService + runtimeHome: CodexRuntimeHomeService + managedHomePaths: CodexManagedHomePath + serializeMutation: (operation: () => Promise) => Promise + getSnapshot: () => CodexRateLimitAccountsState + toSummary: (account: CodexManagedAccount) => CodexManagedAccountSummary +} + +export class CodexResetCreditCoordinator { + private readonly ledger: CodexResetCreditLedger + + constructor(private readonly dependencies: CodexResetCreditCoordinatorDependencies) { + this.ledger = new CodexResetCreditLedger(dependencies.store) + } + + consume( + idempotencyKey: string, + expectedScope: CodexResetCreditExpectedScope + ): Promise { + if (this.ledger.error) { + return Promise.reject(this.ledger.error) + } + const scopeKey = resetScopeKey(expectedScope) + const accountScopeKey = resetAccountScopeKey(expectedScope) + const existing = this.ledger.get(idempotencyKey) + if (existing) { + if (existing.scopeKey !== scopeKey) { + return Promise.reject(new Error('That idempotency key belongs to a different reset scope.')) + } + if (existing.state === 'settled' && existing.settledOutcome) { + return this.dependencies.serializeMutation(async () => { + const { rateLimits } = this.validateScope(expectedScope, { kind: 'settledReplay' }) + return { + outcome: existing.settledOutcome!, + scope: existing.expectedScope, + codex: this.dependencies.getSnapshot(), + rateLimits + } + }) + } + if (existing.promise) { + return existing.promise as Promise + } + return this.startAttempt(idempotencyKey, expectedScope, existing) + } + + const unresolvedKey = this.ledger.getUnresolvedKey(accountScopeKey) + if (unresolvedKey && unresolvedKey !== idempotencyKey) { + return Promise.reject( + new Error('A previous reset attempt for this account still has an unknown outcome.') + ) + } + const claimedKey = this.ledger.getClaimedKey(scopeKey) + if (claimedKey && claimedKey !== idempotencyKey) { + return Promise.reject(new Error('That reset-credit offer was already attempted.')) + } + return this.startAttempt( + idempotencyKey, + expectedScope, + this.ledger.createFresh(idempotencyKey, expectedScope) + ) + } + + async consumeCurrent(): Promise { + if (this.ledger.error) { + throw this.ledger.error + } + const initialRateLimits = this.dependencies.rateLimits.getState() + const initialTarget = { ...initialRateLimits.codexTarget } + const initialSettings = this.dependencies.store.getSettings() + const selectedAccountId = getSelectedCodexAccountIdForTarget(initialSettings, initialTarget) + if (selectedAccountId) { + const account = initialSettings.codexManagedAccounts.find( + (candidate) => candidate.id === selectedAccountId + ) + const pendingAttempt = account + ? this.ledger.getPendingForAccount(initialTarget, account) + : null + const expectedScope = + pendingAttempt?.expectedScope ?? + (account + ? buildCodexResetCreditExpectedScope({ + target: initialTarget, + account: this.dependencies.toSummary(account), + limits: initialRateLimits.codex + }) + : null) + if (!expectedScope) { + throw new Error('The managed Codex reset-credit offer is no longer available.') + } + // Why: do not enter the mutation queue first; the coordinator owns that + // queue and nested serialization would deadlock behind this operation. + const result = await this.consume( + pendingAttempt?.idempotencyKey ?? randomUUID(), + expectedScope + ) + if ('status' in result) { + throw new Error('The Codex account or reset offer changed before reset.') + } + return { outcome: result.outcome, state: result.rateLimits } + } + + return this.dependencies.serializeMutation(async () => { + if (this.ledger.error) { + throw this.ledger.error + } + const target = this.dependencies.rateLimits.getState().codexTarget + if (!sameTarget(target, initialTarget)) { + throw new Error('The active Codex rate-limit target changed before reset.') + } + if (getSelectedCodexAccountIdForTarget(this.dependencies.store.getSettings(), target)) { + throw new Error('The selected Codex account changed before reset.') + } + if (this.ledger.hasPendingForTarget(target)) { + throw new Error('A previous reset attempt for this target still has an unknown outcome.') + } + const homeResolution = this.dependencies.runtimeHome.prepareForRateLimitFetch(target) + // Why: reject before the provider mutation — a skip must never be spent + // against the system-default home (#STA-4422). + if (homeResolution.kind === 'skip') { + throw new ManagedCodexHomeTemporarilyUnavailableError() + } + return this.dependencies.rateLimits.consumeCodexRateLimitResetCredit({ + idempotencyKey: randomUUID(), + target, + codexHomePath: homeResolution.codexHomePath + }) + }) + } + + discardForRemovedAccount(accountId: string): void { + this.ledger.discardForRemovedAccount(accountId) + } + + private startAttempt( + idempotencyKey: string, + expectedScope: CodexResetCreditExpectedScope, + attempt: CodexResetCreditAttempt + ): Promise { + const promise = this.dependencies.serializeMutation( + async (): Promise => { + const isFresh = attempt.state === 'fresh' + let validation: { managedHomePath: string; rateLimits: RateLimitState } + try { + validation = this.validateScope(expectedScope, { + kind: 'providerMutation', + requireCurrentOffer: isFresh + }) + } catch (error) { + if (isFresh && error instanceof CodexResetCreditScopeRejection) { + this.ledger.releaseFresh(idempotencyKey, attempt) + return { + status: 'rejectedBeforeProvider', + retryDisposition: 'discardAttempt', + reason: error.reason, + scope: expectedScope, + codex: this.dependencies.getSnapshot(), + rateLimits: error.rateLimits + } + } + throw error + } + if (isFresh) { + this.ledger.markProviderPending(idempotencyKey, attempt) + } + const { outcome, state } = + await this.dependencies.rateLimits.consumeCodexRateLimitResetCredit({ + idempotencyKey, + target: expectedScope.target, + codexHomePath: validation.managedHomePath + }) + // Why: queued account selection may start as soon as this mutation resolves; + // capture both account selection and usage before releasing the queue. + const result: CodexResetCreditConsumedResult = { + outcome, + scope: expectedScope, + codex: this.dependencies.getSnapshot(), + rateLimits: state + } + this.ledger.markSettled(idempotencyKey, attempt, outcome) + return result + } + ) + attempt.promise = promise + void promise.then( + () => { + attempt.promise = null + }, + () => { + attempt.promise = null + if (attempt.state === 'fresh') { + this.ledger.releaseFresh(idempotencyKey, attempt) + } + } + ) + return promise + } + + private validateScope( + expectedScope: CodexResetCreditExpectedScope, + validation: CodexResetCreditScopeValidation + ): { managedHomePath: string; rateLimits: RateLimitState } { + return validateCodexResetCreditScope(expectedScope, validation, this.dependencies) + } +} diff --git a/src/main/codex-accounts/codex-reset-credit-ledger.ts b/src/main/codex-accounts/codex-reset-credit-ledger.ts new file mode 100644 index 00000000000..625731f7d38 --- /dev/null +++ b/src/main/codex-accounts/codex-reset-credit-ledger.ts @@ -0,0 +1,227 @@ +import type { CodexManagedAccount } from '../../shared/managed-account-types' +import type { + CodexResetCreditAttemptLedger, + DurableCodexResetCreditAttempt +} from '../../shared/codex-reset-credit-attempt-ledger' +import type { CodexResetCreditExpectedScope } from '../../shared/codex-reset-credit-scope' +import type { + CodexRateLimitResetOutcome, + RateLimitRuntimeTarget +} from '../../shared/rate-limit-types' +import type { Store } from '../persistence' + +export type CodexResetCreditAttempt = { + expectedScope: CodexResetCreditExpectedScope + scopeKey: string + accountScopeKey: string + state: 'fresh' | 'providerPending' | 'settled' + promise: Promise | null + settledOutcome: CodexRateLimitResetOutcome | null +} + +export function resetScopeKey(scope: CodexResetCreditExpectedScope): string { + return JSON.stringify([ + scope.target.runtime, + scope.target.wslDistro, + scope.accountId, + scope.accountRevision, + scope.offerRevision + ]) +} + +export function resetAccountScopeKey( + scope: Pick +): string { + return JSON.stringify([ + scope.target.runtime, + scope.target.wslDistro, + scope.accountId, + scope.accountRevision + ]) +} + +export class CodexResetCreditLedger { + private readonly attemptsByKey = new Map() + private readonly attemptKeyByOffer = new Map() + private readonly unresolvedKeyByAccountScope = new Map() + private durableLedger: CodexResetCreditAttemptLedger | null = null + private loadError: Error | null = null + + constructor(private readonly store: Store) { + this.hydrate() + } + + get error(): Error | null { + return this.loadError + } + + get(idempotencyKey: string): CodexResetCreditAttempt | undefined { + return this.attemptsByKey.get(idempotencyKey) + } + + getUnresolvedKey(accountScopeKey: string): string | undefined { + return this.unresolvedKeyByAccountScope.get(accountScopeKey) + } + + getClaimedKey(scopeKey: string): string | undefined { + return this.attemptKeyByOffer.get(scopeKey) + } + + createFresh( + idempotencyKey: string, + expectedScope: CodexResetCreditExpectedScope + ): CodexResetCreditAttempt { + const attempt: CodexResetCreditAttempt = { + expectedScope, + scopeKey: resetScopeKey(expectedScope), + accountScopeKey: resetAccountScopeKey(expectedScope), + state: 'fresh', + promise: null, + settledOutcome: null + } + this.attemptsByKey.set(idempotencyKey, attempt) + this.attemptKeyByOffer.set(attempt.scopeKey, idempotencyKey) + return attempt + } + + getPendingForAccount( + target: RateLimitRuntimeTarget, + account: CodexManagedAccount + ): { idempotencyKey: string; expectedScope: CodexResetCreditExpectedScope } | null { + const accountScopeKey = resetAccountScopeKey({ + target, + accountId: account.id, + accountRevision: account.updatedAt + }) + const idempotencyKey = this.unresolvedKeyByAccountScope.get(accountScopeKey) + if (!idempotencyKey) { + return null + } + const attempt = this.attemptsByKey.get(idempotencyKey) + if (attempt?.state !== 'providerPending') { + throw new Error('Codex reset-credit attempt state is inconsistent.') + } + // Why: a durable providerPending attempt can only be resolved with its original key. + return { idempotencyKey, expectedScope: attempt.expectedScope } + } + + hasPendingForTarget(target: RateLimitRuntimeTarget): boolean { + return [...this.attemptsByKey.values()].some( + (attempt) => + attempt.state === 'providerPending' && + attempt.expectedScope.target.runtime === target.runtime && + attempt.expectedScope.target.wslDistro === target.wslDistro + ) + } + + markProviderPending(idempotencyKey: string, attempt: CodexResetCreditAttempt): void { + this.persist({ idempotencyKey, expectedScope: attempt.expectedScope, state: 'providerPending' }) + attempt.state = 'providerPending' + this.unresolvedKeyByAccountScope.set(attempt.accountScopeKey, idempotencyKey) + } + + markSettled( + idempotencyKey: string, + attempt: CodexResetCreditAttempt, + outcome: CodexRateLimitResetOutcome + ): void { + this.persist({ + idempotencyKey, + expectedScope: attempt.expectedScope, + state: 'settled', + outcome + }) + attempt.state = 'settled' + attempt.settledOutcome = outcome + if (this.unresolvedKeyByAccountScope.get(attempt.accountScopeKey) === idempotencyKey) { + this.unresolvedKeyByAccountScope.delete(attempt.accountScopeKey) + } + } + + releaseFresh(idempotencyKey: string, attempt: CodexResetCreditAttempt): void { + if (attempt.state !== 'fresh') { + return + } + this.attemptsByKey.delete(idempotencyKey) + if (this.attemptKeyByOffer.get(attempt.scopeKey) === idempotencyKey) { + this.attemptKeyByOffer.delete(attempt.scopeKey) + } + } + + // Why: a removed account's managed home is gone, so its unresolved providerPending + // attempt can never validate or be replayed; drop it so a target-scoped default reset + // is not wedged forever by hasPendingResetForTarget matching the orphan. + discardForRemovedAccount(accountId: string): void { + const staleAttempts = [...this.attemptsByKey].filter( + ([, attempt]) => attempt.expectedScope.accountId === accountId + ) + if (staleAttempts.length === 0) { + return + } + const staleKeySet = new Set(staleAttempts.map(([idempotencyKey]) => idempotencyKey)) + if (this.durableLedger) { + const attempts = this.durableLedger.attempts.filter( + (attempt) => !staleKeySet.has(attempt.idempotencyKey) + ) + if (attempts.length !== this.durableLedger.attempts.length) { + const nextLedger: CodexResetCreditAttemptLedger = { version: 1, attempts } + // Persist first so a failed durability barrier leaves the in-memory + // fail-closed guards aligned with the ledger that will reload. + this.store.replaceCodexResetCreditAttemptLedgerAndFlush(nextLedger) + this.durableLedger = structuredClone(nextLedger) + } + } + for (const [idempotencyKey, attempt] of staleAttempts) { + this.attemptsByKey.delete(idempotencyKey) + if (this.attemptKeyByOffer.get(attempt.scopeKey) === idempotencyKey) { + this.attemptKeyByOffer.delete(attempt.scopeKey) + } + if (this.unresolvedKeyByAccountScope.get(attempt.accountScopeKey) === idempotencyKey) { + this.unresolvedKeyByAccountScope.delete(attempt.accountScopeKey) + } + } + } + + private hydrate(): void { + try { + const ledger = this.store.getCodexResetCreditAttemptLedger() + this.durableLedger = ledger + for (const durable of ledger.attempts) { + const attempt: CodexResetCreditAttempt = { + expectedScope: durable.expectedScope, + scopeKey: resetScopeKey(durable.expectedScope), + accountScopeKey: resetAccountScopeKey(durable.expectedScope), + state: durable.state, + promise: null, + settledOutcome: durable.state === 'settled' ? durable.outcome : null + } + this.attemptsByKey.set(durable.idempotencyKey, attempt) + this.attemptKeyByOffer.set(attempt.scopeKey, durable.idempotencyKey) + if (durable.state === 'providerPending') { + this.unresolvedKeyByAccountScope.set(attempt.accountScopeKey, durable.idempotencyKey) + } + } + } catch (error) { + this.loadError = + error instanceof Error ? error : new Error('Codex reset-credit attempt ledger is corrupt') + } + } + + private persist(nextAttempt: DurableCodexResetCreditAttempt): void { + if (!this.durableLedger) { + throw this.loadError ?? new Error('Codex reset-credit attempt ledger is unavailable') + } + const index = this.durableLedger.attempts.findIndex( + (attempt) => attempt.idempotencyKey === nextAttempt.idempotencyKey + ) + const attempts = [...this.durableLedger.attempts] + if (index === -1) { + attempts.push(nextAttempt) + } else { + attempts[index] = nextAttempt + } + const nextLedger: CodexResetCreditAttemptLedger = { version: 1, attempts } + this.store.replaceCodexResetCreditAttemptLedgerAndFlush(nextLedger) + this.durableLedger = structuredClone(nextLedger) + } +} diff --git a/src/main/codex-accounts/codex-reset-credit-scope-validation.ts b/src/main/codex-accounts/codex-reset-credit-scope-validation.ts new file mode 100644 index 00000000000..50abc472d26 --- /dev/null +++ b/src/main/codex-accounts/codex-reset-credit-scope-validation.ts @@ -0,0 +1,115 @@ +import type { + CodexManagedAccount, + CodexManagedAccountSummary +} from '../../shared/managed-account-types' +import { + buildCodexResetCreditExpectedScope, + type CodexResetCreditExpectedScope +} from '../../shared/codex-reset-credit-scope' +import type { RateLimitState, RateLimitRuntimeTarget } from '../../shared/rate-limit-types' +import type { Store } from '../persistence' +import type { RateLimitService } from '../rate-limits/service' +import type { CodexResetCreditRejectedBeforeProviderReason } from './codex-account-service-types' +import type { CodexManagedHomePath } from './codex-managed-home-path' +import { resetScopeKey } from './codex-reset-credit-ledger' +import { + getCodexSelectionTargetForAccount, + getSelectedCodexAccountIdForTarget, + normalizeCodexAccountSelectionTarget +} from './runtime-selection' + +export type CodexResetCreditScopeValidation = + | { kind: 'settledReplay' } + | { kind: 'providerMutation'; requireCurrentOffer: boolean } + +export class CodexResetCreditScopeRejection extends Error { + constructor( + readonly reason: CodexResetCreditRejectedBeforeProviderReason, + readonly rateLimits: RateLimitState, + message: string + ) { + super(message) + this.name = 'CodexResetCreditScopeRejection' + } +} + +type CodexResetCreditScopeDependencies = { + store: Store + rateLimits: RateLimitService + managedHomePaths: CodexManagedHomePath + toSummary: (account: CodexManagedAccount) => CodexManagedAccountSummary +} + +function sameTarget(left: RateLimitRuntimeTarget, right: RateLimitRuntimeTarget): boolean { + return left.runtime === right.runtime && left.wslDistro === right.wslDistro +} + +export function validateCodexResetCreditScope( + expectedScope: CodexResetCreditExpectedScope, + validation: CodexResetCreditScopeValidation, + dependencies: CodexResetCreditScopeDependencies +): { managedHomePath: string; rateLimits: RateLimitState } { + const rateLimitState = dependencies.rateLimits.getState() + if (!sameTarget(rateLimitState.codexTarget, expectedScope.target)) { + throw new CodexResetCreditScopeRejection( + 'targetChanged', + rateLimitState, + 'The active Codex rate-limit target changed before reset.' + ) + } + const settings = dependencies.store.getSettings() + if ( + getSelectedCodexAccountIdForTarget(settings, expectedScope.target) !== expectedScope.accountId + ) { + throw new CodexResetCreditScopeRejection( + 'accountChanged', + rateLimitState, + 'The selected Codex account changed before reset.' + ) + } + const account = settings.codexManagedAccounts.find( + (candidate) => candidate.id === expectedScope.accountId + ) + if (!account || account.updatedAt !== expectedScope.accountRevision) { + throw new CodexResetCreditScopeRejection( + 'accountRevisionChanged', + rateLimitState, + 'The selected Codex account was updated before reset.' + ) + } + const accountTarget = normalizeCodexAccountSelectionTarget( + getCodexSelectionTargetForAccount(account) + ) + if (!sameTarget(accountTarget, expectedScope.target)) { + throw new CodexResetCreditScopeRejection( + 'accountRuntimeChanged', + rateLimitState, + 'The selected Codex account belongs to a different runtime.' + ) + } + if (validation.kind === 'providerMutation' && validation.requireCurrentOffer) { + const currentScope = buildCodexResetCreditExpectedScope({ + target: rateLimitState.codexTarget, + account: dependencies.toSummary(account), + limits: rateLimitState.codex + }) + if (!currentScope) { + throw new CodexResetCreditScopeRejection( + 'offerUnavailable', + rateLimitState, + 'The Codex reset-credit offer is no longer available.' + ) + } + if (resetScopeKey(expectedScope) !== resetScopeKey(currentScope)) { + throw new CodexResetCreditScopeRejection( + 'offerChanged', + rateLimitState, + 'The Codex reset-credit offer changed before reset.' + ) + } + } + if (validation.kind === 'providerMutation' && expectedScope.target.runtime === 'host') { + dependencies.managedHomePaths.assertHostOwnership(account.managedHomePath, account.id) + } + return { managedHomePath: account.managedHomePath, rateLimits: rateLimitState } +} diff --git a/src/main/codex-accounts/service.ts b/src/main/codex-accounts/service.ts index 97a3985c701..663b49acf21 100644 --- a/src/main/codex-accounts/service.ts +++ b/src/main/codex-accounts/service.ts @@ -1,220 +1,47 @@ -/* eslint-disable max-lines -- Why: keeps Codex account lifecycle, path safety, login, and identity parsing in one audited main-process module. */ -import { randomUUID } from 'node:crypto' -import { execFileSync, spawn, type ChildProcess } from 'node:child_process' -import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs' -import { dirname, join, resolve, sep } from 'node:path' -import { homedir } from 'node:os' -import { app } from 'electron' -import { getSpawnArgsForWindows } from '../win32-utils' -import { - buildWindowsHostInteractiveLoginSpawn, - type WindowsHostInteractiveLoginSpawn -} from '../../shared/windows-interactive-login-spawn' +import { execFileSync, spawn } from 'node:child_process' +import type { WindowsHostInteractiveLoginSpawn } from '../../shared/windows-interactive-login-spawn' import type { CodexManagedAccount, CodexManagedAccountSummary, CodexRateLimitAccountsState, CodexSystemDefaultIdentity } from '../../shared/managed-account-types' -import type { - CodexRateLimitResetOutcome, - CodexRateLimitResetResult, - RateLimitState, - RateLimitRuntimeTarget -} from '../../shared/rate-limit-types' -import { - buildCodexResetCreditExpectedScope, - type CodexResetCreditExpectedScope -} from '../../shared/codex-reset-credit-scope' -import type { - CodexResetCreditAttemptLedger, - DurableCodexResetCreditAttempt -} from '../../shared/codex-reset-credit-attempt-ledger' +import type { CodexRateLimitResetResult } from '../../shared/rate-limit-types' +import type { CodexResetCreditExpectedScope } from '../../shared/codex-reset-credit-scope' import type { CodexRuntimeHomeService } from './runtime-home-service' -import { writeFileAtomically } from './fs-utils' -import { syncSystemConfigIntoManagedCodexHome } from '../codex/codex-config-mirror' -import { getSystemCodexHomePath } from '../codex/codex-home-paths' -import { readCodexTopLevelModelProvider } from '../codex/codex-model-provider-config' -import { resolveCodexCommand } from '../codex-cli/command' -import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution' import type { Store } from '../persistence' import type { RateLimitService } from '../rate-limits/service' -import { parseWslUncPath } from '../../shared/wsl-paths' -import { toWindowsWslPath } from '../wsl' import { buildEncodedWslBashCommand } from '../wsl-bash-command' -import { runWslProcess } from '../wsl/wsl-runner' -import { - buildWslCodexAvailabilityScript, - buildWslCodexLoginArgs, - WSL_CODEX_AVAILABILITY_TIMEOUT_MS -} from './wsl-codex-command' -import { - getCodexSelectionTargetForAccount, - getSelectedCodexAccountIdForTarget, - normalizeCodexAccountSelectionTarget, - normalizeCodexRuntimeSelection, - pruneInvalidCodexRuntimeSelection, - removeCodexAccountIdFromSelection, - setSelectedCodexAccountIdForTarget, - type CodexAccountSelectionTarget -} from './runtime-selection' -import { - assertOwnedHostCodexManagedHomePath, - ManagedCodexHomeTemporarilyUnavailableError -} from './host-codex-managed-home-ownership' -import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' +import type { CodexAccountSelectionTarget } from './runtime-selection' +import { CodexAccountIdentity, type ResolvedCodexIdentity } from './codex-account-identity' +import { CodexConfigMirror } from './codex-config-mirror' +import { runCodexLoginSession, type CodexLoginChild } from './codex-login-session' +import { CodexManagedHomePath } from './codex-managed-home-path' +import { CodexManagedHomeLifecycle } from './codex-managed-home-lifecycle' +import { CodexResetCreditCoordinator } from './codex-reset-credit-coordinator' +import { CodexAccountSelection } from './codex-account-selection' +import { CodexAccountRegistration } from './codex-account-registration' +import type { + CodexAccountAddTarget, + CodexAccountReauthenticateOptions, + CodexAccountServiceLifecycle, + CodexResetCreditConsumeResult +} from './codex-account-service-types' +import { toCodexManagedAccountSummary } from './codex-account-service-types' +export type { + CodexAccountAddTarget, + CodexAccountReauthenticateOptions, + CodexAccountServiceLifecycle, + CodexResetCreditConsumeResult, + CodexResetCreditConsumedResult, + CodexResetCreditRejectedBeforeProviderReason, + CodexResetCreditRejectedBeforeProviderResult +} from './codex-account-service-types' -const LOGIN_TIMEOUT_MS = 120_000 -const MAX_LOGIN_OUTPUT_CHARS = 4_000 -// Why: mirrors the Windows rm retry policy in local-worktree-filesystem — a -// just-terminated codex login can briefly keep handles inside a managed home. -const WINDOWS_RM_MAX_RETRIES = 8 -const WINDOWS_RM_RETRY_DELAY_MS = 150 -const WINDOWS_LOGIN_AUTH_POLL_INTERVAL_MS = 500 -const WINDOWS_LOGIN_POST_AUTH_EXIT_GRACE_MS = 5_000 const WINDOWS_LOGIN_TREE_KILL_TIMEOUT_MS = 5_000 -const WSL_MANAGED_HOME_TIMEOUT_MS = 5_000 - -type CodexOAuthCredentials = { - idToken: string | null - accountId: string | null -} - -type ResolvedCodexIdentity = { - email: string | null - providerAccountId: string | null - workspaceLabel: string | null - workspaceAccountId: string | null -} - -type CanonicalCodexConfig = { - contents: string - /** Host-readable source home; the mirror resolves WSL UNC paths to their Linux spelling. */ - sourceHomePath: string - /** Preserve Linux path semantics when WSL $HOME is under /mnt/. */ - sourceConfigDir?: string -} - -export type CodexAccountAddTarget = { - runtime?: 'host' | 'wsl' - wslDistro?: string | null -} - -export type CodexAccountReauthenticateOptions = { - /** - * Local-only intent from the status bar's "Sign in to see usage" action: when - * this account's runtime lane had no selection before login, activate the - * account that just signed in instead of restoring the empty selection. - */ - activateIfSelectionWasEmpty?: boolean -} - -export type CodexAccountServiceLifecycle = { - onHostSystemDefaultSelected?: () => void -} - -type ManagedHomeLocation = { - managedHomePath: string - managedHomeRuntime: 'host' | 'wsl' - wslDistro: string | null - wslLinuxHomePath: string | null -} - -export type CodexResetCreditRejectedBeforeProviderReason = - | 'targetChanged' - | 'accountChanged' - | 'accountRevisionChanged' - | 'accountRuntimeChanged' - | 'offerUnavailable' - | 'offerChanged' - -export type CodexResetCreditConsumedResult = { - outcome: CodexRateLimitResetOutcome - scope: CodexResetCreditExpectedScope - codex: CodexRateLimitAccountsState - rateLimits: RateLimitState -} - -export type CodexResetCreditRejectedBeforeProviderResult = { - status: 'rejectedBeforeProvider' - retryDisposition: 'discardAttempt' - reason: CodexResetCreditRejectedBeforeProviderReason - scope: CodexResetCreditExpectedScope - codex: CodexRateLimitAccountsState - rateLimits: RateLimitState -} - -export type CodexResetCreditConsumeResult = - | CodexResetCreditConsumedResult - | CodexResetCreditRejectedBeforeProviderResult - -type CodexResetCreditAttempt = { - expectedScope: CodexResetCreditExpectedScope - scopeKey: string - accountScopeKey: string - state: 'fresh' | 'providerPending' | 'settled' - promise: Promise | null - settledOutcome: CodexRateLimitResetOutcome | null -} - -type CodexResetCreditScopeValidation = - | { kind: 'settledReplay' } - | { kind: 'providerMutation'; requireCurrentOffer: boolean } - -class CodexResetCreditScopeRejection extends Error { - constructor( - readonly reason: CodexResetCreditRejectedBeforeProviderReason, - readonly rateLimits: RateLimitState, - message: string - ) { - super(message) - this.name = 'CodexResetCreditScopeRejection' - } -} - -function resetScopeKey(scope: CodexResetCreditExpectedScope): string { - return JSON.stringify([ - scope.target.runtime, - scope.target.wslDistro, - scope.accountId, - scope.accountRevision, - scope.offerRevision - ]) -} - -function resetAccountScopeKey( - scope: Pick -): string { - return JSON.stringify([ - scope.target.runtime, - scope.target.wslDistro, - scope.accountId, - scope.accountRevision - ]) -} - -function sameRateLimitTarget(left: RateLimitRuntimeTarget, right: RateLimitRuntimeTarget): boolean { - return left.runtime === right.runtime && left.wslDistro === right.wslDistro -} - -function shellQuote(value: string): string { - return `'${value.replace(/'/g, "'\\''")}'` -} - -function removeManagedHomeTreeSync(targetPath: string): void { - // Why: codex login descendants can briefly keep Windows handles on files in - // the managed home (e.g. log/codex-login.log); bounded retries absorb the - // transient lock instead of failing with ENOTEMPTY and orphaning the home. - rmSync(targetPath, { - recursive: true, - force: true, - maxRetries: WINDOWS_RM_MAX_RETRIES, - retryDelay: WINDOWS_RM_RETRY_DELAY_MS - }) -} function killLoginProcessTree( - child: ChildProcess, + child: CodexLoginChild, interactiveLogin?: WindowsHostInteractiveLoginSpawn | null ): void { const terminationPid = interactiveLogin?.getTerminationPid?.() ?? child.pid @@ -242,47 +69,68 @@ function killLoginProcessTree( child.kill() } -function readLoginAuthSnapshot(authJsonPath: string): string | null | undefined { - try { - return readFileSync(authJsonPath, 'utf-8') - } catch (error) { - const code = (error as NodeJS.ErrnoException).code - if (code === 'ENOENT' || code === 'ENOTDIR') { - return null - } - // Why: codex can atomically replace auth.json while the poll runs; a later - // poll will observe the stable credential. An unreadable initial file must - // disable the shortcut rather than look like a fresh login. - return undefined - } -} - -function loginAuthChanged( - initial: string | null | undefined, - current: string | null | undefined -): boolean { - // Why: metadata-only touches can happen before OAuth finishes. Requiring new - // credential bytes prevents reauthentication from being killed prematurely. - return initial !== undefined && current !== undefined && current !== null && current !== initial -} - export class CodexAccountService { // Why: serialize the read-modify-write of settings; overlapping calls (e.g. double-click Add) would lose updates. private mutationQueue: Promise = Promise.resolve() - private readonly resetAttemptsByKey = new Map() - private readonly resetAttemptKeyByOffer = new Map() - private readonly unresolvedResetKeyByAccountScope = new Map() - private durableResetLedger: CodexResetCreditAttemptLedger | null = null - private resetLedgerLoadError: Error | null = null + private readonly identity: CodexAccountIdentity + private readonly configMirror: CodexConfigMirror + private readonly managedHomePaths: CodexManagedHomePath + private readonly managedHomes: CodexManagedHomeLifecycle + private readonly resetCredits: CodexResetCreditCoordinator + private readonly selection: CodexAccountSelection + private readonly registration: CodexAccountRegistration constructor( - private readonly store: Store, - private readonly rateLimits: RateLimitService, + store: Store, + rateLimits: RateLimitService, private readonly runtimeHome: CodexRuntimeHomeService, - private readonly lifecycle: CodexAccountServiceLifecycle = {} + lifecycle: CodexAccountServiceLifecycle = {} ) { - this.hydrateResetCreditAttempts() - this.safeSyncCanonicalConfigToManagedHomes() + this.managedHomePaths = new CodexManagedHomePath((distro, script) => + execFileSync( + 'wsl.exe', + ['-d', distro, '--exec', 'bash', '-lc', buildEncodedWslBashCommand(script)], + { windowsHide: true, encoding: 'utf-8', timeout: 5000 } + ) + ) + this.managedHomes = new CodexManagedHomeLifecycle(this.managedHomePaths) + this.identity = new CodexAccountIdentity((path, accountId) => + this.managedHomePaths.assert(path, accountId) + ) + this.configMirror = new CodexConfigMirror(store, (path, accountId) => + this.managedHomePaths.assert(path, accountId) + ) + this.resetCredits = new CodexResetCreditCoordinator({ + store, + rateLimits, + runtimeHome, + managedHomePaths: this.managedHomePaths, + serializeMutation: (operation) => this.serializeMutation(operation), + getSnapshot: () => this.getSnapshot(), + toSummary: (account) => this.toSummary(account) + }) + this.selection = new CodexAccountSelection({ + store, + rateLimits, + runtimeHome, + configMirror: this.configMirror, + lifecycle, + resolveSystemDefault: () => this.resolveSystemDefaultIdentity(), + removeManagedHome: (path, accountId) => this.safeRemoveManagedHome(path, accountId), + discardResetAttempts: (accountId) => this.resetCredits.discardForRemovedAccount(accountId) + }) + this.registration = new CodexAccountRegistration({ + store, + rateLimits, + runtimeHome, + readIdentityFromHome: (path, accountId) => this.readIdentityFromHome(path, accountId), + selection: this.selection, + configMirror: this.configMirror, + managedHomePaths: this.managedHomePaths, + managedHomes: this.managedHomes, + login: (managedHomePath) => this.runCodexLogin(managedHomePath) + }) + this.configMirror.safeSyncToManagedHomes() } /** @@ -301,12 +149,11 @@ export class CodexAccountService { } listAccounts(): CodexRateLimitAccountsState { - this.normalizeActiveSelection() - return this.getSnapshot() + return this.selection.list() } async addAccount(target?: CodexAccountAddTarget): Promise { - return this.serializeMutation(() => this.doAddAccount(target)) + return this.serializeMutation(() => this.registration.add(target)) } /** @@ -319,1674 +166,76 @@ export class CodexAccountService { sourceHome: string, target?: CodexAccountAddTarget ): Promise { - return this.serializeMutation(() => this.doAddAccountFromHome(sourceHome, target)) + return this.serializeMutation(() => this.registration.addFromHome(sourceHome, target)) } async reauthenticateAccount( accountId: string, options?: CodexAccountReauthenticateOptions ): Promise { - return this.serializeMutation(() => this.doReauthenticateAccount(accountId, options)) + return this.serializeMutation(() => this.registration.reauthenticate(accountId, options)) } async removeAccount(accountId: string): Promise { - return this.serializeMutation(() => this.doRemoveAccount(accountId)) + return this.serializeMutation(() => this.selection.remove(accountId)) } async selectAccount(accountId: string | null): Promise { - return this.serializeMutation(() => this.doSelectAccount(accountId)) + return this.serializeMutation(() => this.selection.select(accountId)) } async selectAccountForTarget( accountId: string | null, target?: CodexAccountSelectionTarget ): Promise { - return this.serializeMutation(() => this.doSelectAccount(accountId, target)) + return this.serializeMutation(() => this.selection.select(accountId, target)) } consumeRateLimitResetCredit( idempotencyKey: string, expectedScope: CodexResetCreditExpectedScope ): Promise { - if (this.resetLedgerLoadError) { - return Promise.reject(this.resetLedgerLoadError) - } - const scopeKey = resetScopeKey(expectedScope) - const accountScopeKey = resetAccountScopeKey(expectedScope) - const existing = this.resetAttemptsByKey.get(idempotencyKey) - if (existing) { - if (existing.scopeKey !== scopeKey) { - return Promise.reject(new Error('That idempotency key belongs to a different reset scope.')) - } - if (existing.state === 'settled' && existing.settledOutcome) { - return this.serializeMutation(async () => { - const { rateLimits } = this.validateResetCreditScope(expectedScope, { - kind: 'settledReplay' - }) - return { - outcome: existing.settledOutcome!, - scope: existing.expectedScope, - codex: this.getSnapshot(), - rateLimits - } - }) - } - if (existing.promise) { - return existing.promise - } - return this.startResetCreditAttempt(idempotencyKey, expectedScope, existing) - } - - const unresolvedKey = this.unresolvedResetKeyByAccountScope.get(accountScopeKey) - if (unresolvedKey && unresolvedKey !== idempotencyKey) { - return Promise.reject( - new Error('A previous reset attempt for this account still has an unknown outcome.') - ) - } - const claimedKey = this.resetAttemptKeyByOffer.get(scopeKey) - if (claimedKey && claimedKey !== idempotencyKey) { - return Promise.reject(new Error('That reset-credit offer was already attempted.')) - } - - const attempt: CodexResetCreditAttempt = { - expectedScope, - scopeKey, - accountScopeKey, - state: 'fresh', - promise: null, - settledOutcome: null - } - this.resetAttemptsByKey.set(idempotencyKey, attempt) - this.resetAttemptKeyByOffer.set(scopeKey, idempotencyKey) - return this.startResetCreditAttempt(idempotencyKey, expectedScope, attempt) + return this.resetCredits.consume(idempotencyKey, expectedScope) } async consumeCurrentRateLimitResetCredit(): Promise { - if (this.resetLedgerLoadError) { - throw this.resetLedgerLoadError - } - const initialRateLimits = this.rateLimits.getState() - const initialTarget = { ...initialRateLimits.codexTarget } - const initialSettings = this.store.getSettings() - const selectedAccountId = getSelectedCodexAccountIdForTarget(initialSettings, initialTarget) - if (selectedAccountId) { - const account = initialSettings.codexManagedAccounts.find( - (candidate) => candidate.id === selectedAccountId - ) - const pendingAttempt = account - ? this.getPendingResetAttemptForAccount(initialTarget, account) - : null - const expectedScope = - pendingAttempt?.expectedScope ?? - (account - ? buildCodexResetCreditExpectedScope({ - target: initialTarget, - account: this.toSummary(account), - limits: initialRateLimits.codex - }) - : null) - if (!expectedScope) { - throw new Error('The managed Codex reset-credit offer is no longer available.') - } - // Why: do not enter the mutation queue first; the coordinator owns that - // queue and nested serialization would deadlock behind this operation. - const result = await this.consumeRateLimitResetCredit( - pendingAttempt?.idempotencyKey ?? randomUUID(), - expectedScope - ) - if ('status' in result) { - throw new Error('The Codex account or reset offer changed before reset.') - } - return { outcome: result.outcome, state: result.rateLimits } - } - - return this.serializeMutation(async () => { - if (this.resetLedgerLoadError) { - throw this.resetLedgerLoadError - } - const target = this.rateLimits.getState().codexTarget - if (!sameRateLimitTarget(target, initialTarget)) { - throw new Error('The active Codex rate-limit target changed before reset.') - } - if (getSelectedCodexAccountIdForTarget(this.store.getSettings(), target)) { - throw new Error('The selected Codex account changed before reset.') - } - if (this.hasPendingResetForTarget(target)) { - throw new Error('A previous reset attempt for this target still has an unknown outcome.') - } - const homeResolution = this.runtimeHome.prepareForRateLimitFetch(target) - // Why: reject before the provider mutation — a skip must never be spent - // against the system-default home (#STA-4422). - if (homeResolution.kind === 'skip') { - throw new ManagedCodexHomeTemporarilyUnavailableError() - } - const codexHomePath = homeResolution.codexHomePath - return this.rateLimits.consumeCodexRateLimitResetCredit({ - idempotencyKey: randomUUID(), - target, - codexHomePath - }) - }) - } - - private getPendingResetAttemptForAccount( - target: RateLimitRuntimeTarget, - account: CodexManagedAccount - ): { idempotencyKey: string; expectedScope: CodexResetCreditExpectedScope } | null { - const accountScopeKey = resetAccountScopeKey({ - target, - accountId: account.id, - accountRevision: account.updatedAt - }) - const idempotencyKey = this.unresolvedResetKeyByAccountScope.get(accountScopeKey) - if (!idempotencyKey) { - return null - } - const attempt = this.resetAttemptsByKey.get(idempotencyKey) - if (attempt?.state !== 'providerPending') { - throw new Error('Codex reset-credit attempt state is inconsistent.') - } - // Why: a durable providerPending attempt can only be resolved with its original key. - return { idempotencyKey, expectedScope: attempt.expectedScope } - } - - private hasPendingResetForTarget(target: RateLimitRuntimeTarget): boolean { - return [...this.resetAttemptsByKey.values()].some( - (attempt) => - attempt.state === 'providerPending' && - sameRateLimitTarget(attempt.expectedScope.target, target) - ) - } - - private startResetCreditAttempt( - idempotencyKey: string, - expectedScope: CodexResetCreditExpectedScope, - attempt: CodexResetCreditAttempt - ): Promise { - const promise = this.serializeMutation(async (): Promise => { - const isFresh = attempt.state === 'fresh' - let validation: { managedHomePath: string; rateLimits: RateLimitState } - try { - validation = this.validateResetCreditScope(expectedScope, { - kind: 'providerMutation', - requireCurrentOffer: isFresh - }) - } catch (error) { - if (isFresh && error instanceof CodexResetCreditScopeRejection) { - this.releaseFreshResetAttempt(idempotencyKey, attempt) - return { - status: 'rejectedBeforeProvider', - retryDisposition: 'discardAttempt', - reason: error.reason, - scope: expectedScope, - codex: this.getSnapshot(), - rateLimits: error.rateLimits - } - } - throw error - } - if (isFresh) { - this.persistResetAttempt({ - idempotencyKey, - expectedScope, - state: 'providerPending' - }) - attempt.state = 'providerPending' - this.unresolvedResetKeyByAccountScope.set(attempt.accountScopeKey, idempotencyKey) - } - const { outcome, state } = await this.rateLimits.consumeCodexRateLimitResetCredit({ - idempotencyKey, - target: expectedScope.target, - codexHomePath: validation.managedHomePath - }) - // Why: queued account selection may start as soon as this mutation resolves; - // capture both account selection and usage before releasing the queue. - const result: CodexResetCreditConsumedResult = { - outcome, - scope: expectedScope, - codex: this.getSnapshot(), - rateLimits: state - } - this.persistResetAttempt({ - idempotencyKey, - expectedScope, - state: 'settled', - outcome - }) - attempt.state = 'settled' - attempt.settledOutcome = outcome - if (this.unresolvedResetKeyByAccountScope.get(attempt.accountScopeKey) === idempotencyKey) { - this.unresolvedResetKeyByAccountScope.delete(attempt.accountScopeKey) - } - return result - }) - attempt.promise = promise - void promise.then( - () => { - attempt.promise = null - }, - () => { - attempt.promise = null - if (attempt.state === 'fresh') { - this.releaseFreshResetAttempt(idempotencyKey, attempt) - } - } - ) - return promise - } - - private validateResetCreditScope( - expectedScope: CodexResetCreditExpectedScope, - validation: CodexResetCreditScopeValidation - ): { managedHomePath: string; rateLimits: RateLimitState } { - const rateLimitState = this.rateLimits.getState() - if (!sameRateLimitTarget(rateLimitState.codexTarget, expectedScope.target)) { - throw new CodexResetCreditScopeRejection( - 'targetChanged', - rateLimitState, - 'The active Codex rate-limit target changed before reset.' - ) - } - - const settings = this.store.getSettings() - if ( - getSelectedCodexAccountIdForTarget(settings, expectedScope.target) !== expectedScope.accountId - ) { - throw new CodexResetCreditScopeRejection( - 'accountChanged', - rateLimitState, - 'The selected Codex account changed before reset.' - ) - } - const account = settings.codexManagedAccounts.find( - (candidate) => candidate.id === expectedScope.accountId - ) - if (!account || account.updatedAt !== expectedScope.accountRevision) { - throw new CodexResetCreditScopeRejection( - 'accountRevisionChanged', - rateLimitState, - 'The selected Codex account was updated before reset.' - ) - } - const normalizedAccountTarget = normalizeCodexAccountSelectionTarget( - getCodexSelectionTargetForAccount(account) - ) - if (!sameRateLimitTarget(normalizedAccountTarget, expectedScope.target)) { - throw new CodexResetCreditScopeRejection( - 'accountRuntimeChanged', - rateLimitState, - 'The selected Codex account belongs to a different runtime.' - ) - } - - if (validation.kind === 'providerMutation' && validation.requireCurrentOffer) { - const currentScope = buildCodexResetCreditExpectedScope({ - target: rateLimitState.codexTarget, - account: this.toSummary(account), - limits: rateLimitState.codex - }) - if (!currentScope) { - throw new CodexResetCreditScopeRejection( - 'offerUnavailable', - rateLimitState, - 'The Codex reset-credit offer is no longer available.' - ) - } - if (resetScopeKey(expectedScope) !== resetScopeKey(currentScope)) { - throw new CodexResetCreditScopeRejection( - 'offerChanged', - rateLimitState, - 'The Codex reset-credit offer changed before reset.' - ) - } - } - - if (validation.kind === 'providerMutation' && expectedScope.target.runtime === 'host') { - assertOwnedHostCodexManagedHomePath({ - candidatePath: account.managedHomePath, - managedAccountsRoot: join(app.getPath('userData'), 'codex-accounts'), - systemCodexHomePath: getSystemCodexHomePath(), - expectedAccountId: account.id - }) - } - - return { managedHomePath: account.managedHomePath, rateLimits: rateLimitState } - } - - private hydrateResetCreditAttempts(): void { - try { - const ledger = this.store.getCodexResetCreditAttemptLedger() - this.durableResetLedger = ledger - for (const durable of ledger.attempts) { - const scopeKey = resetScopeKey(durable.expectedScope) - const accountScopeKey = resetAccountScopeKey(durable.expectedScope) - this.resetAttemptsByKey.set(durable.idempotencyKey, { - expectedScope: durable.expectedScope, - scopeKey, - accountScopeKey, - state: durable.state, - promise: null, - settledOutcome: durable.state === 'settled' ? durable.outcome : null - }) - this.resetAttemptKeyByOffer.set(scopeKey, durable.idempotencyKey) - if (durable.state === 'providerPending') { - this.unresolvedResetKeyByAccountScope.set(accountScopeKey, durable.idempotencyKey) - } - } - } catch (error) { - this.resetLedgerLoadError = - error instanceof Error ? error : new Error('Codex reset-credit attempt ledger is corrupt') - } - } - - private persistResetAttempt(nextAttempt: DurableCodexResetCreditAttempt): void { - if (!this.durableResetLedger) { - throw ( - this.resetLedgerLoadError ?? new Error('Codex reset-credit attempt ledger is unavailable') - ) - } - const index = this.durableResetLedger.attempts.findIndex( - (attempt) => attempt.idempotencyKey === nextAttempt.idempotencyKey - ) - const attempts = [...this.durableResetLedger.attempts] - if (index === -1) { - attempts.push(nextAttempt) - } else { - attempts[index] = nextAttempt - } - const nextLedger: CodexResetCreditAttemptLedger = { version: 1, attempts } - this.store.replaceCodexResetCreditAttemptLedgerAndFlush(nextLedger) - this.durableResetLedger = structuredClone(nextLedger) - } - - private releaseFreshResetAttempt(idempotencyKey: string, attempt: CodexResetCreditAttempt): void { - if (attempt.state !== 'fresh') { - return - } - this.resetAttemptsByKey.delete(idempotencyKey) - if (this.resetAttemptKeyByOffer.get(attempt.scopeKey) === idempotencyKey) { - this.resetAttemptKeyByOffer.delete(attempt.scopeKey) - } - } - - // Why: a removed account's managed home is gone, so its unresolved providerPending - // attempt can never validate or be replayed; drop it so a target-scoped default reset - // is not wedged forever by hasPendingResetForTarget matching the orphan. - private discardResetAttemptsForRemovedAccount(accountId: string): void { - const staleAttempts: [string, CodexResetCreditAttempt][] = [] - for (const [idempotencyKey, attempt] of this.resetAttemptsByKey) { - if (attempt.expectedScope.accountId === accountId) { - staleAttempts.push([idempotencyKey, attempt]) - } - } - if (staleAttempts.length === 0) { - return - } - const staleKeySet = new Set(staleAttempts.map(([idempotencyKey]) => idempotencyKey)) - if (this.durableResetLedger) { - const attempts = this.durableResetLedger.attempts.filter( - (attempt) => !staleKeySet.has(attempt.idempotencyKey) - ) - if (attempts.length !== this.durableResetLedger.attempts.length) { - const nextLedger: CodexResetCreditAttemptLedger = { version: 1, attempts } - // Persist first so a failed durability barrier leaves the in-memory - // fail-closed guards aligned with the ledger that will reload. - this.store.replaceCodexResetCreditAttemptLedgerAndFlush(nextLedger) - this.durableResetLedger = structuredClone(nextLedger) - } - } - for (const [idempotencyKey, attempt] of staleAttempts) { - this.resetAttemptsByKey.delete(idempotencyKey) - if (this.resetAttemptKeyByOffer.get(attempt.scopeKey) === idempotencyKey) { - this.resetAttemptKeyByOffer.delete(attempt.scopeKey) - } - if (this.unresolvedResetKeyByAccountScope.get(attempt.accountScopeKey) === idempotencyKey) { - this.unresolvedResetKeyByAccountScope.delete(attempt.accountScopeKey) - } - } - } - - // Why: quota probes against a cold per-account CODEX_HOME can take 10–25s - // (RPC + PTY fallback) and queue behind an in-flight global usage refresh. - // The refresh synchronously flips usage to "fetching" before its first await, - // so the switcher updates immediately; the probe itself must never block or - // fail the already-durable account mutation. - private startQuotaRefreshInBackground( - outgoingAccountId: string | null | undefined, - target: CodexAccountSelectionTarget | undefined - ): void { - void this.rateLimits.refreshForCodexAccountChange(outgoingAccountId, target).catch((error) => { - console.error('[codex-accounts] Quota refresh after account change failed:', error) - }) - } - - private async doAddAccount(target?: CodexAccountAddTarget): Promise { - const accountId = randomUUID() - const managedHome = await this.createManagedHome(accountId, target) - const { managedHomePath } = managedHome - try { - const canonicalConfig = this.readCanonicalConfigForManagedHome(managedHomePath) - this.assertOAuthAccountAddAllowed(canonicalConfig) - this.safeSyncCanonicalConfigIntoManagedHome(managedHomePath, canonicalConfig, accountId) - await this.runCodexLogin(managedHomePath) - return await this.persistCapturedCodexAccount(accountId, managedHome) - } catch (error) { - this.removeManagedHomeUnlessUnproven(error, managedHomePath, accountId) - throw error - } - } - - private async doAddAccountFromHome( - sourceHome: string, - target?: CodexAccountAddTarget - ): Promise { - const accountId = randomUUID() - const managedHome = await this.createManagedHome(accountId, target) - const { managedHomePath } = managedHome - try { - const canonicalConfig = this.readCanonicalConfigForManagedHome(managedHomePath) - this.assertOAuthAccountAddAllowed(canonicalConfig) - this.safeSyncCanonicalConfigIntoManagedHome(managedHomePath, canonicalConfig, accountId) - this.importCodexAuthFromHome(sourceHome, managedHomePath, accountId) - return await this.persistCapturedCodexAccount(accountId, managedHome) - } catch (error) { - this.removeManagedHomeUnlessUnproven(error, managedHomePath, accountId) - throw error - } - } - - // Why: copy the auth.json from an already-authenticated CODEX_HOME (e.g. a temp - // dir the CLI ran `codex login` into) into the managed home. Mirrors the login - // step of doAddAccount without spawning an interactive browser flow. - private importCodexAuthFromHome( - sourceHome: string, - managedHomePath: string, - accountId: string - ): void { - const trimmed = sourceHome.trim() - if (!trimmed) { - throw new Error('A Codex home directory path is required.') - } - const authPath = join(resolve(trimmed), 'auth.json') - let sourceAuthContents: string - try { - sourceAuthContents = readFileSync(authPath, 'utf-8') - } catch (error) { - // Why: "no credentials here, run codex login" is only true for a definitive - // absence. A locked source file used to produce that advice, which sends the - // user to re-run a login they had already completed. - if (!isDefinitiveAbsence(error)) { - throw new ManagedCodexHomeTemporarilyUnavailableError(undefined, { cause: error }) - } - throw new Error( - `No Codex credentials found in ${resolve(trimmed)}. Run \`codex login\` into this directory first.` - ) - } - const trustedHome = this.assertManagedHomePath(managedHomePath, accountId) - writeFileAtomically(join(trustedHome, 'auth.json'), sourceAuthContents, { - mode: 0o600 - }) - } - - private async persistCapturedCodexAccount( - accountId: string, - managedHome: ManagedHomeLocation - ): Promise { - const identity = this.readIdentityFromHome(managedHome.managedHomePath, accountId) - if (!identity.email) { - throw new Error('Codex login completed, but Orca could not resolve the account email.') - } - - const now = Date.now() - const account: CodexManagedAccount = { - id: accountId, - email: identity.email, - managedHomePath: managedHome.managedHomePath, - managedHomeRuntime: managedHome.managedHomeRuntime, - wslDistro: managedHome.wslDistro, - wslLinuxHomePath: managedHome.wslLinuxHomePath, - providerAccountId: identity.providerAccountId, - workspaceLabel: identity.workspaceLabel, - workspaceAccountId: identity.workspaceAccountId, - createdAt: now, - updatedAt: now, - lastAuthenticatedAt: now - } - - const settings = this.store.getSettings() - const selection = normalizeCodexRuntimeSelection(settings) - const targetSelection = getCodexSelectionTargetForAccount(account) - this.store.updateSettings({ - codexManagedAccounts: [...settings.codexManagedAccounts, account], - activeCodexManagedAccountId: targetSelection.runtime === 'host' ? account.id : selection.host, - activeCodexManagedAccountIdsByRuntime: setSelectedCodexAccountIdForTarget( - selection, - account.id, - targetSelection - ) - }) - try { - this.safeSyncCanonicalConfigToManagedHomes() - this.runtimeHome.clearLastWrittenAuthJson(account.id) - // Why: pass the account's selection target so a WSL account syncs the WSL - // runtime home instead of the default host target. - this.runtimeHome.syncForCurrentSelection(targetSelection) - } catch (error) { - // Why: settings were already written; if a post-write step fails, restore the - // previous account/selection so the caller's managed-home cleanup cannot leave - // a dangling, broken managed account behind in settings. - this.store.updateSettings({ - codexManagedAccounts: settings.codexManagedAccounts, - activeCodexManagedAccountId: settings.activeCodexManagedAccountId, - activeCodexManagedAccountIdsByRuntime: settings.activeCodexManagedAccountIdsByRuntime - }) - // Why: a failed post-write step must restore both persisted selection and - // the runtime home it drives before the new managed home is removed. - try { - this.runtimeHome.syncForCurrentSelection(targetSelection) - } catch (rollbackError) { - console.warn( - '[codex-accounts] Failed to restore runtime home during rollback:', - rollbackError - ) - } - throw error - } - - // Why: switching activates the new account, so cache the outgoing account's usage for the - // switcher — in the background, since the probe must never block or fail a durable add. - const outgoingAccountId = getSelectedCodexAccountIdForTarget(settings, targetSelection) - this.startQuotaRefreshInBackground(outgoingAccountId, targetSelection) - return this.getSnapshot() - } - - private async doReauthenticateAccount( - accountId: string, - options?: CodexAccountReauthenticateOptions - ): Promise { - const account = this.requireAccount(accountId) - const managedHomePath = await this.ensureManagedHomeForReauthentication(account) - const accountTarget = getCodexSelectionTargetForAccount(account) - const selectedAccountId = getSelectedCodexAccountIdForTarget( - this.store.getSettings(), - accountTarget - ) - // Why: decided from the pre-login capture, never a post-login read — the - // runtime-home poll runs outside the mutation queue and can clear this lane - // while OAuth is open, which would look like an empty selection to activate. - const activateAfterLogin = - options?.activateIfSelectionWasEmpty === true && selectedAccountId === null - - this.safeSyncCanonicalConfigIntoManagedHome(managedHomePath, undefined, account.id) - await this.runCodexLogin(managedHomePath) - const identity = this.readIdentityFromHome(managedHomePath, account.id) - if (!identity.email) { - throw new Error('Codex login completed, but Orca could not resolve the account email.') - } - - const settings = this.store.getSettings() - const now = Date.now() - const updatedAccounts = settings.codexManagedAccounts.map((entry) => - entry.id === accountId - ? { - ...entry, - email: identity.email!, - providerAccountId: identity.providerAccountId, - workspaceLabel: identity.workspaceLabel, - workspaceAccountId: identity.workspaceAccountId, - updatedAt: now, - lastAuthenticatedAt: now - } - : entry - ) - const activeSelection = setSelectedCodexAccountIdForTarget( - normalizeCodexRuntimeSelection(settings), - activateAfterLogin ? accountId : selectedAccountId, - accountTarget - ) - - // Why: login can transiently clear this runtime's selection; unrelated runtime validation must remain authoritative. - this.store.updateSettings({ - codexManagedAccounts: updatedAccounts, - activeCodexManagedAccountId: activeSelection.host, - activeCodexManagedAccountIdsByRuntime: activeSelection - }) - this.safeSyncCanonicalConfigToManagedHomes() - this.runtimeHome.clearLastWrittenAuthJson(accountId) - this.runtimeHome.syncForCurrentSelection(accountTarget) - - // Why: re-auth can change the underlying Codex identity, so force a fresh read to avoid showing stale quota. - this.startQuotaRefreshInBackground(undefined, accountTarget) - return this.getSnapshot() - } - - private async doRemoveAccount(accountId: string): Promise { - const account = this.requireAccount(accountId) - const settings = this.store.getSettings() - const nextAccounts = settings.codexManagedAccounts.filter((entry) => entry.id !== accountId) - const nextSelection = removeCodexAccountIdFromSelection( - normalizeCodexRuntimeSelection(settings), - accountId - ) - const nextActiveId = - settings.activeCodexManagedAccountId === accountId ? null : nextSelection.host - - this.store.updateSettings({ - codexManagedAccounts: nextAccounts, - activeCodexManagedAccountId: nextActiveId, - activeCodexManagedAccountIdsByRuntime: nextSelection - }) - this.runtimeHome.syncForCurrentSelection() - if (account.managedHomeRuntime === 'host' && nextSelection.host === null) { - this.lifecycle.onHostSystemDefaultSelected?.() - } - - this.safeRemoveManagedHome(account.managedHomePath, account.id) - // Why: a removed account can no longer appear in the switcher dropdown, - // so purge its cached usage to avoid stale entries. - this.rateLimits.evictInactiveCodexCache(accountId) - this.discardResetAttemptsForRemovedAccount(accountId) - this.startQuotaRefreshInBackground( - getSelectedCodexAccountIdForTarget(settings, getCodexSelectionTargetForAccount(account)) === - accountId - ? accountId - : undefined, - getCodexSelectionTargetForAccount(account) - ) - return this.getSnapshot() - } - - private async doSelectAccount( - accountId: string | null, - target?: CodexAccountSelectionTarget - ): Promise { - let effectiveTarget = target - if (accountId !== null) { - const account = this.requireAccount(accountId) - const accountTarget = getCodexSelectionTargetForAccount(account) - const requestedTarget = normalizeCodexAccountSelectionTarget(target ?? accountTarget) - const normalizedAccountTarget = normalizeCodexAccountSelectionTarget(accountTarget) - if ( - requestedTarget.runtime !== normalizedAccountTarget.runtime || - (requestedTarget.wslDistro !== null && - requestedTarget.wslDistro !== normalizedAccountTarget.wslDistro) - ) { - throw new Error('That Codex account belongs to a different runtime.') - } - effectiveTarget = accountTarget - } - - const previousSettings = this.store.getSettings() - const selection = normalizeCodexRuntimeSelection(previousSettings) - const outgoingAccountId = getSelectedCodexAccountIdForTarget(previousSettings, effectiveTarget) - const nextSelection = setSelectedCodexAccountIdForTarget(selection, accountId, effectiveTarget) - - this.store.updateSettings({ - activeCodexManagedAccountId: - effectiveTarget?.runtime === 'wsl' ? nextSelection.host : accountId, - activeCodexManagedAccountIdsByRuntime: nextSelection - }) - this.safeSyncCanonicalConfigToManagedHomes() - this.runtimeHome.syncForCurrentSelection(effectiveTarget) - if ( - accountId === null && - normalizeCodexAccountSelectionTarget(effectiveTarget).runtime === 'host' - ) { - this.lifecycle.onHostSystemDefaultSelected?.() - } - - this.startQuotaRefreshInBackground(outgoingAccountId, effectiveTarget) - return this.getSnapshot() + return this.resetCredits.consumeCurrent() } private getSnapshot(): CodexRateLimitAccountsState { - const settings = this.store.getSettings() - return { - accounts: settings.codexManagedAccounts - .map((account) => this.toSummary(account)) - .sort((a, b) => b.updatedAt - a.updatedAt), - activeAccountId: normalizeCodexRuntimeSelection(settings).host, - activeAccountIdsByRuntime: normalizeCodexRuntimeSelection(settings), - systemDefault: this.resolveSystemDefaultIdentity() - } + return this.selection.snapshot() } - // 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 - - 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, - email: account.email, - managedHomeRuntime: account.managedHomeRuntime ?? 'host', - wslDistro: account.wslDistro ?? null, - providerAccountId: account.providerAccountId ?? null, - workspaceLabel: account.workspaceLabel ?? null, - workspaceAccountId: account.workspaceAccountId ?? null, - createdAt: account.createdAt, - updatedAt: account.updatedAt, - lastAuthenticatedAt: account.lastAuthenticatedAt - } - } - - private requireAccount(accountId: string): CodexManagedAccount { - const settings = this.store.getSettings() - const account = settings.codexManagedAccounts.find((entry) => entry.id === accountId) - if (!account) { - throw new Error('That Codex rate limit account no longer exists.') - } - return account - } - - private normalizeActiveSelection(): void { - const settings = this.store.getSettings() - const selection = normalizeCodexRuntimeSelection(settings) - const nextSelection = pruneInvalidCodexRuntimeSelection( - selection, - settings.codexManagedAccounts - ) - const changed = - nextSelection.host !== selection.host || - JSON.stringify(nextSelection.wsl) !== JSON.stringify(selection.wsl) - if (changed) { - this.store.updateSettings({ - activeCodexManagedAccountId: nextSelection.host, - activeCodexManagedAccountIdsByRuntime: nextSelection - }) - if (selection.host !== null && nextSelection.host === null) { - this.lifecycle.onHostSystemDefaultSelected?.() - } - } - } - - private async createManagedHome( - accountId: string, - target?: CodexAccountAddTarget - ): Promise { - const wslHome = await this.tryCreateWslManagedHome(accountId, target) - if (wslHome) { - return wslHome - } - - const managedHomePath = join(this.getManagedAccountsRoot(), accountId, 'home') - mkdirSync(managedHomePath, { recursive: true }) - // Why: marker lets future cleanup prove the path belongs to Orca before deleting anything. - writeFileSync(join(managedHomePath, '.orca-managed-home'), `${accountId}\n`, 'utf-8') - return { - managedHomePath: this.assertManagedHomePath(managedHomePath, accountId), - managedHomeRuntime: 'host', - wslDistro: null, - wslLinuxHomePath: null - } - } - - private async tryCreateWslManagedHome( - accountId: string, - target?: CodexAccountAddTarget - ): Promise { - if (process.platform !== 'win32' || target?.runtime !== 'wsl') { - return null - } - - const requestedDistro = target.wslDistro?.trim() || undefined - const info = await runWslProcess({ - distro: requestedDistro, - loginPath: 'none', - script: 'printf "%s\\n%s\\n" "$WSL_DISTRO_NAME" "$HOME"', - shell: 'bash', - timeoutMs: WSL_MANAGED_HOME_TIMEOUT_MS - }) - if (info.code !== 0 || info.timedOut) { - throw new Error('Could not resolve the active WSL home directory for Codex login.') - } - const [rawDistro, rawHome] = info.stdout - .replaceAll(String.fromCharCode(0), '') - .split(/\r?\n/) - .map((line) => line.trim()) - const distro = requestedDistro || rawDistro - const home = rawHome - if (!distro || !home?.startsWith('/')) { - throw new Error('Could not resolve the active WSL home directory for Codex login.') - } - - const wslLinuxHomePath = `${home.replace(/\/$/, '')}/.local/share/orca/codex-accounts/${accountId}/home` - const markerPath = `${wslLinuxHomePath}/.orca-managed-home` - const created = await runWslProcess({ - distro, - loginPath: 'none', - script: `mkdir -p ${shellQuote(wslLinuxHomePath)} && printf '%s\\n' ${shellQuote(accountId)} > ${shellQuote(markerPath)}`, - shell: 'bash', - timeoutMs: WSL_MANAGED_HOME_TIMEOUT_MS - }) - if (created.code !== 0 || created.timedOut) { - throw new Error('Could not create the managed Codex home inside WSL.') - } - - const managedHomePath = toWindowsWslPath(wslLinuxHomePath, distro) - let trustedManagedHomePath: string - try { - trustedManagedHomePath = this.assertManagedHomePath(managedHomePath, accountId) - } catch (error) { - await this.safeRemoveWslManagedHomeCandidate(distro, wslLinuxHomePath, accountId) - throw error - } - - return { - managedHomePath: trustedManagedHomePath, - managedHomeRuntime: 'wsl', - wslDistro: distro, - wslLinuxHomePath - } - } - - private safeSyncCanonicalConfigToManagedHomes(): void { - try { - this.syncCanonicalConfigToManagedHomes() - } catch (error) { - console.warn('[codex-accounts] Failed to sync canonical config:', error) - } - } - - private safeSyncCanonicalConfigIntoManagedHome( - managedHomePath: string, - canonicalConfig?: CanonicalCodexConfig | null, - expectedAccountId?: string - ): void { - try { - this.syncCanonicalConfigIntoManagedHome(managedHomePath, canonicalConfig, expectedAccountId) - } catch (error) { - console.warn('[codex-accounts] Failed to seed managed config:', error) - } - } - - private syncCanonicalConfigToManagedHomes(): void { - const settings = this.store.getSettings() - for (const account of settings.codexManagedAccounts) { - try { - this.syncCanonicalConfigIntoManagedHome(account.managedHomePath, undefined, account.id) - } catch (error) { - console.warn('[codex-accounts] Failed to sync managed config:', error) - } - } - } - - private syncCanonicalConfigIntoManagedHome( - managedHomePath: string, - canonicalConfig = this.readCanonicalConfigForManagedHome(managedHomePath), - expectedAccountId?: string - ): void { - if (canonicalConfig === null) { - return - } - - const trustedManagedHomePath = this.assertManagedHomePath(managedHomePath, expectedAccountId) - // Why: every account home is Codex's own CODEX_HOME. Preserve trust Codex - // granted there while refreshing ordinary settings from the lane's source. - syncSystemConfigIntoManagedCodexHome({ - runtimeHomePath: trustedManagedHomePath, - systemHomePath: canonicalConfig.sourceHomePath, - systemConfigDir: canonicalConfig.sourceConfigDir - }) - } - - private readCanonicalConfig(): CanonicalCodexConfig | null { - const sourceHomePath = join(homedir(), '.codex') - const primaryConfigPath = join(sourceHomePath, 'config.toml') - if (!existsSync(primaryConfigPath)) { - return null - } - - try { - return { - contents: readFileSync(primaryConfigPath, 'utf-8'), - sourceHomePath - } - } catch (error) { - console.warn('[codex-accounts] Failed to read canonical config:', error) - return null - } - } - - private readCanonicalConfigForManagedHome(managedHomePath: string): CanonicalCodexConfig | null { - const wslInfo = parseWslUncPath(managedHomePath) - if (!wslInfo) { - return this.readCanonicalConfig() - } - - const managedRootMarker = '/.local/share/orca/codex-accounts/' - const markerIndex = wslInfo.linuxPath.indexOf(managedRootMarker) - if (markerIndex === -1) { - return null - } - const wslHome = wslInfo.linuxPath.slice(0, markerIndex) - const configPath = toWindowsWslPath(`${wslHome}/.codex/config.toml`, wslInfo.distro) - if (!existsSync(configPath)) { - return null - } - - try { - // Why: the config is read over UNC but consumed by Codex inside WSL, so - // path rewrites must anchor to the Linux-side ~/.codex, not the UNC path. - return { - contents: readFileSync(configPath, 'utf-8'), - sourceHomePath: toWindowsWslPath(`${wslHome}/.codex`, wslInfo.distro), - sourceConfigDir: `${wslHome}/.codex` - } - } catch (error) { - console.warn('[codex-accounts] Failed to read WSL canonical config:', error) - return null - } - } - - private assertOAuthAccountAddAllowed(canonicalConfig: CanonicalCodexConfig | null): void { - const modelProvider = canonicalConfig - ? readCodexTopLevelModelProvider(canonicalConfig.contents) - : null - if (!modelProvider || modelProvider === 'openai') { - return - } - - // Why: mirroring a custom-provider pin into an OAuth managed home makes - // the new OAuth credentials inert; fail before login and leave user config intact. - throw new Error( - `Orca cannot add a Codex OAuth account while ~/.codex/config.toml pins the custom provider ${JSON.stringify(modelProvider)}. Keep using the system-default account for this provider, or remove model_provider (or set it to "openai") before adding an OAuth account. Orca left your config unchanged.` - ) - } - - private getManagedAccountsRoot(): string { - const root = join(app.getPath('userData'), 'codex-accounts') - mkdirSync(root, { recursive: true }) - return root - } - - private async ensureManagedHomeForReauthentication( - account: CodexManagedAccount - ): Promise { - const wslInfo = parseWslUncPath(account.managedHomePath) - if (wslInfo && process.platform === 'win32') { - await this.ensureExpectedWslManagedHomeForReauthentication(account, wslInfo) - return this.assertManagedHomePath(account.managedHomePath, account.id) - } - - try { - return this.assertManagedHomePath(account.managedHomePath, account.id) - } catch (error) { - if (!this.isMissingManagedHomeError(error)) { - throw error - } - return this.recreateExpectedHostManagedHomeForReauthentication(account, error) - } - } - - private recreateExpectedHostManagedHomeForReauthentication( - account: CodexManagedAccount, - originalError: unknown - ): string { - const expectedManagedHomePath = join(this.getManagedAccountsRoot(), account.id, 'home') - if (!this.pathsEqual(account.managedHomePath, expectedManagedHomePath)) { - throw originalError - } - - // Why: re-auth may recreate a lost empty home, but only at the exact Orca-owned path persisted for this account. - mkdirSync(expectedManagedHomePath, { recursive: true }) - writeFileSync(join(expectedManagedHomePath, '.orca-managed-home'), `${account.id}\n`, 'utf-8') - return this.assertManagedHomePath(expectedManagedHomePath, account.id) - } - - private async ensureExpectedWslManagedHomeForReauthentication( - account: CodexManagedAccount, - wslInfo: { distro: string; linuxPath: string } - ): Promise { - if ( - account.managedHomeRuntime !== 'wsl' || - account.wslDistro !== wslInfo.distro || - account.wslLinuxHomePath !== wslInfo.linuxPath || - !wslInfo.linuxPath.endsWith(`/.local/share/orca/codex-accounts/${account.id}/home`) - ) { - return - } - - const result = await runWslProcess({ - distro: wslInfo.distro, - loginPath: 'none', - script: [ - 'set -euo pipefail', - `candidate=${shellQuote(wslInfo.linuxPath)}`, - `expected_marker=${shellQuote(account.id)}`, - 'marker="$candidate/.orca-managed-home"', - 'if [ -e "$candidate" ] && [ ! -f "$marker" ]; then exit 41; fi', - 'if [ -f "$marker" ] && [ "$(cat "$marker")" != "$expected_marker" ]; then exit 42; fi', - 'mkdir -p -- "$candidate"', - 'printf "%s\\n" "$expected_marker" > "$marker"' - ].join('\n'), - shell: 'bash', - timeoutMs: WSL_MANAGED_HOME_TIMEOUT_MS - }) - // Why: 41/42 mean the path is not this account's home; re-auth must refuse - // rather than write credentials into someone else's directory. - if (result.code !== 0 || result.timedOut) { - throw new Error( - `Could not prepare the managed Codex home in WSL ${wslInfo.distro} for re-authentication.` - ) - } - } - - private isMissingManagedHomeError(error: unknown): boolean { - return ( - error instanceof Error && - error.message === 'Managed Codex home directory does not exist on disk.' - ) - } - - private pathsEqual(left: string, right: string): boolean { - const resolvedLeft = resolve(left) - const resolvedRight = resolve(right) - if (process.platform === 'win32') { - return resolvedLeft.toLowerCase() === resolvedRight.toLowerCase() - } - return resolvedLeft === resolvedRight - } - - private assertManagedHomePath(candidatePath: string, expectedAccountId?: string): string { - const wslInfo = parseWslUncPath(candidatePath) - if (wslInfo) { - if ( - !wslInfo.linuxPath.includes('/.local/share/orca/codex-accounts/') || - !wslInfo.linuxPath.endsWith('/home') - ) { - throw new Error('Managed WSL Codex home is outside Orca account storage.') - } - if ( - expectedAccountId !== undefined && - !wslInfo.linuxPath.endsWith(`/.local/share/orca/codex-accounts/${expectedAccountId}/home`) - ) { - throw new Error('Managed WSL Codex home does not match its persisted account ID.') - } - - if (process.platform === 'win32') { - try { - const canonicalLinuxPath = execFileSync( - 'wsl.exe', - [ - '-d', - wslInfo.distro, - '--exec', - 'bash', - '-lc', - buildEncodedWslBashCommand( - [ - 'set -euo pipefail', - `candidate=${shellQuote(wslInfo.linuxPath)}`, - 'managed_root="${HOME%/}/.local/share/orca/codex-accounts"', - 'candidate_real=$(readlink -f -- "$candidate")', - 'managed_root_real=$(readlink -f -- "$managed_root")', - 'test -f "$candidate_real/.orca-managed-home"', - ...(expectedAccountId === undefined - ? [ - 'case "$candidate_real" in "$managed_root_real"/*/home) printf "%s\\n" "$candidate_real" ;; *) exit 35 ;; esac' - ] - : [ - `expected_marker=${shellQuote(expectedAccountId)}`, - 'test "$candidate_real" = "$managed_root_real/$expected_marker/home"', - 'test "$(cat "$candidate_real/.orca-managed-home")" = "$expected_marker"', - 'printf "%s\\n" "$candidate_real"' - ]) - ].join('\n') - ) - ], - { windowsHide: true, encoding: 'utf-8', timeout: 5000 } - ).trim() - if (!canonicalLinuxPath) { - throw new Error('Managed Codex home directory does not exist on disk.') - } - return toWindowsWslPath(canonicalLinuxPath, wslInfo.distro) - } catch (error) { - throw new Error('Managed WSL Codex home is outside Orca account storage.', { - cause: error - }) - } - } - - if (wslInfo.linuxPath.split('/').includes('..')) { - throw new Error('Managed WSL Codex home is outside Orca account storage.') - } - if (!existsSync(candidatePath)) { - throw new Error('Managed Codex home directory does not exist on disk.') - } - if (!existsSync(join(candidatePath, '.orca-managed-home'))) { - throw new Error('Managed Codex home is missing Orca ownership marker.') - } - if ( - expectedAccountId !== undefined && - readFileSync(join(candidatePath, '.orca-managed-home'), 'utf-8').trim() !== - expectedAccountId - ) { - throw new Error('Managed WSL Codex home ownership marker does not match its account ID.') - } - return candidatePath - } - - return assertOwnedHostCodexManagedHomePath({ - candidatePath, - managedAccountsRoot: this.getManagedAccountsRoot(), - systemCodexHomePath: getSystemCodexHomePath(), - expectedAccountId - }) - } - - private async safeRemoveWslManagedHomeCandidate( - distro: string, - linuxHomePath: string, - expectedAccountId: string - ): Promise { - // Why: creation can fail after mkdir/marker but before trust, so cleanup must verify the marker/account ID inside WSL. - try { - const result = await runWslProcess({ - distro, - loginPath: 'none', - script: [ - 'set -euo pipefail', - `candidate=${shellQuote(linuxHomePath)}`, - `expected_marker=${shellQuote(expectedAccountId)}`, - 'managed_root="${HOME%/}/.local/share/orca/codex-accounts"', - 'candidate_real=$(readlink -f -- "$candidate" 2>/dev/null || true)', - 'managed_root_real=$(readlink -f -- "$managed_root" 2>/dev/null || true)', - 'test -n "$candidate_real"', - 'test -n "$managed_root_real"', - 'case "$candidate_real" in "$managed_root_real"/*/home) ;; *) exit 0 ;; esac', - 'test -f "$candidate_real/.orca-managed-home"', - 'test "$(cat "$candidate_real/.orca-managed-home")" = "$expected_marker"', - 'rm -rf -- "$candidate_real"', - 'parent_dir=$(dirname -- "$candidate_real")', - 'case "$parent_dir" in "$managed_root_real"/*) rmdir -- "$parent_dir" 2>/dev/null || true ;; esac' - ].join('\n'), - shell: 'bash', - timeoutMs: WSL_MANAGED_HOME_TIMEOUT_MS - }) - if (result.code !== 0 || result.timedOut) { - throw new Error(`WSL cleanup exited with ${result.timedOut ? 'a timeout' : result.code}`) - } - } catch (error) { - console.warn('[codex-accounts] Failed to clean up WSL managed home candidate:', error) - } - } - - /** - * Rollback deletes the managed home, so it may only run on a *proven* failure. - * An unreadable credential file means the login may well have succeeded, and a - * kept home is a recoverable leak where a deleted one is permanent data loss. - */ - private removeManagedHomeUnlessUnproven( - error: unknown, - managedHomePath: string, - accountId: string - ): void { - if (error instanceof ManagedCodexHomeTemporarilyUnavailableError) { - return - } - this.safeRemoveManagedHome(managedHomePath, accountId) - } - - private safeRemoveManagedHome(candidatePath: string, expectedAccountId: string): void { - let managedHomePath: string - try { - managedHomePath = this.assertManagedHomePath(candidatePath, expectedAccountId) - } catch (error) { - console.warn('[codex-accounts] Refusing to remove untrusted managed home:', error) - return - } - - try { - removeManagedHomeTreeSync(managedHomePath) - } catch (error) { - // Why: this runs from error-cleanup paths; a still-held Windows handle - // must not mask the original failure with an ENOTEMPTY from rmSync. - console.warn('[codex-accounts] Failed to remove managed home:', error) - return - } - - if (parseWslUncPath(managedHomePath)) { - try { - removeManagedHomeTreeSync(dirname(managedHomePath)) - } catch { - // Best-effort cleanup - } - return - } - - // Why: homes live at //home; removing the home/ leaf leaves an empty / behind. - try { - const parentDir = resolve(managedHomePath, '..') - // Why: canonicalize the root too so the prefix check works on macOS where userData resolves through /private/var. - const root = realpathSync(this.getManagedAccountsRoot()) - if (parentDir.startsWith(root + sep) && parentDir !== root) { - removeManagedHomeTreeSync(parentDir) - } - } catch { - // Best-effort cleanup - } - } - - private async runCodexLogin(managedHomePath: string): Promise { - const wslInfo = parseWslUncPath(managedHomePath) - if (wslInfo) { - await this.assertWslCodexCliAvailable(wslInfo) - } - // Why: reauthentication starts with an existing auth.json. Only new auth - // bytes prove this login completed; existence alone would kill the - // Windows OAuth flow five seconds after it opened. - const initialAuthSnapshot = wslInfo - ? null - : readLoginAuthSnapshot(join(managedHomePath, 'auth.json')) - - await new Promise((resolvePromise, rejectPromise) => { - const spawnConfig = wslInfo - ? { - command: 'wsl.exe', - args: buildWslCodexLoginArgs(wslInfo.distro, wslInfo.linuxPath), - env: process.env, - codexCommand: 'codex', - interactiveLogin: null - } - : (() => { - const codexCommand = resolveCodexCommand() - // Why: Windows host login needs a real console; otherwise inherit/hide - // leaves the child unable to read a paste-code / device-auth prompt. - const interactiveLogin = - process.platform === 'win32' - ? buildWindowsHostInteractiveLoginSpawn(codexCommand, ['login']) - : null - const { spawnCmd, spawnArgs } = interactiveLogin - ? { spawnCmd: interactiveLogin.command, spawnArgs: interactiveLogin.args } - : getSpawnArgsForWindows(codexCommand, ['login']) - return { - command: spawnCmd, - args: spawnArgs, - env: withCliRuntimeOnPath(codexCommand, { - ...process.env, - CODEX_HOME: managedHomePath - }), - codexCommand, - interactiveLogin - } - })() - const child = spawn(spawnConfig.command, spawnConfig.args, { - stdio: spawnConfig.interactiveLogin - ? spawnConfig.interactiveLogin.stdio - : ['ignore', 'pipe', 'pipe'], - // Why: hide the outer wrapper only. A dedicated login console stays visible. - windowsHide: true, - env: spawnConfig.env - }) - - let settled = false - let output = '' - const appendOutput = (chunk: Buffer): void => { - output = `${output}${chunk.toString()}` - if (output.length > MAX_LOGIN_OUTPUT_CHARS) { - output = output.slice(-MAX_LOGIN_OUTPUT_CHARS) - } - } - - let timeout: ReturnType | null = null - let authWatchInterval: ReturnType | null = null - let postAuthExitTimeout: ReturnType | null = null - let loginTreeKilledAfterAuth = false - const authJsonPath = join(managedHomePath, 'auth.json') - const cleanupListeners = (): void => { - if (timeout) { - clearTimeout(timeout) - timeout = null - } - if (authWatchInterval) { - clearInterval(authWatchInterval) - authWatchInterval = null - } - if (postAuthExitTimeout) { - clearTimeout(postAuthExitTimeout) - postAuthExitTimeout = null - } - child.stdout?.off('data', appendOutput) - child.stderr?.off('data', appendOutput) - child.off('error', onError) - child.off('close', onClose) - spawnConfig.interactiveLogin?.cleanup?.() - } - - const settle = (callback: () => void): void => { - if (settled) { - return - } - settled = true - cleanupListeners() - callback() - } - - const timeoutError = new Error('Codex sign-in took too long to finish. Please try again.') - timeout = setTimeout(() => { - killLoginProcessTree(child, spawnConfig.interactiveLogin) - settle(() => { - rejectPromise(timeoutError) - }) - }, LOGIN_TIMEOUT_MS) - - // Why: on Windows the codex login CLI can linger after writing auth.json, - // and its open handles on the managed home (log/codex-login.log) make the - // post-login file operations fail with ENOTEMPTY. Once auth.json exists, - // give the tree a short grace period to exit, then force it down. - if (process.platform === 'win32' && !wslInfo) { - authWatchInterval = setInterval(() => { - if (!loginAuthChanged(initialAuthSnapshot, readLoginAuthSnapshot(authJsonPath))) { - return - } - if (authWatchInterval) { - clearInterval(authWatchInterval) - authWatchInterval = null - } - postAuthExitTimeout = setTimeout(() => { - loginTreeKilledAfterAuth = true - killLoginProcessTree(child, spawnConfig.interactiveLogin) - }, WINDOWS_LOGIN_POST_AUTH_EXIT_GRACE_MS) - }, WINDOWS_LOGIN_AUTH_POLL_INTERVAL_MS) - } - - const onError = (error: Error): void => { - settle(() => { - const isEnoent = (error as NodeJS.ErrnoException).code === 'ENOENT' - // Why: ENOENT is ambiguous — missing codex binary or missing node in PATH; a resolved full path implies node is missing. - const isBareCommand = spawnConfig.codexCommand === 'codex' - const message = isEnoent - ? isBareCommand - ? 'Codex CLI not found.' - : 'Codex CLI found but could not run — Node.js may not be in your PATH.' - : error.message - rejectPromise(new Error(message)) - }) - } - - const onClose = (code: number | null): void => { - settle(() => { - // Why: the post-auth tree kill is a success path — auth.json already - // exists and codex only failed to exit on its own, so the forced - // non-zero exit must not surface as a login failure. - // Why: the kill only arms after the watcher observed new credential - // bytes, so an unreadable auth.json here is a lock, not a failed login. - // Only a definitive absence may revoke that verdict — reading a lock as - // failure sends the caller's rollback at a home that just authenticated. - if ( - code === 0 || - (loginTreeKilledAfterAuth && readLoginAuthSnapshot(authJsonPath) !== null) - ) { - resolvePromise() - return - } - const trimmedOutput = output.trim() - rejectPromise( - new Error( - trimmedOutput - ? `Codex login failed: ${trimmedOutput}` - : `Codex login exited with code ${code ?? 'unknown'}.` - ) - ) - }) - } - - child.stdout?.on('data', appendOutput) - child.stderr?.on('data', appendOutput) - child.on('error', onError) - child.on('close', onClose) - }) - } - - private async assertWslCodexCliAvailable(wslInfo: { - distro: string - linuxPath: string - }): Promise { - // This is a PATH lookup, so it needs the login PATH: an nvm-installed codex - // lives nowhere else. Marking it 'none' reports a working install as absent. - const result = await runWslProcess({ - distro: wslInfo.distro, - loginPath: 'preferred', - script: buildWslCodexAvailabilityScript(), - // POSIX command lookup; declared because the payload is opaque here. - shell: 'sh', - timeoutMs: WSL_CODEX_AVAILABILITY_TIMEOUT_MS - }) - if (result.code !== 0 && !result.environmentResolved) { - // A miss without the login PATH is "we could not check", not "not - // installed" -- claiming absence here is #9725. - throw new Error('Could not check the Codex CLI in WSL. Try again.') - } - if (result.code !== 0 || result.timedOut) { - throw new Error( - `Codex CLI is not available in WSL ${wslInfo.distro}. Install Codex in that distro or switch Account location to Windows.`, - { cause: new Error(result.stderr.trim() || `codex lookup exited with ${result.code}`) } - ) - } + return this.identity.resolveSystemDefault() } private readIdentityFromHome( managedHomePath: string, expectedAccountId: string ): ResolvedCodexIdentity { - return this.resolveIdentityFromCredentials( - this.loadOAuthCredentials(managedHomePath, expectedAccountId) - ) + return this.identity.readFromHome(managedHomePath, expectedAccountId) } - 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') - - return { - email: this.normalizeField( - this.readStringClaim(payload, 'email') ?? this.readStringClaim(profileClaims, 'email') - ), - providerAccountId: this.normalizeField( - credentials.accountId ?? - this.readStringClaim(authClaims, 'chatgpt_account_id') ?? - this.readStringClaim(payload, 'chatgpt_account_id') - ), - workspaceLabel: this.normalizeField( - this.readStringClaim(authClaims, 'workspace_name') ?? - this.readStringClaim(profileClaims, 'workspace_name') - ), - workspaceAccountId: this.normalizeField( - this.readStringClaim(authClaims, 'workspace_account_id') ?? - credentials.accountId ?? - this.readStringClaim(payload, 'chatgpt_account_id') - ) - } + private toSummary(account: CodexManagedAccount): CodexManagedAccountSummary { + return toCodexManagedAccountSummary(account) } - private loadOAuthCredentials( - managedHomePath: string, - expectedAccountId: string - ): CodexOAuthCredentials { - const authFilePath = join( - this.assertManagedHomePath(managedHomePath, expectedAccountId), - 'auth.json' - ) - let authFileContents: string - try { - authFileContents = readFileSync(authFilePath, 'utf-8') - } catch (error) { - // Why: an unreadable auth.json is not a missing credential. Surfacing it as - // a generic failure is what lets the add path's rollback delete a home - // holding freshly authenticated bytes. - if (isDefinitiveAbsence(error)) { - throw error - } - throw new ManagedCodexHomeTemporarilyUnavailableError(undefined, { cause: error }) - } - let parsed: Record - try { - parsed = JSON.parse(authFileContents) as Record - } catch { - // Why: a raw SyntaxError echoes credential bytes into logs/error UI; a - // corrupt auth.json must fail loudly but without them (same sanitization - // intent as the system-default identity path, which degrades instead). - throw new Error('Codex auth.json is corrupt or not valid JSON') - } - return this.extractOAuthCredentials(parsed) + private safeRemoveManagedHome(candidatePath: string, expectedAccountId: string): void { + this.managedHomes.safeRemove(candidatePath, expectedAccountId) } - 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 - // on missing nested token fields. - if (typeof raw.OPENAI_API_KEY === 'string' && raw.OPENAI_API_KEY.trim() !== '') { - return { - idToken: null, - accountId: null - } - } - - const tokens = this.readRecordClaim(raw, 'tokens') - return { - idToken: this.normalizeField( - this.readStringClaim(tokens, 'id_token') ?? this.readStringClaim(tokens, 'idToken') - ), - accountId: this.normalizeField( - this.readStringClaim(tokens, 'account_id') ?? this.readStringClaim(tokens, 'accountId') - ) - } - } - - private parseJwtPayload(token: string): Record | null { - const parts = token.split('.') - if (parts.length < 2) { - return null - } - - let payload = parts[1].replace(/-/g, '+').replace(/_/g, '/') - while (payload.length % 4 !== 0) { - payload += '=' - } - - try { - const json = Buffer.from(payload, 'base64').toString('utf-8') - return JSON.parse(json) as Record - } catch { - return null - } - } - - private readRecordClaim( - value: Record | null, - key: string - ): Record | null { - const claim = value?.[key] - if (!claim || typeof claim !== 'object' || Array.isArray(claim)) { - return null - } - return claim as Record - } - - private readStringClaim(value: Record | null, key: string): string | null { - const claim = value?.[key] - return typeof claim === 'string' ? claim : null - } - - private normalizeField(value: string | null | undefined): string | null { - if (!value) { - return null - } - const trimmed = value.trim() - return trimmed === '' ? null : trimmed + private async runCodexLogin(managedHomePath: string): Promise { + await runCodexLoginSession(managedHomePath, { + wslCommand: 'wsl.exe', + spawn: ({ command, args, env, stdio }) => + spawn(command, args, { + stdio, + // Why: hide the outer wrapper only. A dedicated login console stays visible. + windowsHide: true, + env + }), + killProcessTree: killLoginProcessTree + }) } }