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
This commit is contained in:
Neil
2026-08-29 20:24:24 -07:00
committed by GitHub
parent d140e561a6
commit 42857f73bb
15 changed files with 2290 additions and 1880 deletions
-1
View File
@@ -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
@@ -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, unknown>): string {
const payload = Buffer.from(JSON.stringify(claims), 'utf-8').toString('base64url')
return `header.${payload}.signature`
}
function homeWithAuthJson(auth: Record<string, unknown>): 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'
})
})
})
@@ -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<string, unknown>).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() !== ''
}
}
@@ -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<void>
}
export class CodexAccountRegistration {
constructor(private readonly dependencies: CodexAccountRegistrationDependencies) {}
async add(target?: CodexAccountAddTarget): Promise<CodexRateLimitAccountsState> {
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<CodexRateLimitAccountsState> {
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<CodexRateLimitAccountsState> {
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<CodexRateLimitAccountsState> {
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 1025s
// (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)
})
}
}
@@ -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<CodexRateLimitAccountsState> {
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<CodexRateLimitAccountsState> {
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)
})
}
}
@@ -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
}
}
+23 -15
View File
@@ -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<CodexAuthIdentity, 'workspaceLabel'>
// 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) ||
@@ -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/<drive>. */
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
}
}
}
@@ -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<void> {
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<void>((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<typeof setTimeout> | null = null
let authWatchInterval: ReturnType<typeof setInterval> | null = null
let postAuthExitTimeout: ReturnType<typeof setTimeout> | 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<void> {
// 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}`) }
)
}
}
@@ -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<ManagedCodexHomeLocation> {
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 <accounts-root>/<uuid>/home; removing the home/ leaf leaves an empty <uuid>/ 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<ManagedCodexHomeLocation | null> {
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<void> {
// 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)
}
}
}
@@ -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<string> {
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<void> {
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
}
}
@@ -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: <T>(operation: () => Promise<T>) => Promise<T>
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<CodexResetCreditConsumeResult> {
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<CodexResetCreditConsumeResult>
}
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<CodexRateLimitResetResult> {
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<CodexResetCreditConsumeResult> {
const promise = this.dependencies.serializeMutation(
async (): Promise<CodexResetCreditConsumeResult> => {
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)
}
}
@@ -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<unknown> | 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<CodexResetCreditExpectedScope, 'target' | 'accountId' | 'accountRevision'>
): string {
return JSON.stringify([
scope.target.runtime,
scope.target.wslDistro,
scope.accountId,
scope.accountRevision
])
}
export class CodexResetCreditLedger {
private readonly attemptsByKey = new Map<string, CodexResetCreditAttempt>()
private readonly attemptKeyByOffer = new Map<string, string>()
private readonly unresolvedKeyByAccountScope = new Map<string, string>()
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)
}
}
@@ -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 }
}
File diff suppressed because it is too large Load Diff