fix(codex): stop a transient filesystem error from logging out the active account (#15046)

* fix(codex): stop a transient filesystem error from logging out the active account

A single unreadable read of a managed Codex home's ownership marker cleared the
user's active account selection, permanently. On Windows any exclusive lock —
Defender real-time scanning, a backup agent, a sync client — makes every read of
that marker fail with EBUSY, and the background rate-limit poll runs every 15
minutes plus once at every app start.

Root cause: the ownership gate answered two very different questions through one
channel. "This home is not ours" (a successful observation that failed a trust
check) and "we could not read it" both surfaced as a throw, which the caller
flattened to null, which three call sites took as proof the home was
untrustworthy and wrote activeCodexManagedAccountId: null.

Refusing to USE an unverified home is correct. Erasing the user's account
selection because a file was briefly locked is not.

The gate now returns a tri-state verdict. `untrusted` comes only from a proven
trust failure or a definitive ENOENT/ENOTDIR where absence is itself the
verdict; every other filesystem exception is `indeterminate`. Only `untrusted`
may touch persisted state.

Because `null` already meant "fall through to the system default" on both the
launch and poll paths, not-clearing on its own would have run a DIFFERENT
account behind a UI still showing the selected one. So the refusal needed real
channels rather than a sentinel:

- the poll returns an explicit skip; returning null would not have skipped at
  all, since the fetcher maps null to ~/.codex and would have spawned a
  token-refreshing app-server inside the user's real credential home
- pane launch throws a typed temporary-unavailability error that both PTY
  implementations convert into a clean refusal with a retry message, including
  the re-resolution after the async auth-readiness wait
- automatic session resume resolves the selected home eagerly, so an unreadable
  account can no longer be silently replaced by another one in the ranking
- config-sync status reports a distinct managed-home-unavailable stall instead
  of "synced", with a bounded renderer retry so it clears on its own

Also fixes the ticket's second symptom. The status bar's Sign in button called a
re-auth that captured the selection before login and restored it after, so
re-authenticating a deselected account restored `null` — a successful login that
left the account inactive, with no success toast to distinguish it from failure.
It now activates the account it just signed in, but only when the pre-login
selection was empty, so it cannot silently switch accounts for multi-account
users, and it runs the same restart prompt an explicit switch does.

No retry or grace window inside the synchronous gate: it runs on the Electron
main process in a loop over accounts, so a sleep there would freeze the UI.
Recovery is simply the next readable evaluation.

The WSL lane has the same class of defect, including one path that deletes a
credential mirror. It is pre-existing, unreachable from these host code paths,
and deliberately left for its own change; the host clearing sites cannot reach a
WSL account because getSelfContainedManagedHostAccount excludes them.

Fixes STA-4422

* test(codex): cover pending reset home ownership
This commit is contained in:
Brennan Benson
2026-08-17 02:19:57 -07:00
committed by GitHub
parent b0e27354b5
commit 7ae6aedc02
40 changed files with 2637 additions and 282 deletions
@@ -1,4 +1,4 @@
import { existsSync, lstatSync, readFileSync, realpathSync } from 'node:fs'
import { lstatSync, readFileSync, realpathSync, statSync } from 'node:fs'
import { isAbsolute, join, relative, resolve, sep } from 'node:path'
type HostCodexManagedHomeOwnershipOptions = {
@@ -8,6 +8,43 @@ type HostCodexManagedHomeOwnershipOptions = {
expectedAccountId?: string
}
export const MISSING_MANAGED_HOME_MESSAGE = 'Managed Codex home directory does not exist on disk.'
/**
* Why: the gate answers two different questions and callers act on them very
* differently. `untrusted` is a *successful observation* that failed a trust
* check (or a definitive absence where absence is itself the verdict), and only
* it may clear the user's persisted account selection. `indeterminate` means we
* could not read the home at all — the home may be perfectly valid, so callers
* must refuse to *use* it without erasing durable state (#STA-4422: one EBUSY
* from an antivirus lock used to log the user out permanently).
*/
export type HostCodexManagedHomeVerdict =
| { kind: 'owned'; homePath: string }
| { kind: 'untrusted'; reason: string }
| { kind: 'indeterminate'; error: unknown }
/** Thrown for a proven trust failure; safe to clear selection on. */
export class UntrustedManagedCodexHomeError extends Error {}
/**
* Thrown when the home could not be read. Callers must refuse the operation and
* leave persisted selection, credentials, and managed directories untouched.
*/
export class ManagedCodexHomeTemporarilyUnavailableError extends Error {
constructor(
message = 'Codex account files are temporarily locked. Retry in a moment.',
options?: { cause?: unknown }
) {
super(message, options)
}
}
function isDefinitiveAbsence(error: unknown): boolean {
const code = (error as NodeJS.ErrnoException | null)?.code
return code === 'ENOENT' || code === 'ENOTDIR'
}
function pathsEqual(left: string, right: string): boolean {
const resolvedLeft = resolve(left)
const resolvedRight = resolve(right)
@@ -24,73 +61,148 @@ function pathIsInsideOrEqual(rootPath: string, candidatePath: string): boolean {
)
}
/**
* Why: the system Codex home is optional — a user with no ~/.codex is normal,
* and its absence says nothing about the managed candidate. Only a definitive
* absence may fall back to the resolved spelling; any other read error is
* inconclusive and must reach the caller as `indeterminate`.
*/
function canonicalizeIfPresent(candidatePath: string): string {
const resolvedPath = resolve(candidatePath)
return existsSync(resolvedPath) ? realpathSync(resolvedPath) : resolvedPath
try {
return realpathSync(resolvedPath)
} catch (error) {
if (isDefinitiveAbsence(error)) {
return resolvedPath
}
throw error
}
}
/**
* Proves a host managed home is an Orca-owned account directory and cannot
* resolve into the user's real CODEX_HOME before callers write launch state.
*/
export function assertOwnedHostCodexManagedHomePath({
function evaluate({
candidatePath,
managedAccountsRoot,
systemCodexHomePath,
expectedAccountId
}: HostCodexManagedHomeOwnershipOptions): string {
}: HostCodexManagedHomeOwnershipOptions): HostCodexManagedHomeVerdict {
const resolvedCandidate = resolve(candidatePath)
const resolvedRoot = resolve(managedAccountsRoot)
if (!existsSync(resolvedCandidate)) {
throw new Error('Managed Codex home directory does not exist on disk.')
// Why: absence of the candidate IS the structural verdict here, but only a
// definitive one. `existsSync` used to fold EPERM into "does not exist", which
// is what made an antivirus lock look like a deleted account.
let canonicalCandidate: string
try {
statSync(resolvedCandidate)
canonicalCandidate = realpathSync(resolvedCandidate)
} catch (error) {
if (isDefinitiveAbsence(error)) {
return { kind: 'untrusted', reason: MISSING_MANAGED_HOME_MESSAGE }
}
return { kind: 'indeterminate', error }
}
// Why: macOS can spell one path as /var and /private/var, while a replaced
// path component must still be caught as a canonical containment escape.
const canonicalCandidate = realpathSync(resolvedCandidate)
const canonicalRoot = realpathSync(resolvedRoot)
const canonicalSystemHome = canonicalizeIfPresent(systemCodexHomePath)
let canonicalRoot: string
let canonicalSystemHome: string
try {
canonicalRoot = realpathSync(resolvedRoot)
canonicalSystemHome = canonicalizeIfPresent(systemCodexHomePath)
} catch (error) {
return { kind: 'indeterminate', error }
}
if (expectedAccountId !== undefined) {
const candidateUsesManagedRootSpelling =
pathIsInsideOrEqual(resolvedRoot, resolvedCandidate) ||
pathIsInsideOrEqual(canonicalRoot, resolvedCandidate)
const canonicalExpectedHome = canonicalizeIfPresent(
join(canonicalRoot, expectedAccountId, 'home')
)
let canonicalExpectedHome: string
try {
canonicalExpectedHome = canonicalizeIfPresent(join(canonicalRoot, expectedAccountId, 'home'))
} catch (error) {
return { kind: 'indeterminate', error }
}
if (
!candidateUsesManagedRootSpelling ||
!pathsEqual(canonicalCandidate, canonicalExpectedHome)
) {
throw new Error('Managed Codex home does not match its persisted account ID.')
return {
kind: 'untrusted',
reason: 'Managed Codex home does not match its persisted account ID.'
}
}
}
// Why: a replaced codex-accounts directory could otherwise redirect config,
// hook, or resource writes into the user's real ~/.codex tree.
if (pathIsInsideOrEqual(canonicalSystemHome, canonicalCandidate)) {
throw new Error('Managed Codex home resolves inside the system Codex home.')
return {
kind: 'untrusted',
reason: 'Managed Codex home resolves inside the system Codex home.'
}
}
if (
!pathIsInsideOrEqual(canonicalRoot, canonicalCandidate) ||
canonicalRoot === canonicalCandidate
) {
throw new Error(
`Managed Codex home is outside current storage root (expected under ${canonicalRoot}).`
)
return {
kind: 'untrusted',
reason: `Managed Codex home is outside current storage root (expected under ${canonicalRoot}).`
}
}
const markerPath = join(canonicalCandidate, '.orca-managed-home')
let markerIsRegularFile: boolean
let markerContents: string
try {
markerIsRegularFile = lstatSync(markerPath).isFile()
markerContents = markerIsRegularFile ? readFileSync(markerPath, 'utf-8') : ''
} catch (error) {
throw new Error('Managed Codex home is missing Orca ownership marker.', { cause: error })
// Why: the marker is required, so its definitive absence is structural — but
// an unreadable marker is not evidence of anything.
if (isDefinitiveAbsence(error)) {
return { kind: 'untrusted', reason: 'Managed Codex home is missing Orca ownership marker.' }
}
return { kind: 'indeterminate', error }
}
if (!markerIsRegularFile) {
throw new Error('Managed Codex home ownership marker is not a regular file.')
return {
kind: 'untrusted',
reason: 'Managed Codex home ownership marker is not a regular file.'
}
}
const markerContents = readFileSync(markerPath, 'utf-8')
if (expectedAccountId !== undefined && markerContents.trim() !== expectedAccountId) {
throw new Error('Managed Codex home ownership marker does not match its account ID.')
return {
kind: 'untrusted',
reason: 'Managed Codex home ownership marker does not match its account ID.'
}
}
return canonicalCandidate
return { kind: 'owned', homePath: canonicalCandidate }
}
/** Non-throwing verdict; callers decide how to treat each kind. */
export function resolveHostCodexManagedHomeVerdict(
options: HostCodexManagedHomeOwnershipOptions
): HostCodexManagedHomeVerdict {
return evaluate(options)
}
/**
* Throwing wrapper for write paths (add, re-auth, migration) that must never
* proceed on an unproven home. Distinct error classes keep an unreadable home
* from being mistaken for a deleted one — `isMissingManagedHomeError` must not
* match the temporary case, or re-auth would recreate the home and rewrite its
* ownership marker after a transient stat failure.
*/
export function assertOwnedHostCodexManagedHomePath(
options: HostCodexManagedHomeOwnershipOptions
): string {
const verdict = evaluate(options)
if (verdict.kind === 'owned') {
return verdict.homePath
}
if (verdict.kind === 'untrusted') {
throw new UntrustedManagedCodexHomeError(verdict.reason)
}
throw new ManagedCodexHomeTemporarilyUnavailableError(undefined, { cause: verdict.error })
}
@@ -101,7 +101,10 @@ describe('CodexRuntimeHomeService', () => {
expect(warnSpy).not.toHaveBeenCalled()
expect(service.isHostSystemDefaultRealHome()).toBe(false)
expect(service.prepareForRateLimitFetch()).toBe(managedHomePath1)
expect(service.prepareForRateLimitFetch()).toEqual({
kind: 'ready',
codexHomePath: managedHomePath1
})
expect(service.prepareForCodexLaunch()).toBe(managedHomePath1)
expect(existsSync(join(managedHomePath1, 'auth.json'))).toBe(false)
@@ -76,7 +76,7 @@ describe('CodexRuntimeHomeService.getMirroredHostHomePathForStatus', () => {
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
const service = new CodexRuntimeHomeService(createStore([], null) as never)
expect(service.getMirroredHostHomePathForStatus()).toBeNull()
expect(service.getMirroredHostHomePathForStatus()).toEqual({ kind: 'ready', homePath: null })
})
it('returns the selected account own home, which is what its mirror targets', async () => {
@@ -84,7 +84,10 @@ describe('CodexRuntimeHomeService.getMirroredHostHomePathForStatus', () => {
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
const service = new CodexRuntimeHomeService(createStore([account], account.id) as never)
expect(service.getMirroredHostHomePathForStatus()).toBe(account.managedHomePath)
expect(service.getMirroredHostHomePathForStatus()).toEqual({
kind: 'ready',
homePath: account.managedHomePath
})
})
it('returns the shared runtime home when a custom CODEX_HOME keeps the mirror lane', async () => {
@@ -95,6 +98,9 @@ describe('CodexRuntimeHomeService.getMirroredHostHomePathForStatus', () => {
// Why: compare against the real helper, not a repeated literal, so the
// status cannot silently drift if the managed home layout ever moves.
expect(service.getMirroredHostHomePathForStatus()).toBe(getOrcaManagedCodexHomePath())
expect(service.getMirroredHostHomePathForStatus()).toEqual({
kind: 'ready',
homePath: getOrcaManagedCodexHomePath()
})
})
})
@@ -221,7 +221,7 @@ describe('CodexRuntimeHomeService', () => {
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
const service = new CodexRuntimeHomeService(store as never)
expect(service.prepareForRateLimitFetch()).toBe(home1)
expect(service.prepareForRateLimitFetch()).toEqual({ kind: 'ready', codexHomePath: home1 })
})
it('preserves a managed selection whose auth.json is temporarily missing', async () => {
@@ -253,7 +253,7 @@ describe('CodexRuntimeHomeService', () => {
const service = new CodexRuntimeHomeService(store as never)
expect(service.prepareForCodexLaunch()).toBe(brokenHome)
expect(service.prepareForRateLimitFetch()).toBe(brokenHome)
expect(service.prepareForRateLimitFetch()).toEqual({ kind: 'ready', codexHomePath: brokenHome })
expect(store.getSettings().activeCodexManagedAccountId).toBe('account-1')
})
@@ -435,12 +435,18 @@ describe('CodexRuntimeHomeService', () => {
settings.activeCodexManagedAccountIdsByRuntime = { host: null, wsl: {} }
const syncSpy = vi.spyOn(service, 'syncForCurrentSelection')
expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath())
expect(service.prepareForRateLimitFetch()).toEqual({
kind: 'ready',
codexHomePath: getSystemCodexHomePath()
})
expect(readFileSync(join(managedHomePath, 'auth.json'), 'utf-8')).toBe(managedAuth)
expect(readFileSync(getSystemCodexAuthPath(), 'utf-8')).toBe(systemAuth)
expect(syncSpy).not.toHaveBeenCalled()
expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath())
expect(service.prepareForRateLimitFetch()).toEqual({
kind: 'ready',
codexHomePath: getSystemCodexHomePath()
})
expect(service.prepareForCodexLaunch()).toBeNull()
expect(syncSpy).not.toHaveBeenCalled()
})
@@ -109,7 +109,10 @@ describe('CodexRuntimeHomeService', () => {
expect(service.beginHostSystemDefaultSessionMigrationLaunch(getRuntimeCodexHomePath())).toBe(
true
)
expect(service.prepareForRateLimitFetch()).toBe(getRuntimeCodexHomePath())
expect(service.prepareForRateLimitFetch()).toEqual({
kind: 'ready',
codexHomePath: getRuntimeCodexHomePath()
})
expect(service.getHostCodexHomePathsForSessionDiscovery()).toEqual([getRuntimeCodexHomePath()])
expect(existsSync(getRuntimeCodexHomePath())).toBe(true)
})
@@ -176,13 +179,19 @@ describe('CodexRuntimeHomeService', () => {
try {
// Background fetchers prefer ambient CODEX_HOME when passed null, so an
// explicit path proves nested Orca launches cannot poll the managed home.
expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath())
expect(service.prepareForRateLimitFetch()).toEqual({
kind: 'ready',
codexHomePath: getSystemCodexHomePath()
})
process.env.CODEX_HOME = getSystemCodexHomePath()
delete process.env.ORCA_CODEX_HOME
expect(service.isHostSystemDefaultRealHome()).toBe(true)
process.env.CODEX_HOME = join(testState.fakeHomeDir, 'user-owned-codex-home')
expect(service.isHostSystemDefaultRealHome()).toBe(false)
expect(service.prepareForRateLimitFetch()).toBe(getRuntimeCodexHomePath())
expect(service.prepareForRateLimitFetch()).toEqual({
kind: 'ready',
codexHomePath: getRuntimeCodexHomePath()
})
} finally {
if (previousCodexHome === undefined) {
delete process.env.CODEX_HOME
@@ -242,7 +251,10 @@ describe('CodexRuntimeHomeService', () => {
service.reconcileLegacySharedHomeForRetainedPanes()
expect(service.prepareForCodexLaunch()).toBeNull()
expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath())
expect(service.prepareForRateLimitFetch()).toEqual({
kind: 'ready',
codexHomePath: getSystemCodexHomePath()
})
expect(syncLegacySharedCodexConfigForRetainedPanes).not.toHaveBeenCalled()
expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(retainedAuth)
@@ -0,0 +1,176 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { realpathSync, writeFileSync } from 'node:fs'
import type * as NodeFs from 'node:fs'
import { join } from 'node:path'
import type { CodexRuntimeHomeService } from './runtime-home-service'
import { createSettings } from './runtime-home-settings-test-fixtures'
import {
createCodexAccountRecord,
createCodexAuthJson,
createManagedAuth,
createStore,
getSystemCodexAuthPath,
setupRuntimeHomeTest,
teardownRuntimeHomeTest,
testState
} from './runtime-home-service-test-harness'
// STA-4422 P1g: automatic session resume picks the resumed pane's CODEX_HOME,
// i.e. its account. The read-only resolver collapses an unreadable home to
// `null`, which the ranking reads as "no account selected" — another account's
// readable alias then wins and the pane resumes under the wrong credentials.
// The resume boundary therefore resolves the selection eagerly through a
// refusing gate.
const lstatFaults = vi.hoisted(() => {
const state = {
/** paths that fail on EVERY read until released (models a held AV lock) */
held: new Set<string>(),
reads: new Map<string, number>(),
hold(path: string): void {
state.held.add(path)
},
release(path: string): void {
state.held.delete(path)
},
heldReads(path: string): number {
return state.reads.get(path) ?? 0
},
reset(): void {
state.held.clear()
state.reads.clear()
},
consume(target: unknown): void {
if (typeof target !== 'string' || !state.held.has(target)) {
return
}
state.reads.set(target, (state.reads.get(target) ?? 0) + 1)
const error: NodeJS.ErrnoException = new Error(
`EPERM: operation not permitted, lstat '${target}'`
)
error.code = 'EPERM'
error.errno = -4048
error.syscall = 'lstat'
error.path = target
throw error
}
}
return state
})
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof NodeFs>()
const original = actual.lstatSync as (...args: unknown[]) => unknown
const patched: Record<string, unknown> = {
...actual,
lstatSync: Object.assign((...args: unknown[]): unknown => {
lstatFaults.consume(args[0])
return original(...args)
}, original)
}
return { ...patched, default: patched }
})
vi.mock('electron', () => ({
app: {
getPath: () => testState.userDataDir
}
}))
vi.mock('node:os', async () => {
const actual = await vi.importActual<typeof import('node:os')>('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import()
return {
...actual,
homedir: () => testState.fakeHomeDir
}
})
async function createServiceWithSelectedAccount(): Promise<{
service: CodexRuntimeHomeService
store: ReturnType<typeof createStore>
managedHomePath: string
}> {
writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8')
const managedHomePath = createManagedAuth(
testState.userDataDir,
'account-a',
createCodexAuthJson('a@example.com', 'acct-a', 'refresh-a')
)
createManagedAuth(
testState.userDataDir,
'account-b',
createCodexAuthJson('b@example.com', 'acct-b', 'refresh-b')
)
const store = createStore(
createSettings({
shellStartupEnvProbeSupported: true,
codexManagedAccounts: [
createCodexAccountRecord('account-a', 'a@example.com', 'acct-a', managedHomePath),
createCodexAccountRecord(
'account-b',
'b@example.com',
'acct-b',
join(testState.userDataDir, 'codex-accounts', 'account-b', 'home')
)
],
activeCodexManagedAccountId: 'account-a',
activeCodexManagedAccountIdsByRuntime: { host: 'account-a', wsl: {} }
})
)
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
return { service: new CodexRuntimeHomeService(store as never), store, managedHomePath }
}
describe('CodexRuntimeHomeService.resolveSelectedHostAccountCodexHomePathForResume', () => {
beforeEach(() => {
lstatFaults.reset()
setupRuntimeHomeTest()
})
afterEach(() => {
lstatFaults.reset()
teardownRuntimeHomeTest()
})
it('refuses the resume while the selected account marker is locked, instead of reporting no selection', async () => {
const { service, store, managedHomePath } = await createServiceWithSelectedAccount()
const { ManagedCodexHomeTemporarilyUnavailableError } =
await import('./host-codex-managed-home-ownership')
// Anchor: a readable marker resolves the selected account's own home.
expect(service.resolveSelectedHostAccountCodexHomePathForResume()).toBe(managedHomePath)
const markerPath = join(realpathSync(managedHomePath), '.orca-managed-home')
lstatFaults.hold(markerPath)
expect(() => service.resolveSelectedHostAccountCodexHomePathForResume()).toThrow(
ManagedCodexHomeTemporarilyUnavailableError
)
// The injected fault really was consumed by the gate under test.
expect(lstatFaults.heldReads(markerPath)).toBeGreaterThan(0)
// Why this is the whole point: the read-only resolver still answers `null`,
// which the resume ranking cannot distinguish from "system default", so the
// resume boundary must not use it.
expect(service.getSelectedHostAccountCodexHomePath()).toBeNull()
// Refusing is not clearing.
expect(store.getSettings().activeCodexManagedAccountId).toBe('account-a')
expect(store.updateSettings).not.toHaveBeenCalled()
// Recovery is automatic once the lock clears.
lstatFaults.release(markerPath)
expect(service.resolveSelectedHostAccountCodexHomePathForResume()).toBe(managedHomePath)
})
it('reports no managed selection for a proven untrusted home rather than refusing', async () => {
const { service, store, managedHomePath } = await createServiceWithSelectedAccount()
// A successful observation that fails a trust check: the marker names
// another account. The resume must proceed on the system/default ranking.
writeFileSync(join(managedHomePath, '.orca-managed-home'), 'someone-else\n', 'utf-8')
expect(service.resolveSelectedHostAccountCodexHomePathForResume()).toBeNull()
expect(store.getSettings().activeCodexManagedAccountId).toBeNull()
expect(store.getSettings().activeCodexManagedAccountIdsByRuntime?.host).toBeNull()
expect(store.updateSettings).toHaveBeenCalledOnce()
})
})
@@ -178,7 +178,10 @@ describe('CodexRuntimeHomeService', () => {
setShellStartupEnvProbeSupportedForTest(true)
writeFileSync(getSystemCodexAuthPath(), refreshedSystemAuth, 'utf-8')
expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath())
expect(service.prepareForRateLimitFetch()).toEqual({
kind: 'ready',
codexHomePath: getSystemCodexHomePath()
})
expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(refreshedSystemAuth)
})
@@ -266,9 +269,15 @@ describe('CodexRuntimeHomeService', () => {
service.syncForCurrentSelection()
writeFileSync(getSystemCodexAuthPath(), refreshedSystemAuth, 'utf-8')
expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath())
expect(service.prepareForRateLimitFetch()).toEqual({
kind: 'ready',
codexHomePath: getSystemCodexHomePath()
})
expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(systemAuth)
expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath())
expect(service.prepareForRateLimitFetch()).toEqual({
kind: 'ready',
codexHomePath: getSystemCodexHomePath()
})
expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(refreshedSystemAuth)
})
@@ -287,7 +296,10 @@ describe('CodexRuntimeHomeService', () => {
writeFileSync(getRuntimeCodexAuthPath(), retainedAuth, 'utf-8')
writeFileSync(getSystemCodexAuthPath(), refreshedSystemAuth, 'utf-8')
expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath())
expect(service.prepareForRateLimitFetch()).toEqual({
kind: 'ready',
codexHomePath: getSystemCodexHomePath()
})
expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(retainedAuth)
expect(readFileSync(getSystemCodexAuthPath(), 'utf-8')).toBe(refreshedSystemAuth)
})
@@ -305,7 +317,10 @@ describe('CodexRuntimeHomeService', () => {
writeFileSync(getRuntimeCodexAuthPath(), retainedAuth, 'utf-8')
rmSync(getSystemCodexAuthPath())
expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath())
expect(service.prepareForRateLimitFetch()).toEqual({
kind: 'ready',
codexHomePath: getSystemCodexHomePath()
})
expect(existsSync(getRuntimeCodexAuthPath())).toBe(false)
})
@@ -331,7 +346,10 @@ describe('CodexRuntimeHomeService', () => {
setShellStartupEnvProbeSupportedForTest(true)
writeFileSync(getSystemCodexAuthPath(), refreshedSystemAuth, 'utf-8')
expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath())
expect(service.prepareForRateLimitFetch()).toEqual({
kind: 'ready',
codexHomePath: getSystemCodexHomePath()
})
expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(refreshedSystemAuth)
})
@@ -371,7 +389,10 @@ describe('CodexRuntimeHomeService', () => {
setShellStartupEnvProbeSupportedForTest(true)
rmSync(getSystemCodexAuthPath())
expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath())
expect(service.prepareForRateLimitFetch()).toEqual({
kind: 'ready',
codexHomePath: getSystemCodexHomePath()
})
expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(retainedAuth)
})
@@ -390,7 +411,10 @@ describe('CodexRuntimeHomeService', () => {
setShellStartupEnvProbeSupportedForTest(true)
writeFileSync(getSystemCodexAuthPath(), refreshedSystemAuth, 'utf-8')
expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath())
expect(service.prepareForRateLimitFetch()).toEqual({
kind: 'ready',
codexHomePath: getSystemCodexHomePath()
})
expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(systemAuth)
})
@@ -405,11 +429,17 @@ describe('CodexRuntimeHomeService', () => {
setShellStartupEnvProbeSupportedForTest(true)
const service = new CodexRuntimeHomeService(store as never)
rmSync(getSystemCodexAuthPath())
expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath())
expect(service.prepareForRateLimitFetch()).toEqual({
kind: 'ready',
codexHomePath: getSystemCodexHomePath()
})
expect(existsSync(getRuntimeCodexAuthPath())).toBe(false)
writeFileSync(getSystemCodexAuthPath(), reloginAuth, 'utf-8')
expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath())
expect(service.prepareForRateLimitFetch()).toEqual({
kind: 'ready',
codexHomePath: getSystemCodexHomePath()
})
expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(reloginAuth)
})
@@ -443,7 +473,10 @@ describe('CodexRuntimeHomeService', () => {
expect(existsSync(getRuntimeCodexAuthPath())).toBe(false)
writeFileSync(getSystemCodexAuthPath(), reloginAuth, 'utf-8')
expect(restartedService.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath())
expect(restartedService.prepareForRateLimitFetch()).toEqual({
kind: 'ready',
codexHomePath: getSystemCodexHomePath()
})
expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(reloginAuth)
}
)
@@ -136,7 +136,10 @@ describe('CodexRuntimeHomeService per-account takeover composition', () => {
expect(readFileSync(join(account.managedHomePath, 'auth.json'), 'utf-8')).toBe(migrated)
expect(service.prepareForCodexLaunch()).toBe(account.managedHomePath)
writeFileSync(sharedAuthPath(), laterShared, 'utf-8')
expect(service.prepareForRateLimitFetch()).toBe(account.managedHomePath)
expect(service.prepareForRateLimitFetch()).toEqual({
kind: 'ready',
codexHomePath: account.managedHomePath
})
expect(service.prepareForCodexLaunch()).toBe(account.managedHomePath)
expect(readFileSync(join(account.managedHomePath, 'auth.json'), 'utf-8')).toBe(migrated)
expect(readFileSync(systemAuthPath(), 'utf-8')).toBe('system auth sentinel\n')
@@ -157,7 +160,10 @@ describe('CodexRuntimeHomeService per-account takeover composition', () => {
settings.activeCodexManagedAccountIdsByRuntime = { host: null, wsl: {} }
expect(service.prepareForCodexLaunch()).toBeNull()
expect(service.prepareForRateLimitFetch()).toBe(systemHome())
expect(service.prepareForRateLimitFetch()).toEqual({
kind: 'ready',
codexHomePath: systemHome()
})
expect(readFileSync(join(account.managedHomePath, 'auth.json'), 'utf-8')).toBe(fresh)
expect(readFileSync(sharedAuthPath(), 'utf-8')).toBe(mismatch)
expect(readFileSync(systemAuthPath(), 'utf-8')).toBe('system auth sentinel\n')
@@ -180,7 +186,10 @@ describe('CodexRuntimeHomeService per-account takeover composition', () => {
writeFileSync(sharedAuthPath(), laterShared, 'utf-8')
expect(service.prepareForCodexLaunch()).toBe(account.managedHomePath)
expect(service.prepareForRateLimitFetch()).toBe(account.managedHomePath)
expect(service.prepareForRateLimitFetch()).toEqual({
kind: 'ready',
codexHomePath: account.managedHomePath
})
expect(existsSync(accountAuthPath)).toBe(false)
expect(settings.activeCodexManagedAccountId).toBe(account.id)
expect(readFileSync(sharedAuthPath(), 'utf-8')).toBe(laterShared)
+137 -32
View File
@@ -72,7 +72,10 @@ import {
invalidateCodexSessionBackfillMarker
} from '../codex/codex-session-backfill-marker'
import { resolveCodexSessionBackfillPaths } from '../codex/codex-session-backfill'
import { assertOwnedHostCodexManagedHomePath } from './host-codex-managed-home-ownership'
import {
ManagedCodexHomeTemporarilyUnavailableError,
resolveHostCodexManagedHomeVerdict
} from './host-codex-managed-home-ownership'
import {
codexAuthCouldBelongToManagedAccount,
codexAuthIsFresher,
@@ -169,6 +172,20 @@ function codexAuthIsMonotonicallyFresher(
return codexAuthIsFresher(candidateAuthJson, baselineAuthJson)
}
/**
* Why: a skipped Codex quota poll must be distinguishable from "use the
* system-default home". `null` inside `ready` still means the system lane;
* `skip` means do not fetch at all this cycle (#STA-4422).
*/
/** Mirror path for the config-sync status channel; `unavailable` is not `null`. */
export type CodexMirroredHomeStatus =
| { kind: 'ready'; homePath: string | null }
| { kind: 'unavailable' }
export type CodexRateLimitHomeResolution =
| { kind: 'ready'; codexHomePath: string | null }
| { kind: 'skip' }
export class CodexRuntimeHomeService {
// Which managed account runtime auth.json mirrors; null means it follows system-default ~/.codex instead of a managed account.
private lastSyncedAccountId: string | null = null
@@ -345,11 +362,18 @@ export class CodexRuntimeHomeService {
account: CodexManagedAccount,
unavailableManagedHomePath?: string
): string | null {
const perAccountHome = this.getTrustedSelfContainedManagedHomePath(account)
if (!perAccountHome) {
const resolved = this.resolveSelfContainedManagedHome(account)
if (resolved.kind === 'indeterminate') {
// Why: refuse the launch rather than silently falling through to the
// system default, which would run a different account behind a UI still
// showing this one. The selection stays put; a later read may succeed.
throw new ManagedCodexHomeTemporarilyUnavailableError()
}
if (resolved.kind === 'untrusted') {
this.clearSelfContainedManagedSelection(account)
return null
}
const perAccountHome = resolved.homePath
if (
unavailableManagedHomePath &&
normalizeRuntimePathForComparison(unavailableManagedHomePath) ===
@@ -407,7 +431,13 @@ export class CodexRuntimeHomeService {
// hot-swap or token read-back to reconcile. A trusted home remains selected
// while Codex atomically replaces auth.json.
private syncSelfContainedManagedSelection(account: CodexManagedAccount): void {
const perAccountHome = this.getTrustedSelfContainedManagedHomePath(account)
const resolved = this.resolveSelfContainedManagedHome(account)
if (resolved.kind === 'indeterminate') {
// Why: a sync runs on every app start, exactly when antivirus is busiest.
// An unreadable home must not deselect the account (#STA-4422).
return
}
const perAccountHome = resolved.kind === 'owned' ? resolved.homePath : null
if (perAccountHome) {
this.lastSyncedAccountId = account.id
this.lastHostAccountUsedSelfContainedHome = true
@@ -421,21 +451,41 @@ export class CodexRuntimeHomeService {
this.clearSelfContainedManagedSelection(account)
}
private getTrustedSelfContainedManagedHomePath(account: CodexManagedAccount): string | null {
try {
assertOwnedHostCodexManagedHomePath({
candidatePath: account.managedHomePath,
managedAccountsRoot: this.getManagedAccountsRoot(),
systemCodexHomePath: getSystemCodexHomePath(),
expectedAccountId: account.id
})
/**
* Why: an unreadable home and an untrustworthy one demand opposite responses.
* Only `untrusted` may clear the user's selection; `indeterminate` means we
* could not tell, so callers refuse the operation and leave durable state
* alone (#STA-4422).
*/
private resolveSelfContainedManagedHome(
account: CodexManagedAccount
): { kind: 'owned'; homePath: string } | { kind: 'untrusted' } | { kind: 'indeterminate' } {
const verdict = resolveHostCodexManagedHomeVerdict({
candidatePath: account.managedHomePath,
managedAccountsRoot: this.getManagedAccountsRoot(),
systemCodexHomePath: getSystemCodexHomePath(),
expectedAccountId: account.id
})
if (verdict.kind === 'owned') {
// Preserve the persisted path spelling (notably /var vs /private/var on
// macOS) so injected CODEX_HOME stays stable across the rollout.
return account.managedHomePath
} catch (error) {
console.warn('[codex-runtime-home] Refusing untrusted managed account home:', error)
return null
return { kind: 'owned', homePath: account.managedHomePath }
}
if (verdict.kind === 'untrusted') {
console.warn('[codex-runtime-home] Refusing untrusted managed account home:', verdict.reason)
return { kind: 'untrusted' }
}
console.warn(
'[codex-runtime-home] Managed account home is temporarily unreadable; keeping selection:',
verdict.error
)
return { kind: 'indeterminate' }
}
/** Read-only callers that mutate nothing and simply skip an unusable home. */
private getTrustedSelfContainedManagedHomePath(account: CodexManagedAccount): string | null {
const resolved = this.resolveSelfContainedManagedHome(account)
return resolved.kind === 'owned' ? resolved.homePath : null
}
private clearSelfContainedManagedSelection(
@@ -539,6 +589,42 @@ export class CodexRuntimeHomeService {
: null
}
/**
* Same selection, but an unreadable home refuses instead of collapsing to
* `null`. Session resume must not read "no managed selection" out of a failed
* marker stat: another account's readable alias would then win the legacy
* rescan and the pane would resume under that account's credentials while the
* UI still shows this one (#STA-4422).
*/
resolveSelectedHostAccountCodexHomePathForResume(): string | null {
const selfContainedAccount = this.getSelfContainedManagedHostAccount()
if (!selfContainedAccount) {
return null
}
const resolved = this.resolveSelfContainedManagedHome(selfContainedAccount)
if (resolved.kind === 'indeterminate') {
throw new ManagedCodexHomeTemporarilyUnavailableError()
}
if (resolved.kind === 'untrusted') {
this.clearSelfContainedManagedSelection(selfContainedAccount)
return null
}
return resolved.homePath
}
/** Trust-gates host previews without changing WSL routing or durable account state. */
resolveCodexManagedAccountHomeForInactiveFetch(
account: CodexManagedAccount
): { kind: 'ready'; homePath: string } | { kind: 'skip' } {
if (account.managedHomeRuntime === 'wsl' || this.getWslManagedHomePath(account)) {
return { kind: 'ready', homePath: account.managedHomePath }
}
const resolved = this.resolveSelfContainedManagedHome(account)
return resolved.kind === 'owned'
? { kind: 'ready', homePath: resolved.homePath }
: { kind: 'skip' }
}
getSelectedHostCodexHomeRoute(): CodexPaneHomeRoute {
if (this.getSelfContainedManagedHostAccount()) {
return 'account-home'
@@ -672,22 +758,32 @@ export class CodexRuntimeHomeService {
syncSystemConfigIntoManagedCodexHome({ runtimeHomePath, systemHomePath })
}
prepareForRateLimitFetch(target?: CodexAccountSelectionTarget): string | null {
// Why: `null` is a real value here — it means "use the system-default lane".
// A skipped poll needs its own channel or the fetcher silently retargets the
// user's real ~/.codex (#STA-4422).
prepareForRateLimitFetch(target?: CodexAccountSelectionTarget): CodexRateLimitHomeResolution {
if (target?.runtime === 'wsl') {
const wslTarget = this.resolveWslDefaultTarget(target)
const syncedRuntimeHomePath = this.getPreparedWslRateLimitHomePath(wslTarget)
return syncedRuntimeHomePath ?? this.getWslSystemCodexHomePath(wslTarget)
return {
kind: 'ready',
codexHomePath: syncedRuntimeHomePath ?? this.getWslSystemCodexHomePath(wslTarget)
}
}
const selfContainedAccount = this.getSelfContainedManagedHostAccount()
const selfContainedHome = selfContainedAccount
? this.getTrustedSelfContainedManagedHomePath(selfContainedAccount)
: null
if (selfContainedAccount && selfContainedHome) {
// Why: the quota fetch reads the account's own auth.json in place; no
// shared-home hot-swap or per-poll resource relink (that is launch prep).
return selfContainedHome
}
if (selfContainedAccount) {
const resolved = this.resolveSelfContainedManagedHome(selfContainedAccount)
if (resolved.kind === 'owned') {
// Why: the quota fetch reads the account's own auth.json in place; no
// shared-home hot-swap or per-poll resource relink (that is launch prep).
return { kind: 'ready', codexHomePath: resolved.homePath }
}
if (resolved.kind === 'indeterminate') {
// Why: returning null here would NOT skip — the fetcher maps null to
// ~/.codex and would probe the user's real home with a token-refreshing
// app-server. Skip the poll outright and keep the selection.
return { kind: 'skip' }
}
this.clearSelfContainedManagedSelection(selfContainedAccount)
}
if (this.isHostSystemDefaultRealHome()) {
@@ -698,12 +794,12 @@ export class CodexRuntimeHomeService {
if (hasRecordedLegacySharedCodexPane()) {
this.syncLegacySharedSystemDefaultAuthForRetainedPanes()
}
return getSystemCodexHomePath()
return { kind: 'ready', codexHomePath: getSystemCodexHomePath() }
}
this.syncForCurrentSelection()
syncSystemCodexResourcesIntoManagedHome()
syncSystemConfigIntoManagedCodexHome()
return this.getRuntimeHomePath()
return { kind: 'ready', codexHomePath: this.getRuntimeHomePath() }
}
syncForCurrentSelection(
@@ -1310,15 +1406,24 @@ export class CodexRuntimeHomeService {
* real-home lane, which runs Codex directly against ~/.codex — there is no
* mirror there, so there is nothing that can fall behind.
*/
getMirroredHostHomePathForStatus(): string | null {
getMirroredHostHomePathForStatus(): CodexMirroredHomeStatus {
const selfContainedAccount = this.getSelfContainedManagedHostAccount()
if (selfContainedAccount) {
return this.getTrustedSelfContainedManagedHomePath(selfContainedAccount)
const resolved = this.resolveSelfContainedManagedHome(selfContainedAccount)
if (resolved.kind === 'indeterminate') {
// Why: `null` here is a positive claim that no mirror exists, which the
// status channel reports as healthy. An unreadable home is not that.
return { kind: 'unavailable' }
}
return { kind: 'ready', homePath: resolved.kind === 'owned' ? resolved.homePath : null }
}
if (this.isHostSystemDefaultRealHome()) {
return null
return { kind: 'ready', homePath: null }
}
return {
kind: 'ready',
homePath: join(getOrcaUserDataPath(), 'codex-runtime-home', 'home')
}
return join(getOrcaUserDataPath(), 'codex-runtime-home', 'home')
}
private getRuntimeAuthPath(): string {
@@ -92,10 +92,14 @@ describe('CodexRuntimeHomeService', () => {
expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(
'{"account":"wsl"}\n'
)
expect(service.prepareForRateLimitFetch()).toBe(getRuntimeCodexHomePath())
expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe(
wslRuntimeHomePath
)
expect(service.prepareForRateLimitFetch()).toEqual({
kind: 'ready',
codexHomePath: getRuntimeCodexHomePath()
})
expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toEqual({
kind: 'ready',
codexHomePath: wslRuntimeHomePath
})
} finally {
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform)
@@ -64,8 +64,14 @@ describe('CodexRuntimeHomeService', () => {
const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' }
const expectedHome = join(wslHome, '.codex')
expect(service.prepareForRateLimitFetch(target)).toBe(expectedHome)
expect(service.prepareForRateLimitFetch(target)).toBe(expectedHome)
expect(service.prepareForRateLimitFetch(target)).toEqual({
kind: 'ready',
codexHomePath: expectedHome
})
expect(service.prepareForRateLimitFetch(target)).toEqual({
kind: 'ready',
codexHomePath: expectedHome
})
expect(syncWslRuntime).not.toHaveBeenCalled()
} finally {
if (originalPlatform) {
@@ -138,9 +144,10 @@ describe('CodexRuntimeHomeService', () => {
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
const service = new CodexRuntimeHomeService(store as never)
expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: null })).toBe(
join(wslHome, '.local', 'share', 'orca', 'codex-runtime-home', 'home')
)
expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: null })).toEqual({
kind: 'ready',
codexHomePath: join(wslHome, '.local', 'share', 'orca', 'codex-runtime-home', 'home')
})
expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(ubuntuAuth)
} finally {
if (originalPlatform) {
@@ -194,9 +201,10 @@ describe('CodexRuntimeHomeService', () => {
try {
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
const service = new CodexRuntimeHomeService(store as never)
expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe(
systemCodexHomePath
)
expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toEqual({
kind: 'ready',
codexHomePath: systemCodexHomePath
})
expect(readFileSync(join(managedHomePath, 'auth.json'), 'utf-8')).toBe(managedAuth)
const externallyRefreshedAuth = createCodexAuthJson(
'wsl@example.com',
@@ -205,9 +213,10 @@ describe('CodexRuntimeHomeService', () => {
3_000
)
writeFileSync(join(systemCodexHomePath, 'auth.json'), externallyRefreshedAuth, 'utf-8')
expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe(
systemCodexHomePath
)
expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toEqual({
kind: 'ready',
codexHomePath: systemCodexHomePath
})
expect(readFileSync(join(systemCodexHomePath, 'auth.json'), 'utf-8')).toBe(
externallyRefreshedAuth
)
@@ -0,0 +1,206 @@
/**
* STA-4422 symptom 2: the status bar's "Sign in" action re-authenticates an
* account while nothing is selected, and the pre-login capture restored that
* empty selection — the browser said "Signed in to Codex" and the account was
* still inactive. The activation intent is deliberately narrow: it may only fill
* an empty lane, and the decision is made from the value captured *before* the
* OAuth await because the runtime-home poll runs outside the mutation queue.
*/
import { describe, expect, it, vi } from 'vitest'
import { EventEmitter } from 'node:events'
import { writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { PassThrough } from 'node:stream'
import type { GlobalSettings } from '../../shared/global-settings-types'
import {
createCodexAuthJson,
createManagedHome,
createRateLimits,
createRuntimeHome,
createSettings,
createStore,
registerCodexAccountsTestHomes,
testState
} from './service-test-harness'
vi.mock('electron', () => ({
app: {
getPath: () => testState.userDataDir
}
}))
vi.mock('node:os', async () => {
const actual = await vi.importActual<typeof import('node:os')>('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import()
return {
...actual,
homedir: () => testState.fakeHomeDir
}
})
function createHostAccounts(): GlobalSettings['codexManagedAccounts'] {
return ['account-1', 'account-2'].map((id, index) => ({
id,
email: `${id}@example.com`,
managedHomePath: createManagedHome(
testState.userDataDir,
id,
'',
createCodexAuthJson(`${id}@example.com`, `provider-${id}`, `refresh-${id}`)
),
providerAccountId: `provider-${id}`,
workspaceLabel: null,
workspaceAccountId: `provider-${id}`,
createdAt: index + 1,
updatedAt: index + 1,
lastAuthenticatedAt: index + 1
}))
}
/** Real `codex login` stand-in: writes fresh credentials, optionally racing the store. */
function createLoginSpawn(onLogin?: () => void) {
return vi.fn((_command: string, _args: string[], options: { env: NodeJS.ProcessEnv }) => {
const child = new EventEmitter() as EventEmitter & {
stdout: PassThrough
stderr: PassThrough
kill: () => void
}
child.stdout = new PassThrough()
child.stderr = new PassThrough()
child.kill = vi.fn()
onLogin?.()
writeFileSync(
join(options.env.CODEX_HOME!, 'auth.json'),
createCodexAuthJson('reauthenticated@example.com', 'provider-new', 'refresh-new'),
'utf-8'
)
queueMicrotask(() => child.emit('close', 0))
return child
})
}
describe('CodexAccountService reauthenticate activation intent', () => {
registerCodexAccountsTestHomes()
it('activates the re-authed account when the intent is set and nothing was selected', async () => {
vi.resetModules()
const spawnMock = createLoginSpawn()
vi.doMock('node:child_process', () => ({ execFileSync: vi.fn(), spawn: spawnMock }))
vi.doMock('../codex-cli/command', () => ({ resolveCodexCommand: () => 'codex' }))
const settings = createSettings({
codexManagedAccounts: createHostAccounts(),
activeCodexManagedAccountId: null,
activeCodexManagedAccountIdsByRuntime: { host: null, wsl: {} }
})
const store = createStore(settings)
const runtimeHome = createRuntimeHome()
const rateLimits = createRateLimits()
const { CodexAccountService } = await import('./service')
const service = new CodexAccountService(
store as never,
rateLimits as never,
runtimeHome as never
)
const result = await service.reauthenticateAccount('account-2', {
activateIfSelectionWasEmpty: true
})
// Healthy anchor: the login really ran and its identity landed.
expect(spawnMock).toHaveBeenCalledTimes(1)
expect(result.accounts.find((account) => account.id === 'account-2')).toMatchObject({
email: 'reauthenticated@example.com',
providerAccountId: 'provider-new'
})
expect(result.activeAccountId).toBe('account-2')
expect(result.activeAccountIdsByRuntime).toEqual({ host: 'account-2', wsl: {} })
expect(store.getSettings()).toMatchObject({
activeCodexManagedAccountId: 'account-2',
activeCodexManagedAccountIdsByRuntime: { host: 'account-2', wsl: {} }
})
expect(runtimeHome.syncForCurrentSelection).toHaveBeenCalledWith({ runtime: 'host' })
})
it('keeps the previously selected account even when the selection is cleared during OAuth', async () => {
vi.resetModules()
// Why: the runtime-home poll runs outside the mutation queue, so it can null
// the lane while the login promise is pending. A post-login read of the
// selection would see that null and hand the lane to the wrong account.
const spawnMock = createLoginSpawn(() => {
const current = store.getSettings()
store.updateSettings({
activeCodexManagedAccountId: null,
activeCodexManagedAccountIdsByRuntime: {
...current.activeCodexManagedAccountIdsByRuntime!,
host: null
}
})
})
vi.doMock('node:child_process', () => ({ execFileSync: vi.fn(), spawn: spawnMock }))
vi.doMock('../codex-cli/command', () => ({ resolveCodexCommand: () => 'codex' }))
const settings = createSettings({
codexManagedAccounts: createHostAccounts(),
activeCodexManagedAccountId: 'account-1',
activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} }
})
const store = createStore(settings)
const runtimeHome = createRuntimeHome()
const rateLimits = createRateLimits()
const { CodexAccountService } = await import('./service')
const service = new CodexAccountService(
store as never,
rateLimits as never,
runtimeHome as never
)
const result = await service.reauthenticateAccount('account-2', {
activateIfSelectionWasEmpty: true
})
// Healthy anchor: the concurrent clear really landed before the settings write.
expect(spawnMock).toHaveBeenCalledTimes(1)
expect(store.updateSettings).toHaveBeenCalledWith(
expect.objectContaining({ activeCodexManagedAccountId: null })
)
expect(result.activeAccountId).toBe('account-1')
expect(result.activeAccountIdsByRuntime).toEqual({ host: 'account-1', wsl: {} })
expect(store.getSettings()).toMatchObject({
activeCodexManagedAccountId: 'account-1',
activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} }
})
})
it('leaves the selection empty without the intent, as the settings pane expects', async () => {
vi.resetModules()
const spawnMock = createLoginSpawn()
vi.doMock('node:child_process', () => ({ execFileSync: vi.fn(), spawn: spawnMock }))
vi.doMock('../codex-cli/command', () => ({ resolveCodexCommand: () => 'codex' }))
const settings = createSettings({
codexManagedAccounts: createHostAccounts(),
activeCodexManagedAccountId: null,
activeCodexManagedAccountIdsByRuntime: { host: null, wsl: {} }
})
const store = createStore(settings)
const runtimeHome = createRuntimeHome()
const rateLimits = createRateLimits()
const { CodexAccountService } = await import('./service')
const service = new CodexAccountService(
store as never,
rateLimits as never,
runtimeHome as never
)
const result = await service.reauthenticateAccount('account-2')
// Healthy anchor: the same login ran; only the selection outcome differs.
expect(spawnMock).toHaveBeenCalledTimes(1)
expect(result.accounts.find((account) => account.id === 'account-2')).toMatchObject({
email: 'reauthenticated@example.com'
})
expect(result.activeAccountId).toBeNull()
expect(result.activeAccountIdsByRuntime).toEqual({ host: null, wsl: {} })
expect(store.getSettings().activeCodexManagedAccountId).toBeNull()
})
})
@@ -0,0 +1,251 @@
import { describe, expect, it, vi } from 'vitest'
import { join } from 'node:path'
import { realpathSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
import type * as NodeFs from 'node:fs'
import { buildCodexResetCreditExpectedScope } from '../../shared/codex-reset-credit-scope'
import {
createManagedHome,
createRateLimits,
createRuntimeHome,
createSettings,
createStore,
registerCodexAccountsTestHomes,
testState
} from './service-test-harness'
import {
createResetCreditLimits,
createResetRateLimitState
} from './service-reset-credit-test-fixtures'
const fsFaults = vi.hoisted(() => {
const held = new Set<string>()
let heldReads = 0
let mkdirCalls = 0
return {
hold(path: string): void {
held.add(path)
},
reset(): void {
held.clear()
heldReads = 0
mkdirCalls = 0
},
heldReads(): number {
return heldReads
},
resetMkdirCalls(): void {
mkdirCalls = 0
},
mkdirCalls(): number {
return mkdirCalls
},
noteMkdir(): void {
mkdirCalls += 1
},
consumeLstat(path: unknown): void {
if (typeof path !== 'string' || !held.has(path)) {
return
}
heldReads += 1
const error: NodeJS.ErrnoException = new Error(`EPERM: lstat '${path}'`)
error.code = 'EPERM'
throw error
}
}
})
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof NodeFs>()
const patched = {
...actual,
lstatSync: (...args: Parameters<typeof actual.lstatSync>) => {
fsFaults.consumeLstat(args[0])
return actual.lstatSync(...args)
},
mkdirSync: (...args: Parameters<typeof actual.mkdirSync>) => {
fsFaults.noteMkdir()
return actual.mkdirSync(...args)
}
}
return { ...patched, default: patched }
})
vi.mock('electron', () => ({
app: {
getPath: () => testState.userDataDir
}
}))
vi.mock('node:os', async () => {
const actual = await vi.importActual<typeof import('node:os')>('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import()
return {
...actual,
homedir: () => testState.fakeHomeDir
}
})
describe('Codex reset-credit managed-home ownership', () => {
registerCodexAccountsTestHomes()
it('refuses an indeterminate host home before ledger or provider mutation', async () => {
const fixture = await createFixture()
fsFaults.hold(join(realpathSync(fixture.managedHomePath), '.orca-managed-home'))
fsFaults.resetMkdirCalls()
await expect(
fixture.service.consumeRateLimitResetCredit(fixture.idempotencyKey, fixture.expectedScope)
).rejects.toThrow('temporarily locked')
expect(fixture.consume).not.toHaveBeenCalled()
expect(fixture.store.replaceCodexResetCreditAttemptLedgerAndFlush).not.toHaveBeenCalled()
expect(fixture.store.updateSettings).not.toHaveBeenCalled()
expect(fsFaults.mkdirCalls()).toBe(0)
expect(readFileSync(join(fixture.managedHomePath, 'auth.json'), 'utf-8')).toBe('auth-before')
})
it('refuses a proven-untrusted host home before ledger or provider mutation', async () => {
const fixture = await createFixture()
writeFileSync(join(fixture.managedHomePath, '.orca-managed-home'), 'someone-else\n', 'utf-8')
fsFaults.resetMkdirCalls()
await expect(
fixture.service.consumeRateLimitResetCredit(fixture.idempotencyKey, fixture.expectedScope)
).rejects.toThrow('ownership marker does not match')
expect(fixture.consume).not.toHaveBeenCalled()
expect(fixture.store.replaceCodexResetCreditAttemptLedgerAndFlush).not.toHaveBeenCalled()
expect(fixture.store.updateSettings).not.toHaveBeenCalled()
expect(fsFaults.mkdirCalls()).toBe(0)
})
it.each([
{
homeState: 'indeterminate',
expectedError: 'temporarily locked',
makeHomeUnsafe: (managedHomePath: string) =>
fsFaults.hold(join(realpathSync(managedHomePath), '.orca-managed-home'))
},
{
homeState: 'proven untrusted',
expectedError: 'ownership marker does not match',
makeHomeUnsafe: (managedHomePath: string) =>
writeFileSync(join(managedHomePath, '.orca-managed-home'), 'someone-else\n', 'utf-8')
}
])(
'rechecks ownership for a durable providerPending retry when the home is $homeState',
async ({ expectedError, makeHomeUnsafe }) => {
const fixture = await createFixture()
const pendingLedger = {
version: 1 as const,
attempts: [
{
idempotencyKey: fixture.idempotencyKey,
expectedScope: fixture.expectedScope,
state: 'providerPending' as const
}
]
}
fixture.store.replaceCodexResetCreditAttemptLedgerAndFlush(pendingLedger)
makeHomeUnsafe(fixture.managedHomePath)
const settingsBefore = structuredClone(fixture.store.getSettings())
const authPath = join(fixture.managedHomePath, 'auth.json')
const markerPath = join(fixture.managedHomePath, '.orca-managed-home')
const authBefore = readFileSync(authPath, 'utf-8')
const markerBefore = readFileSync(markerPath, 'utf-8')
const directoryEntriesBefore = readdirSync(fixture.managedHomePath).sort()
fixture.consume.mockClear()
fixture.store.getCodexResetCreditAttemptLedger.mockClear()
fixture.store.replaceCodexResetCreditAttemptLedgerAndFlush.mockClear()
fixture.store.updateSettings.mockClear()
fsFaults.resetMkdirCalls()
const restarted = fixture.createService()
await expect(
restarted.consumeRateLimitResetCredit(fixture.idempotencyKey, fixture.expectedScope)
).rejects.toThrow(expectedError)
expect(fixture.store.getCodexResetCreditAttemptLedger).toHaveBeenCalledOnce()
expect(fixture.store.getCodexResetCreditAttemptLedger()).toEqual(pendingLedger)
expect(fixture.consume).not.toHaveBeenCalled()
expect(fixture.store.replaceCodexResetCreditAttemptLedgerAndFlush).not.toHaveBeenCalled()
expect(fixture.store.updateSettings).not.toHaveBeenCalled()
expect(fixture.store.getSettings()).toEqual(settingsBefore)
expect(fsFaults.mkdirCalls()).toBe(0)
expect(readFileSync(authPath, 'utf-8')).toBe(authBefore)
expect(readFileSync(markerPath, 'utf-8')).toBe(markerBefore)
expect(readdirSync(fixture.managedHomePath).sort()).toEqual(directoryEntriesBefore)
}
)
it('replays a durable settled outcome without reading a locked managed home', async () => {
const fixture = await createFixture()
await expect(
fixture.service.consumeRateLimitResetCredit(fixture.idempotencyKey, fixture.expectedScope)
).resolves.toMatchObject({ outcome: 'reset' })
fixture.consume.mockClear()
fixture.store.replaceCodexResetCreditAttemptLedgerAndFlush.mockClear()
fixture.store.updateSettings.mockClear()
fsFaults.hold(join(realpathSync(fixture.managedHomePath), '.orca-managed-home'))
fsFaults.resetMkdirCalls()
await expect(
fixture
.createService()
.consumeRateLimitResetCredit(fixture.idempotencyKey, fixture.expectedScope)
).resolves.toMatchObject({ outcome: 'reset', scope: fixture.expectedScope })
expect(fsFaults.heldReads()).toBe(0)
expect(fixture.consume).not.toHaveBeenCalled()
expect(fixture.store.replaceCodexResetCreditAttemptLedgerAndFlush).not.toHaveBeenCalled()
expect(fixture.store.updateSettings).not.toHaveBeenCalled()
expect(fsFaults.mkdirCalls()).toBe(0)
expect(readFileSync(join(fixture.managedHomePath, 'auth.json'), 'utf-8')).toBe('auth-before')
})
async function createFixture() {
fsFaults.reset()
const managedHomePath = createManagedHome(testState.userDataDir, 'account-1', '', 'auth-before')
const account = {
id: 'account-1',
email: 'user@example.com',
managedHomePath,
managedHomeRuntime: 'host' as const,
wslDistro: null,
createdAt: 1,
updatedAt: 1,
lastAuthenticatedAt: 1
}
const settings = createSettings({
codexManagedAccounts: [account],
activeCodexManagedAccountId: account.id,
activeCodexManagedAccountIdsByRuntime: { host: account.id, wsl: {} }
})
const store = createStore(settings)
const limits = createResetCreditLimits()
const state = createResetRateLimitState(limits)
const consume = vi.fn().mockResolvedValue({ outcome: 'reset', state })
const rateLimits = {
...createRateLimits(),
getState: vi.fn(() => state),
consumeCodexRateLimitResetCredit: consume
}
const expectedScope = buildCodexResetCreditExpectedScope({
target: state.codexTarget,
account,
limits
})!
const { CodexAccountService } = await import('./service')
const createService = () =>
new CodexAccountService(store as never, rateLimits as never, createRuntimeHome() as never)
return {
service: createService(),
createService,
store,
consume,
expectedScope,
managedHomePath,
idempotencyKey: '11111111-1111-4111-8111-111111111111'
}
}
})
@@ -168,7 +168,7 @@ describe('CodexAccountService config sync', () => {
consumeCodexRateLimitResetCredit: consume
}
const runtimeHome = createRuntimeHome()
runtimeHome.prepareForRateLimitFetch.mockReturnValue(null)
runtimeHome.prepareForRateLimitFetch.mockReturnValue({ kind: 'ready', codexHomePath: null })
const { CodexAccountService } = await import('./service')
const service = new CodexAccountService(
createStore(settings) as never,
@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { GlobalSettings } from '../../shared/global-settings-types'
import type { CodexResetCreditAttemptLedger } from '../../shared/codex-reset-credit-attempt-ledger'
import type { CodexRateLimitHomeResolution } from './runtime-home-service'
export const testState = {
userDataDir: '',
@@ -197,14 +198,16 @@ export function createRateLimits(): RateLimitsStub {
export type RuntimeHomeStub = {
syncForCurrentSelection: Mock<(...args: unknown[]) => void>
clearLastWrittenAuthJson: Mock<(...args: unknown[]) => void>
prepareForRateLimitFetch: Mock<(...args: unknown[]) => string | null>
prepareForRateLimitFetch: Mock<(...args: unknown[]) => CodexRateLimitHomeResolution>
}
export function createRuntimeHome(): RuntimeHomeStub {
return {
syncForCurrentSelection: vi.fn(),
clearLastWrittenAuthJson: vi.fn(),
prepareForRateLimitFetch: vi.fn(() => null)
prepareForRateLimitFetch: vi.fn(
(): CodexRateLimitHomeResolution => ({ kind: 'ready', codexHomePath: null })
)
}
}
+75 -32
View File
@@ -56,7 +56,10 @@ import {
setSelectedCodexAccountIdForTarget,
type CodexAccountSelectionTarget
} from './runtime-selection'
import { assertOwnedHostCodexManagedHomePath } from './host-codex-managed-home-ownership'
import {
assertOwnedHostCodexManagedHomePath,
ManagedCodexHomeTemporarilyUnavailableError
} from './host-codex-managed-home-ownership'
const LOGIN_TIMEOUT_MS = 120_000
const MAX_LOGIN_OUTPUT_CHARS = 4_000
@@ -92,6 +95,15 @@ export type CodexAccountAddTarget = {
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
}
@@ -140,6 +152,10 @@ type CodexResetCreditAttempt = {
settledOutcome: CodexRateLimitResetOutcome | null
}
type CodexResetCreditScopeValidation =
| { kind: 'settledReplay' }
| { kind: 'providerMutation'; requireCurrentOffer: boolean }
class CodexResetCreditScopeRejection extends Error {
constructor(
readonly reason: CodexResetCreditRejectedBeforeProviderReason,
@@ -297,8 +313,11 @@ export class CodexAccountService {
return this.serializeMutation(() => this.doAddAccountFromHome(sourceHome, target))
}
async reauthenticateAccount(accountId: string): Promise<CodexRateLimitAccountsState> {
return this.serializeMutation(() => this.doReauthenticateAccount(accountId))
async reauthenticateAccount(
accountId: string,
options?: CodexAccountReauthenticateOptions
): Promise<CodexRateLimitAccountsState> {
return this.serializeMutation(() => this.doReauthenticateAccount(accountId, options))
}
async removeAccount(accountId: string): Promise<CodexRateLimitAccountsState> {
@@ -332,7 +351,9 @@ export class CodexAccountService {
}
if (existing.state === 'settled' && existing.settledOutcome) {
return this.serializeMutation(async () => {
const { rateLimits } = this.validateResetCreditScope(expectedScope, false)
const { rateLimits } = this.validateResetCreditScope(expectedScope, {
kind: 'settledReplay'
})
return {
outcome: existing.settledOutcome!,
scope: existing.expectedScope,
@@ -424,7 +445,13 @@ export class CodexAccountService {
if (this.hasPendingResetForTarget(target)) {
throw new Error('A previous reset attempt for this target still has an unknown outcome.')
}
const codexHomePath = this.runtimeHome.prepareForRateLimitFetch(target)
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,
@@ -471,7 +498,10 @@ export class CodexAccountService {
const isFresh = attempt.state === 'fresh'
let validation: { managedHomePath: string; rateLimits: RateLimitState }
try {
validation = this.validateResetCreditScope(expectedScope, isFresh)
validation = this.validateResetCreditScope(expectedScope, {
kind: 'providerMutation',
requireCurrentOffer: isFresh
})
} catch (error) {
if (isFresh && error instanceof CodexResetCreditScopeRejection) {
this.releaseFreshResetAttempt(idempotencyKey, attempt)
@@ -538,7 +568,7 @@ export class CodexAccountService {
private validateResetCreditScope(
expectedScope: CodexResetCreditExpectedScope,
requireCurrentOffer: boolean
validation: CodexResetCreditScopeValidation
): { managedHomePath: string; rateLimits: RateLimitState } {
const rateLimitState = this.rateLimits.getState()
if (!sameRateLimitTarget(rateLimitState.codexTarget, expectedScope.target)) {
@@ -580,30 +610,35 @@ export class CodexAccountService {
)
}
const currentScope = buildCodexResetCreditExpectedScope({
target: rateLimitState.codexTarget,
account: this.toSummary(account),
limits: rateLimitState.codex
})
// Why: a same-key replay resolves an already-started provider mutation;
// its credit snapshot may have refreshed, but its account/runtime identity may not change.
if (requireCurrentOffer && !currentScope) {
throw new CodexResetCreditScopeRejection(
'offerUnavailable',
rateLimitState,
'The Codex reset-credit offer is no longer available.'
)
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 (
requireCurrentOffer &&
currentScope &&
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 }
@@ -847,7 +882,10 @@ export class CodexAccountService {
return this.getSnapshot()
}
private async doReauthenticateAccount(accountId: string): Promise<CodexRateLimitAccountsState> {
private async doReauthenticateAccount(
accountId: string,
options?: CodexAccountReauthenticateOptions
): Promise<CodexRateLimitAccountsState> {
const account = this.requireAccount(accountId)
const managedHomePath = this.ensureManagedHomeForReauthentication(account)
const accountTarget = getCodexSelectionTargetForAccount(account)
@@ -855,6 +893,11 @@ export class CodexAccountService {
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)
@@ -880,7 +923,7 @@ export class CodexAccountService {
)
const activeSelection = setSelectedCodexAccountIdForTarget(
normalizeCodexRuntimeSelection(settings),
selectedAccountId,
activateAfterLogin ? accountId : selectedAccountId,
accountTarget
)
@@ -0,0 +1,221 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { realpathSync, writeFileSync } from 'node:fs'
import type * as NodeFs from 'node:fs'
import { join } from 'node:path'
import { createSettings } from './runtime-home-settings-test-fixtures'
import {
createCodexAccountRecord,
createCodexAuthJson,
createManagedAuth,
createStore,
getSystemCodexAuthPath,
getSystemCodexHomePath,
setupRuntimeHomeTest,
teardownRuntimeHomeTest,
testState
} from './runtime-home-service-test-harness'
// STA-4422 regression: a transient lstat failure on the ownership marker
// (Windows AV/indexer EPERM/EBUSY) must NOT deselect the managed account. The
// poll skips instead, and the selection survives — before the fix one fault
// cleared it permanently.
const lstatFaults = vi.hoisted(() => {
const state = {
/** path -> remaining injected EPERM failures */
pending: new Map<string, number>(),
/** paths that fail on EVERY read until released (models a held AV lock) */
held: new Set<string>(),
failOnce(path: string): void {
state.pending.set(path, (state.pending.get(path) ?? 0) + 1)
},
hold(path: string): void {
state.held.add(path)
},
release(path: string): void {
state.held.delete(path)
},
consumedHeld: new Map<string, number>(),
heldReads(path: string): number {
return state.consumedHeld.get(path) ?? 0
},
remaining(path: string): number {
return state.pending.get(path) ?? 0
},
reset(): void {
state.pending.clear()
state.held.clear()
state.consumedHeld.clear()
},
consume(target: unknown): void {
if (typeof target !== 'string') {
return
}
const remaining = state.pending.get(target) ?? 0
const isHeld = state.held.has(target)
if (remaining <= 0 && !isHeld) {
return
}
if (isHeld) {
state.consumedHeld.set(target, (state.consumedHeld.get(target) ?? 0) + 1)
} else {
state.pending.set(target, remaining - 1)
}
const error: NodeJS.ErrnoException = new Error(
`EPERM: operation not permitted, lstat '${target}'`
)
error.code = 'EPERM'
error.errno = -4048
error.syscall = 'lstat'
error.path = target
throw error
}
}
return state
})
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof NodeFs>()
const original = actual.lstatSync as (...args: unknown[]) => unknown
const patched: Record<string, unknown> = {
...actual,
lstatSync: Object.assign((...args: unknown[]): unknown => {
lstatFaults.consume(args[0])
return original(...args)
}, original)
}
return { ...patched, default: patched }
})
vi.mock('electron', () => ({
app: {
getPath: () => testState.userDataDir
}
}))
vi.mock('node:os', async () => {
const actual = await vi.importActual<typeof import('node:os')>('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import()
return {
...actual,
homedir: () => testState.fakeHomeDir
}
})
describe('STA-4422 Codex sessions keep logging out', () => {
beforeEach(() => {
lstatFaults.reset()
setupRuntimeHomeTest()
})
afterEach(() => {
lstatFaults.reset()
teardownRuntimeHomeTest()
})
it('keeps the selection and SKIPS the poll while the ownership marker is locked', async () => {
writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8')
const managedHomePath = createManagedAuth(
testState.userDataDir,
'account-1',
createCodexAuthJson('user@example.com', 'acct-1', 'refresh-1')
)
const store = createStore(
createSettings({
shellStartupEnvProbeSupported: true,
codexManagedAccounts: [
createCodexAccountRecord('account-1', 'user@example.com', 'acct-1', managedHomePath)
],
activeCodexManagedAccountId: 'account-1',
activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} }
})
)
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
const service = new CodexRuntimeHomeService(store as never)
// Anchor: a healthy poll resolves the managed home and touches nothing.
expect(service.prepareForRateLimitFetch()).toEqual({
kind: 'ready',
codexHomePath: managedHomePath
})
expect(store.updateSettings).not.toHaveBeenCalled()
// Hold the marker for the whole window, the way an AV scan does. A
// single-shot fault would be too weak: it could be masked by any future
// retry and would green-light an unimplemented fix.
const markerPath = join(realpathSync(managedHomePath), '.orca-managed-home')
lstatFaults.hold(markerPath)
const duringLock = service.prepareForRateLimitFetch()
// The fault really was consumed by the code under test.
expect(lstatFaults.heldReads(markerPath)).toBeGreaterThan(0)
// THE FIX: skip the poll rather than retargeting the user's real ~/.codex.
expect(duringLock).toEqual({ kind: 'skip' })
expect(
service.resolveCodexManagedAccountHomeForInactiveFetch(
store.getSettings().codexManagedAccounts[0]!
)
).toEqual({ kind: 'skip' })
expect(duringLock).not.toEqual({ kind: 'ready', codexHomePath: getSystemCodexHomePath() })
// THE FIX: the selection survives, and nothing was persisted.
expect(store.getSettings().activeCodexManagedAccountId).toBe('account-1')
expect(store.getSettings().activeCodexManagedAccountIdsByRuntime?.host).toBe('account-1')
expect(store.updateSettings).not.toHaveBeenCalled()
// Repeated evaluation must not accumulate into a destructive verdict.
expect(service.prepareForRateLimitFetch()).toEqual({ kind: 'skip' })
expect(service.prepareForRateLimitFetch()).toEqual({ kind: 'skip' })
expect(store.updateSettings).not.toHaveBeenCalled()
// Recovery is automatic: the next readable poll resolves normally.
lstatFaults.release(markerPath)
expect(service.prepareForRateLimitFetch()).toEqual({
kind: 'ready',
codexHomePath: managedHomePath
})
expect(
service.resolveCodexManagedAccountHomeForInactiveFetch(
store.getSettings().codexManagedAccounts[0]!
)
).toEqual({ kind: 'ready', homePath: managedHomePath })
expect(store.getSettings().activeCodexManagedAccountId).toBe('account-1')
expect(store.updateSettings).not.toHaveBeenCalled()
})
it('still clears the selection when the home is genuinely untrusted', async () => {
writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8')
const managedHomePath = createManagedAuth(
testState.userDataDir,
'account-1',
createCodexAuthJson('user@example.com', 'acct-1', 'refresh-1')
)
// A proven trust failure: the marker names a different account.
writeFileSync(join(managedHomePath, '.orca-managed-home'), 'someone-else\n', 'utf-8')
const store = createStore(
createSettings({
shellStartupEnvProbeSupported: true,
codexManagedAccounts: [
createCodexAccountRecord('account-1', 'user@example.com', 'acct-1', managedHomePath)
],
activeCodexManagedAccountId: 'account-1',
activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} }
})
)
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
const service = new CodexRuntimeHomeService(store as never)
// Why this case matters: the fix must not make the gate toothless. A
// successful observation that fails a trust check still deselects.
expect(service.prepareForRateLimitFetch()).toEqual({
kind: 'ready',
codexHomePath: getSystemCodexHomePath()
})
expect(store.getSettings().activeCodexManagedAccountId).toBeNull()
expect(store.updateSettings).toHaveBeenCalledTimes(1)
expect(
service.resolveCodexManagedAccountHomeForInactiveFetch(
store.getSettings().codexManagedAccounts[0]!
)
).toEqual({ kind: 'skip' })
})
})
@@ -0,0 +1,81 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { linkSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { ManagedCodexHomeTemporarilyUnavailableError } from '../codex-accounts/host-codex-managed-home-ownership'
import { prepareCodexAiVaultSessionResume } from './codex-ai-vault-session-resume'
describe('prepareCodexAiVaultSessionResume', () => {
let root: string
let peerHome: string
let selectedHome: string
let peerRolloutPath: string
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'orca-codex-ai-vault-resume-'))
peerHome = join(root, 'codex-accounts', 'account-a', 'home')
selectedHome = join(root, 'codex-accounts', 'account-b', 'home')
const relativeRolloutPath = join(
'sessions',
'2026',
'08',
'17',
'rollout-2026-08-17T10-00-00-session.jsonl'
)
peerRolloutPath = join(peerHome, relativeRolloutPath)
const selectedRolloutPath = join(selectedHome, relativeRolloutPath)
mkdirSync(dirname(peerRolloutPath), { recursive: true })
mkdirSync(dirname(selectedRolloutPath), { recursive: true })
writeFileSync(peerRolloutPath, '{"type":"session_meta"}\n', 'utf-8')
linkSync(peerRolloutPath, selectedRolloutPath)
})
afterEach(() => {
rmSync(root, { recursive: true, force: true })
})
it('refuses before falling through when selected-home trust is indeterminate', async () => {
const resolveSelectedHome = vi.fn((): string | null => {
throw new ManagedCodexHomeTemporarilyUnavailableError()
})
const isSystemDefaultRealHome = vi.fn(() => true)
await expect(prepare(resolveSelectedHome, isSystemDefaultRealHome)).rejects.toBeInstanceOf(
ManagedCodexHomeTemporarilyUnavailableError
)
expect(resolveSelectedHome).toHaveBeenCalledOnce()
expect(isSystemDefaultRealHome).not.toHaveBeenCalled()
})
it('repins an aliased rollout when the selected home is owned', async () => {
await expect(prepare(() => selectedHome)).resolves.toEqual({
useRealCodexHome: false,
substituteCodexHome: selectedHome
})
})
it('preserves deliberate no-selection behavior for a proven-untrusted home', async () => {
await expect(prepare(() => null)).resolves.toEqual({ useRealCodexHome: false })
})
function prepare(
resolveSelectedHome: () => string | null,
isSystemDefaultRealHome: () => boolean = () => false
) {
return prepareCodexAiVaultSessionResume(
{
agent: 'codex',
filePath: peerRolloutPath,
codexHome: peerHome,
executionHostId: 'local'
},
{
runtimeHome: {
isHostSystemDefaultRealHome: isSystemDefaultRealHome,
resolveSelectedHostAccountCodexHomePathForResume: resolveSelectedHome
},
systemCodexHomePath: join(root, 'system-codex-home')
}
)
}
})
@@ -0,0 +1,27 @@
import type {
AiVaultPrepareSessionResumeArgs,
AiVaultPrepareSessionResumeResult
} from '../../shared/ai-vault-resume-preparation'
import type { CodexRuntimeHomeService } from '../codex-accounts/runtime-home-service'
import { prepareLegacySharedCodexSessionResume } from './codex-legacy-session-resume'
type CodexAiVaultRuntimeHome = Pick<
CodexRuntimeHomeService,
'isHostSystemDefaultRealHome' | 'resolveSelectedHostAccountCodexHomePathForResume'
>
/** Keeps window and serve AI Vault resumes behind the same refusing account-home gate. */
export function prepareCodexAiVaultSessionResume(
args: AiVaultPrepareSessionResumeArgs,
options: {
runtimeHome: CodexAiVaultRuntimeHome | null
systemCodexHomePath: string | undefined
}
): Promise<AiVaultPrepareSessionResumeResult> {
return prepareLegacySharedCodexSessionResume(args, {
isHostSystemDefaultRealHome: () => options.runtimeHome?.isHostSystemDefaultRealHome() === true,
getSelectedHostAccountCodexHomePath: () =>
options.runtimeHome?.resolveSelectedHostAccountCodexHomePathForResume() ?? null,
systemCodexHomePath: options.systemCodexHomePath
})
}
+27 -12
View File
@@ -243,6 +243,7 @@ import {
stopCodexStateDbBackfillRecoveries
} from './codex/codex-state-db-backfill-recovery'
import { createCodexSessionMigrationScheduler } from './codex/codex-session-migration-scheduler'
import { prepareCodexAiVaultSessionResume } from './codex/codex-ai-vault-session-resume'
import { prepareLegacySharedCodexSessionResume } from './codex/codex-legacy-session-resume'
import { resolveHostCodexSessionSourceHome } from './codex/codex-session-source-home'
import type { CodexSessionResumePreparation } from './codex/codex-session-resume-home'
@@ -1073,6 +1074,9 @@ function prepareCodexRuntimeHomeForLaunch(
return true
}
let realHomeHooksPrepared = ensureRealHomeHooksIfSelected()
// Why: a ManagedCodexHomeTemporarilyUnavailableError must escape uncaught —
// the fallbacks below all key off `null`, which means "system default", so
// swallowing the refusal would launch the wrong account (#STA-4422).
let runtimeHomePath = codexRuntimeHome!.prepareForCodexLaunch(target, launchEnv, {
unavailableManagedHomePath: launchContext?.unavailableManagedHomePath
})
@@ -1145,6 +1149,15 @@ async function prepareCodexSessionResumeForLaunch(args: {
...codexRuntimeHome.getHostCodexHomePathsForSessionDiscovery()
]
const settingsStore = store
// Why: resolved eagerly, once, before any ranking or provenance match. The
// marker read used to be deferred into the ranking thunk so a
// provenance-present resume never paid for it, but that optimisation let an
// unreadable selected home reach the PTY as "no selection": the provenance
// branch simply omits the account from `trustedHomes` and another account's
// readable alias wins. A throw here refuses the whole resume instead
// (#STA-4422).
const selectedAccountCodexHome =
codexRuntimeHome.resolveSelectedHostAccountCodexHomePathForResume()
// Why: a `fresh` outcome must skip migration, trust and hook repair entirely — there is
// no verified origin home to prepare, so the PTY layer drops the resume argv (#10793).
const preparation = await prepareCodexSessionResume({
@@ -1153,8 +1166,7 @@ async function prepareCodexSessionResumeForLaunch(args: {
trustedCodexHomes: trustedHomes,
// Why: the legacy id rescan's winning home becomes this pane's CODEX_HOME, i.e. its account;
// rank it by the current selection so settings insertion order can never decide the account.
// Lazy: only the legacy branch ranks, so a provenance-present resume never stats the marker.
getSelectedAccountCodexHome: () => codexRuntimeHome!.getSelectedHostAccountCodexHomePath(),
getSelectedAccountCodexHome: () => selectedAccountCodexHome,
systemCodexHomePath: systemHomePath,
// Why: the mirror winning is what triggers the migration into ~/.codex below, so it must
// outrank the path-sorted account homes or a system-default selection resumes as an account.
@@ -1466,11 +1478,8 @@ function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {}): Brow
getAdditionalAiVaultCodexHomePaths: () =>
codexRuntimeHome ? codexRuntimeHome.getHostCodexHomePathsForSessionDiscovery() : [],
prepareAiVaultSessionResume: (args) =>
prepareLegacySharedCodexSessionResume(args, {
isHostSystemDefaultRealHome: () =>
codexRuntimeHome?.isHostSystemDefaultRealHome() === true,
getSelectedHostAccountCodexHomePath: () =>
codexRuntimeHome?.getSelectedHostAccountCodexHomePath() ?? null,
prepareCodexAiVaultSessionResume(args, {
runtimeHome: codexRuntimeHome,
systemCodexHomePath: resolveHostCodexSessionSourceHome(store!.getSettings())
}),
onBeforeRelaunch: async () => {
@@ -2528,7 +2537,15 @@ void app.whenReady().then(async () => {
)
return settings.codexManagedAccounts
.filter((account) => !activeIds.has(account.id))
.map((account) => ({ id: account.id, managedHomePath: account.managedHomePath }))
.map((account) => ({
id: account.id,
resolveHome: () => {
const resolved = codexRuntimeHome!.resolveCodexManagedAccountHomeForInactiveFetch(account)
return resolved.kind === 'ready'
? { kind: 'ready' as const, managedHomePath: resolved.homePath }
: { kind: 'skip' as const }
}
}))
})
const orchestrationEnvironmentTransport: OrchestrationEnvironmentTransport = {
resolve: (selector) => {
@@ -2589,10 +2606,8 @@ void app.whenReady().then(async () => {
getAdditionalAiVaultCodexHomePaths: () =>
codexRuntimeHome ? codexRuntimeHome.getHostCodexHomePathsForSessionDiscovery() : [],
prepareAiVaultSessionResume: (args) =>
prepareLegacySharedCodexSessionResume(args, {
isHostSystemDefaultRealHome: () => codexRuntimeHome?.isHostSystemDefaultRealHome() === true,
getSelectedHostAccountCodexHomePath: () =>
codexRuntimeHome?.getSelectedHostAccountCodexHomePath() ?? null,
prepareCodexAiVaultSessionResume(args, {
runtimeHome: codexRuntimeHome,
systemCodexHomePath: resolveHostCodexSessionSourceHome(store!.getSettings())
}),
buildAgentHookPtyEnv: () =>
+6 -2
View File
@@ -38,8 +38,12 @@ export function registerCodexAccountHandlers(
ipcMain.handle('codexAccounts:add', (_event, args?: CodexAccountAddTarget) =>
codexAccounts.addAccount(args)
)
ipcMain.handle('codexAccounts:reauthenticate', (_event, args: { accountId: string }) =>
codexAccounts.reauthenticateAccount(args.accountId)
ipcMain.handle(
'codexAccounts:reauthenticate',
(_event, args: { accountId: string; activateIfSelectionWasEmpty?: boolean }) =>
codexAccounts.reauthenticateAccount(args.accountId, {
activateIfSelectionWasEmpty: args.activateIfSelectionWasEmpty === true
})
)
ipcMain.handle('codexAccounts:remove', (_event, args: { accountId: string }) =>
codexAccounts.removeAccount(args.accountId)
+25 -1
View File
@@ -25,9 +25,15 @@ import type { CodexConfigSyncStatus } from '../../shared/codex-config-sync-types
let root: string
function invokeHandler(mirroredHome: string | null): CodexConfigSyncStatus {
return invokeHandlerWithStatus({ kind: 'ready', homePath: mirroredHome })
}
function invokeHandlerWithStatus(
mirrored: { kind: 'ready'; homePath: string | null } | { kind: 'unavailable' }
): CodexConfigSyncStatus {
handleMock.mockClear()
registerCodexConfigSyncHandlers({
getMirroredHostHomePathForStatus: () => mirroredHome
getMirroredHostHomePathForStatus: () => mirrored
})
const handler = handleMock.mock.calls.at(-1)?.[1] as () => CodexConfigSyncStatus
return handler()
@@ -78,3 +84,21 @@ describe('codexConfigSync:status handler', () => {
expect(removeHandlerMock).toHaveBeenCalledWith('codexConfigSync:status')
})
})
// STA-4422: an unreadable managed home must not be reported as healthy. Before
// the fix the resolver collapsed to `null`, which this channel reads as "no
// mirror exists" and reports as synced.
it('reports a managed-home-unavailable stall instead of synced when the home is unreadable', () => {
// Anchor: a genuine no-mirror lane still reports synced.
expect(invokeHandlerWithStatus({ kind: 'ready', homePath: null })).toEqual({
state: 'synced',
reason: null,
systemConfigPath: join(root, '.codex', 'config.toml')
})
expect(invokeHandlerWithStatus({ kind: 'unavailable' })).toEqual({
state: 'stalled',
reason: 'managed-home-unavailable',
systemConfigPath: join(root, '.codex', 'config.toml')
})
})
+13 -2
View File
@@ -3,10 +3,11 @@ import { join } from 'node:path'
import { getSystemCodexHomePath } from '../codex/codex-home-paths'
import { getCodexConfigSyncStatus } from '../codex/config-sync-stall'
import type { CodexConfigSyncStatus } from '../../shared/codex-config-sync-types'
import type { CodexMirroredHomeStatus } from '../codex-accounts/runtime-home-service'
/** The read-only slice of the runtime home service this channel needs. */
type CodexMirroredHomeResolver = {
getMirroredHostHomePathForStatus: () => string | null
getMirroredHostHomePathForStatus: () => CodexMirroredHomeStatus
}
/** Registers the read-only IPC channel the settings pane reads once per mount for Codex config sync health. */
@@ -14,7 +15,17 @@ export function registerCodexConfigSyncHandlers(runtimeHome: CodexMirroredHomeRe
ipcMain.removeHandler('codexConfigSync:status')
ipcMain.handle('codexConfigSync:status', (): CodexConfigSyncStatus => {
const systemHomePath = getSystemCodexHomePath()
const runtimeHomePath = runtimeHome.getMirroredHostHomePathForStatus()
const mirrored = runtimeHome.getMirroredHostHomePathForStatus()
if (mirrored.kind === 'unavailable') {
// Why: do not throw — the settings pane catches thrown status errors and
// would show nothing at all. Report the stall so the user sees why.
return {
state: 'stalled',
reason: 'managed-home-unavailable',
systemConfigPath: join(systemHomePath, 'config.toml')
}
}
const runtimeHomePath = mirrored.homePath
if (!runtimeHomePath) {
// Why: the system default runs Codex directly against ~/.codex, so there
// is no mirror that can fall behind. Reporting on the shared home here
@@ -0,0 +1,359 @@
import { describe, expect, it, vi } from 'vitest'
import { readFileSyncMock } from './pty-ipc-mock-registry'
import { TEST_CODEX_HOME, TEST_CODEX_AUTH_JSON } from './pty-ipc-test-constants'
import { setupPtyIpcSuite } from './pty-ipc-test-harness'
import { createDaemonActiveProviderFixtures } from './pty-ipc-daemon-provider-fixtures'
import { ManagedCodexHomeTemporarilyUnavailableError } from '../codex-accounts/host-codex-managed-home-ownership'
import { registerPtyHandlers } from './pty'
vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock()))
vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock()))
vi.mock('node-pty', () => import('./pty-ipc-mock-registry').then((m) => m.nodePtyModuleMock()))
vi.mock('node:child_process', async (importOriginal) =>
(await import('./pty-ipc-mock-registry')).childProcessModuleMock(await importOriginal())
)
vi.mock('../opencode/hook-service', () =>
import('./pty-ipc-mock-registry').then((m) => m.openCodeHookServiceModuleMock())
)
vi.mock('../mimo/hook-service', () =>
import('./pty-ipc-mock-registry').then((m) => m.mimoHookServiceModuleMock())
)
vi.mock('../agent-hooks/server', () =>
import('./pty-ipc-mock-registry').then((m) => m.agentHookServerModuleMock())
)
vi.mock('../pi/titlebar-extension-service', () =>
import('./pty-ipc-mock-registry').then((m) => m.piTitlebarExtensionModuleMock())
)
vi.mock('../pwsh', () => import('./pty-ipc-mock-registry').then((m) => m.pwshModuleMock()))
vi.mock('../wsl', async (importOriginal) =>
(await import('./pty-ipc-mock-registry')).wslModuleMock(await importOriginal())
)
vi.mock('../telemetry/client', () =>
import('./pty-ipc-mock-registry').then((m) => m.telemetryClientModuleMock())
)
vi.mock('../telemetry/classify-error', () =>
import('./pty-ipc-mock-registry').then((m) => m.classifyErrorModuleMock())
)
vi.mock('../cli/linux-terminal-orca-cli-shim', () =>
import('./pty-ipc-mock-registry').then((m) => m.linuxCliShimModuleMock())
)
vi.mock('../memory/pty-registry', () =>
import('./pty-ipc-mock-registry').then((m) => m.ptyRegistryModuleMock())
)
vi.mock('../agent-hooks/migration-unsupported-pty-state', () =>
import('./pty-ipc-mock-registry').then((m) => m.migrationUnsupportedPtyModuleMock())
)
vi.mock('../codex/codex-pane-account-registry', () =>
import('./pty-ipc-mock-registry').then((m) => m.codexPaneAccountRegistryModuleMock())
)
vi.mock('../codex/codex-state-db-backfill-recovery', () =>
import('./pty-ipc-mock-registry').then((m) => m.codexBackfillRecoveryModuleMock())
)
const UNAVAILABLE_MESSAGE = 'Codex account files are temporarily locked. Retry in a moment.'
const MANAGED_ACCOUNT_SETTINGS = (() =>
({
codexManagedAccounts: [
{
id: 'account-1',
managedHomePath: TEST_CODEX_HOME,
managedHomeRuntime: 'host'
}
]
}) as never) as () => never
type RuntimeSpawnController = {
spawn(args: { cols: number; rows: number; launchAgent: 'codex' }): Promise<{ id: string }>
}
/**
* #STA-4422: launch prep refuses an unreadable managed home by throwing, because
* `null` already means "launch the system default". A refusal that reached
* either spawn path as `null` would start the pane on the user's real ~/.codex
* while the UI still shows the managed account, so every assertion here pairs
* the rejection with a zero-spawn check — existing suites prove a `null`
* re-resolution deliberately launches with no CODEX_HOME.
*/
describe('registerPtyHandlers Codex launch refusal on an unreadable managed home', () => {
const { handlers, mainWindow } = setupPtyIpcSuite()
const { setupDaemonAdapter } = createDaemonActiveProviderFixtures({ handlers, mainWindow })
function makeRuntime() {
return {
setPtyController: vi.fn(),
registerPty: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
}
function register(
resolveHome: (
target?: unknown,
env?: NodeJS.ProcessEnv,
context?: { unavailableManagedHomePath?: string }
) => string | null,
runtime?: ReturnType<typeof makeRuntime>,
prepareCodexSessionResume?: () => Promise<never>
): void {
handlers.clear()
registerPtyHandlers(
mainWindow as never,
runtime as never,
resolveHome,
MANAGED_ACCOUNT_SETTINGS,
undefined,
undefined,
prepareCodexSessionResume ? { prepareCodexSessionResume } : undefined
)
}
const RESUME_SESSION = {
key: 'session_id' as const,
id: 'resume-session-1',
transcriptPath: '/tmp/orca-codex-rollout.jsonl'
}
function makeAuthUnreadable(): void {
readFileSyncMock.mockImplementation((filePath: string) => {
if (filePath.endsWith('auth.json')) {
throw Object.assign(new Error('missing auth'), { code: 'ENOENT' })
}
return ''
})
}
describe('local pty:spawn handler', () => {
it('spawns with the managed CODEX_HOME when the home reads back cleanly', async () => {
readFileSyncMock.mockReturnValue(TEST_CODEX_AUTH_JSON)
const daemonSpawn = setupDaemonAdapter()
register(() => TEST_CODEX_HOME)
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, launchAgent: 'codex' })
expect(daemonSpawn).toHaveBeenCalledOnce()
expect(daemonSpawn.mock.calls[0]?.[0].env).toMatchObject({ CODEX_HOME: TEST_CODEX_HOME })
})
it('refuses the spawn when the first home resolution is indeterminate', async () => {
readFileSyncMock.mockReturnValue(TEST_CODEX_AUTH_JSON)
const daemonSpawn = setupDaemonAdapter()
const resolveHome = vi.fn((): string | null => {
throw new ManagedCodexHomeTemporarilyUnavailableError()
})
register(resolveHome)
await expect(
handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, launchAgent: 'codex' })
).rejects.toThrow(UNAVAILABLE_MESSAGE)
expect(resolveHome).toHaveBeenCalledOnce()
expect(daemonSpawn).not.toHaveBeenCalled()
})
it('refuses the spawn when the post-auth-wait re-resolution is indeterminate', async () => {
vi.useFakeTimers()
makeAuthUnreadable()
const daemonSpawn = setupDaemonAdapter()
const resolveHome = vi.fn(
(
_target?: unknown,
_env?: NodeJS.ProcessEnv,
context?: { unavailableManagedHomePath?: string }
): string | null => {
if (context?.unavailableManagedHomePath) {
throw new ManagedCodexHomeTemporarilyUnavailableError()
}
return TEST_CODEX_HOME
}
)
register(resolveHome)
const spawnPromise = handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
launchAgent: 'codex'
})
const rejection = expect(spawnPromise).rejects.toThrow(UNAVAILABLE_MESSAGE)
await vi.advanceTimersByTimeAsync(2_000)
await rejection
// Why: the first resolution must have succeeded, or the rejection would
// prove nothing about the re-resolution this test exists for.
expect(resolveHome).toHaveBeenCalledTimes(2)
expect(resolveHome.mock.calls[1]?.[2]).toMatchObject({
unavailableManagedHomePath: TEST_CODEX_HOME
})
expect(daemonSpawn).not.toHaveBeenCalled()
})
it('refuses the spawn when the current-selection re-read is indeterminate after auth recovers', async () => {
vi.useFakeTimers()
let authReady = false
readFileSyncMock.mockImplementation((filePath: string) => {
if (!filePath.endsWith('auth.json')) {
return ''
}
if (!authReady) {
throw Object.assign(new Error('missing auth'), { code: 'ENOENT' })
}
return TEST_CODEX_AUTH_JSON
})
const daemonSpawn = setupDaemonAdapter()
const resolveHome = vi.fn((): string | null => {
if (resolveHome.mock.calls.length > 1) {
throw new ManagedCodexHomeTemporarilyUnavailableError()
}
return TEST_CODEX_HOME
})
register(resolveHome)
const spawnPromise = handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
launchAgent: 'codex'
})
const rejection = expect(spawnPromise).rejects.toThrow(UNAVAILABLE_MESSAGE)
await vi.advanceTimersByTimeAsync(0)
expect(daemonSpawn).not.toHaveBeenCalled()
authReady = true
await vi.advanceTimersByTimeAsync(25)
await rejection
expect(resolveHome).toHaveBeenCalledTimes(2)
expect(daemonSpawn).not.toHaveBeenCalled()
})
// Why (#STA-4422 P1g): the eager selected-home gate rejects the awaited
// resume preparation. That rejection has to reach the spawn, not be
// downgraded to "no resume" — a resume that proceeds picks its CODEX_HOME
// from whichever account's alias stayed readable.
it('refuses the spawn when the eager resume gate rejects', async () => {
readFileSyncMock.mockReturnValue(TEST_CODEX_AUTH_JSON)
const daemonSpawn = setupDaemonAdapter()
register(
() => TEST_CODEX_HOME,
undefined,
() => Promise.reject(new ManagedCodexHomeTemporarilyUnavailableError())
)
await expect(
handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
launchAgent: 'codex',
resumeProviderSession: RESUME_SESSION
})
).rejects.toThrow(UNAVAILABLE_MESSAGE)
expect(daemonSpawn).not.toHaveBeenCalled()
})
it('spawns a resume when the eager resume gate resolves', async () => {
readFileSyncMock.mockReturnValue(TEST_CODEX_AUTH_JSON)
const daemonSpawn = setupDaemonAdapter()
register(() => TEST_CODEX_HOME, undefined, (() =>
Promise.resolve(null)) as unknown as () => Promise<never>)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
launchAgent: 'codex',
resumeProviderSession: RESUME_SESSION
})
expect(daemonSpawn).toHaveBeenCalledOnce()
})
})
describe('runtime pty controller spawn', () => {
it('spawns with the managed CODEX_HOME when the home reads back cleanly', async () => {
readFileSyncMock.mockReturnValue(TEST_CODEX_AUTH_JSON)
const daemonSpawn = setupDaemonAdapter()
const runtime = makeRuntime()
register(() => TEST_CODEX_HOME, runtime)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as RuntimeSpawnController
await controller.spawn({ cols: 80, rows: 24, launchAgent: 'codex' })
expect(daemonSpawn).toHaveBeenCalledOnce()
expect(daemonSpawn.mock.calls[0]?.[0].env).toMatchObject({ CODEX_HOME: TEST_CODEX_HOME })
})
it('refuses the spawn when the first home resolution is indeterminate', async () => {
readFileSyncMock.mockReturnValue(TEST_CODEX_AUTH_JSON)
const daemonSpawn = setupDaemonAdapter()
const runtime = makeRuntime()
const resolveHome = vi.fn((): string | null => {
throw new ManagedCodexHomeTemporarilyUnavailableError()
})
register(resolveHome, runtime)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as RuntimeSpawnController
await expect(controller.spawn({ cols: 80, rows: 24, launchAgent: 'codex' })).rejects.toThrow(
UNAVAILABLE_MESSAGE
)
expect(resolveHome).toHaveBeenCalledOnce()
expect(daemonSpawn).not.toHaveBeenCalled()
})
it('refuses the spawn when the post-auth-wait re-resolution is indeterminate', async () => {
vi.useFakeTimers()
makeAuthUnreadable()
const daemonSpawn = setupDaemonAdapter()
const runtime = makeRuntime()
const resolveHome = vi.fn(
(
_target?: unknown,
_env?: NodeJS.ProcessEnv,
context?: { unavailableManagedHomePath?: string }
): string | null => {
if (context?.unavailableManagedHomePath) {
throw new ManagedCodexHomeTemporarilyUnavailableError()
}
return TEST_CODEX_HOME
}
)
register(resolveHome, runtime)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as RuntimeSpawnController
const spawnPromise = controller.spawn({ cols: 80, rows: 24, launchAgent: 'codex' })
const rejection = expect(spawnPromise).rejects.toThrow(UNAVAILABLE_MESSAGE)
await vi.advanceTimersByTimeAsync(2_000)
await rejection
expect(resolveHome).toHaveBeenCalledTimes(2)
expect(resolveHome.mock.calls[1]?.[2]).toMatchObject({
unavailableManagedHomePath: TEST_CODEX_HOME
})
expect(daemonSpawn).not.toHaveBeenCalled()
})
it('refuses the spawn when the eager resume gate rejects', async () => {
readFileSyncMock.mockReturnValue(TEST_CODEX_AUTH_JSON)
const daemonSpawn = setupDaemonAdapter()
const runtime = makeRuntime()
register(
() => TEST_CODEX_HOME,
runtime,
() => Promise.reject(new ManagedCodexHomeTemporarilyUnavailableError())
)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as RuntimeSpawnController
await expect(
controller.spawn({
cols: 80,
rows: 24,
launchAgent: 'codex',
resumeProviderSession: RESUME_SESSION
} as never)
).rejects.toThrow(UNAVAILABLE_MESSAGE)
expect(daemonSpawn).not.toHaveBeenCalled()
})
})
})
+59 -28
View File
@@ -212,6 +212,7 @@ import {
isCodexHomeAuthReadyForLaunch,
waitForManagedCodexAuthReady
} from '../codex-accounts/managed-codex-auth-readiness'
import { ManagedCodexHomeTemporarilyUnavailableError } from '../codex-accounts/host-codex-managed-home-ownership'
import {
forgetCodexPaneAccount,
getCodexPaneAccount,
@@ -1344,6 +1345,26 @@ const MANAGED_CODEX_AUTH_UNAVAILABLE_MESSAGE =
'The selected Codex account credentials are temporarily unavailable. Try opening the terminal again.'
const CODEX_RESUME_AUTH_UNAVAILABLE_MESSAGE =
'The Codex account credentials for this session are temporarily unavailable. Try opening the terminal again.'
const MANAGED_CODEX_HOME_UNAVAILABLE_MESSAGE =
'Codex account files are temporarily locked. Retry in a moment.'
/**
* Why: launch prep refuses an unreadable managed home by throwing instead of
* returning `null`, because `null` already means "launch the system default".
* Turn that refusal into the same shape as the auth-unavailable refusal above
* a user-facing spawn rejection so the pane never starts on another account's
* credentials (#STA-4422).
*/
function resolveSelectedCodexHomeOrRefuseSpawn(resolve: () => string | null): string | null {
try {
return resolve()
} catch (error) {
if (error instanceof ManagedCodexHomeTemporarilyUnavailableError) {
throw new Error(MANAGED_CODEX_HOME_UNAVAILABLE_MESSAGE, { cause: error })
}
throw error
}
}
type ManagedCodexAuthResolutionArgs = {
selectedCodexHomePath: string | null
@@ -4686,7 +4707,7 @@ export function registerPtyHandlers(
if (args.preAllocatedHandle) {
env = { ...env, ORCA_TERMINAL_HANDLE: args.preAllocatedHandle }
}
let selectedCodexHomePath =
let selectedCodexHomePath = resolveSelectedCodexHomeOrRefuseSpawn(() =>
!preAdoptedStablePane && !args.connectionId
? getCompatibleSelectedCodexHomePath(
codexSelectionTarget,
@@ -4706,6 +4727,7 @@ export function registerPtyHandlers(
}) ?? null)
)
: null
)
if (
!preAdoptedStablePane &&
args.launchAgent === 'codex' &&
@@ -4717,21 +4739,25 @@ export function registerPtyHandlers(
requiredCodexHomePath: codexResumeHome?.codexHomePath,
target: codexSelectionTarget,
resolveCurrent: () =>
getCompatibleSelectedCodexHomePath(
codexSelectionTarget,
getSelectedCodexHomePath?.(codexSelectionTarget, env, {
workspacePath: cwd,
launchAgent: 'codex'
}) ?? null
resolveSelectedCodexHomeOrRefuseSpawn(() =>
getCompatibleSelectedCodexHomePath(
codexSelectionTarget,
getSelectedCodexHomePath?.(codexSelectionTarget, env, {
workspacePath: cwd,
launchAgent: 'codex'
}) ?? null
)
),
resolveAfterUnavailable: (unavailableManagedHomePath) =>
getCompatibleSelectedCodexHomePath(
codexSelectionTarget,
getSelectedCodexHomePath?.(codexSelectionTarget, env, {
workspacePath: cwd,
launchAgent: 'codex',
unavailableManagedHomePath
}) ?? null
resolveSelectedCodexHomeOrRefuseSpawn(() =>
getCompatibleSelectedCodexHomePath(
codexSelectionTarget,
getSelectedCodexHomePath?.(codexSelectionTarget, env, {
workspacePath: cwd,
launchAgent: 'codex',
unavailableManagedHomePath
}) ?? null
)
)
})
selectedCodexHomePath = resolution instanceof Promise ? await resolution : resolution
@@ -6417,7 +6443,7 @@ export function registerPtyHandlers(
// Why: declared after the strip so a local-provider spawn cannot capture the
// pre-strip env — only the daemon branch below re-derives this from baseEnv.
let env: Record<string, string> | undefined = baseEnv
let selectedCodexHomePath =
let selectedCodexHomePath = resolveSelectedCodexHomeOrRefuseSpawn(() =>
!preAdoptedStablePane && !args.connectionId
? getCompatibleSelectedCodexHomePath(
codexSelectionTarget,
@@ -6437,6 +6463,7 @@ export function registerPtyHandlers(
}) ?? null)
)
: null
)
if (!preAdoptedStablePane && args.launchAgent === 'codex' && args.sessionId === undefined) {
const resolution = resolveCodexHomeAfterManagedAuthReadiness({
selectedCodexHomePath,
@@ -6444,21 +6471,25 @@ export function registerPtyHandlers(
requiredCodexHomePath: codexResumeHome?.codexHomePath,
target: codexSelectionTarget,
resolveCurrent: () =>
getCompatibleSelectedCodexHomePath(
codexSelectionTarget,
getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv, {
workspacePath: cwd,
launchAgent: 'codex'
}) ?? null
resolveSelectedCodexHomeOrRefuseSpawn(() =>
getCompatibleSelectedCodexHomePath(
codexSelectionTarget,
getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv, {
workspacePath: cwd,
launchAgent: 'codex'
}) ?? null
)
),
resolveAfterUnavailable: (unavailableManagedHomePath) =>
getCompatibleSelectedCodexHomePath(
codexSelectionTarget,
getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv, {
workspacePath: cwd,
launchAgent: 'codex',
unavailableManagedHomePath
}) ?? null
resolveSelectedCodexHomeOrRefuseSpawn(() =>
getCompatibleSelectedCodexHomePath(
codexSelectionTarget,
getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv, {
workspacePath: cwd,
launchAgent: 'codex',
unavailableManagedHomePath
}) ?? null
)
)
})
selectedCodexHomePath = resolution instanceof Promise ? await resolution : resolution
@@ -58,7 +58,10 @@ describe('RateLimitService', () => {
const wslCodexHome =
'\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home'
const hostCodexHome = 'C:\\Users\\jin\\.orca\\codex-accounts\\host\\home'
const resolver = vi.fn((target) => (target?.runtime === 'wsl' ? wslCodexHome : hostCodexHome))
const resolver = vi.fn((target) => ({
kind: 'ready' as const,
codexHomePath: target?.runtime === 'wsl' ? wslCodexHome : hostCodexHome
}))
service.setCodexHomePathResolver(resolver)
vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now()))
@@ -71,10 +74,62 @@ describe('RateLimitService', () => {
)
})
it.each([
[
'account-change refresh',
(service: RateLimitService) => service.refreshForCodexAccountChange(null, { runtime: 'host' })
],
[
'target refresh',
(service: RateLimitService) => service.refreshCodexForTarget({ runtime: 'host' })
]
])('settles %s when managed-home resolution skips before the fetch', async (_label, refresh) => {
const service = new RateLimitService()
service.setCodexHomePathResolver(() => ({ kind: 'skip' }))
await refresh(service)
expect(fetchCodexRateLimits).not.toHaveBeenCalled()
expect(service.getState().codex).toBeNull()
})
it('settles without applying a result when the managed home becomes unavailable mid-fetch', async () => {
const service = new RateLimitService()
const resolver = vi
.fn()
.mockReturnValueOnce({ kind: 'ready', codexHomePath: '/tmp/codex-home' })
.mockReturnValue({ kind: 'skip' })
service.setCodexHomePathResolver(resolver)
vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now()))
await service.refreshForCodexAccountChange(null, { runtime: 'host' })
expect(resolver).toHaveBeenCalledTimes(2)
expect(fetchCodexRateLimits).toHaveBeenCalledOnce()
expect(service.getState().codex).toBeNull()
})
it('settles the Codex slot when its home becomes unavailable during a full refresh', async () => {
const service = new RateLimitService()
service.setCodexHomePathResolver(
vi
.fn()
.mockReturnValueOnce({ kind: 'ready', codexHomePath: '/tmp/codex-home' })
.mockReturnValue({ kind: 'skip' })
)
vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now()))
vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now()))
await service.refresh()
expect(fetchCodexRateLimits).toHaveBeenCalledOnce()
expect(service.getState().codex).toBeNull()
})
it('reuses a caller-provided idempotency key when consuming a Codex reset credit', async () => {
const service = new RateLimitService()
const idempotencyKey = '11111111-1111-4111-8111-111111111111'
service.setCodexHomePathResolver(() => '/tmp/codex-home')
service.setCodexHomePathResolver(() => ({ kind: 'ready', codexHomePath: '/tmp/codex-home' }))
vi.mocked(consumeCodexRateLimitResetCredit).mockResolvedValueOnce('reset')
vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 0, Date.now()))
@@ -104,7 +159,7 @@ describe('RateLimitService', () => {
)
vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 0, Date.now()))
service.setCodexHomePathResolver(() => '/tmp/new-selection')
service.setCodexHomePathResolver(() => ({ kind: 'ready', codexHomePath: '/tmp/new-selection' }))
const pending = service.consumeCodexRateLimitResetCredit({
idempotencyKey,
target: { runtime: 'host', wslDistro: null },
@@ -139,9 +194,10 @@ describe('RateLimitService', () => {
const service = new RateLimitService()
const idempotencyKey = '33333333-3333-4333-8333-333333333333'
const hostRefresh = deferred<ProviderRateLimits>()
service.setCodexHomePathResolver((target) =>
target?.runtime === 'wsl' ? '/tmp/wsl-selection' : '/tmp/approved-selection'
)
service.setCodexHomePathResolver((target) => ({
kind: 'ready',
codexHomePath: target?.runtime === 'wsl' ? '/tmp/wsl-selection' : '/tmp/approved-selection'
}))
vi.mocked(consumeCodexRateLimitResetCredit).mockResolvedValueOnce('reset')
vi.mocked(fetchCodexRateLimits)
.mockReturnValueOnce(hostRefresh.promise)
@@ -173,7 +229,10 @@ describe('RateLimitService', () => {
it('does not let an older full refresh overwrite the post-reset Codex state', async () => {
const service = new RateLimitService()
const slowClaude = deferred<ProviderRateLimits>()
service.setCodexHomePathResolver(() => '/tmp/approved-selection')
service.setCodexHomePathResolver(() => ({
kind: 'ready',
codexHomePath: '/tmp/approved-selection'
}))
vi.mocked(fetchClaudeRateLimits).mockReturnValueOnce(slowClaude.promise)
vi.mocked(fetchCodexRateLimits)
.mockResolvedValueOnce(okProvider('codex', 100, Date.now()))
@@ -201,7 +260,10 @@ describe('RateLimitService', () => {
const wslCodexHome =
'\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home'
const hostCodexHome = 'C:\\Users\\jin\\.orca\\codex-accounts\\host\\home'
const resolver = vi.fn((target) => (target?.runtime === 'wsl' ? wslCodexHome : hostCodexHome))
const resolver = vi.fn((target) => ({
kind: 'ready' as const,
codexHomePath: target?.runtime === 'wsl' ? wslCodexHome : hostCodexHome
}))
service.setCodexHomePathResolver(resolver)
service.setCodexFetchTarget({ runtime: 'wsl', wslDistro: 'Ubuntu' })
@@ -218,7 +280,7 @@ describe('RateLimitService', () => {
it('does not fetch host Codex usage when WSL home resolution fails', async () => {
const service = new RateLimitService()
const resolver = vi.fn(() => null)
const resolver = vi.fn(() => ({ kind: 'ready' as const, codexHomePath: null }))
service.setCodexHomePathResolver(resolver)
service.setCodexFetchTarget({ runtime: 'wsl', wslDistro: 'Ubuntu' })
@@ -352,9 +414,10 @@ describe('RateLimitService', () => {
const wslCodexHome =
'\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home'
const hostCodexHome = 'C:\\Users\\jin\\.orca\\codex-accounts\\host\\home'
service.setCodexHomePathResolver((target) =>
target?.runtime === 'wsl' ? wslCodexHome : hostCodexHome
)
service.setCodexHomePathResolver((target) => ({
kind: 'ready',
codexHomePath: target?.runtime === 'wsl' ? wslCodexHome : hostCodexHome
}))
vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now()))
vi.mocked(fetchCodexRateLimits)
@@ -375,7 +438,7 @@ describe('RateLimitService', () => {
it('caches an outgoing weekly-only Codex account so the switcher keeps its inline bars', async () => {
const service = new RateLimitService()
service.setInactiveCodexAccountsResolver(() => [
{ id: 'account-weekly', managedHomePath: '/tmp/account-weekly/home' }
inactiveCodexAccount('account-weekly', '/tmp/account-weekly/home')
])
const weeklyOnly: ProviderRateLimits = {
@@ -410,7 +473,7 @@ describe('RateLimitService', () => {
it('does not cache an outgoing Codex account that has no usage windows', async () => {
const service = new RateLimitService()
service.setInactiveCodexAccountsResolver(() => [
{ id: 'account-empty', managedHomePath: '/tmp/account-empty/home' }
inactiveCodexAccount('account-empty', '/tmp/account-empty/home')
])
vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now()))
@@ -464,3 +527,10 @@ describe('RateLimitService', () => {
)
})
})
function inactiveCodexAccount(id: string, managedHomePath: string) {
return {
id,
resolveHome: () => ({ kind: 'ready' as const, managedHomePath })
}
}
@@ -10,6 +10,13 @@ import {
resetRateLimitProviderMocks
} from './rate-limit-service-test-harness'
function inactiveCodexAccount(id: string, managedHomePath: string) {
return {
id,
resolveHome: () => ({ kind: 'ready' as const, managedHomePath })
}
}
vi.mock('./claude-fetcher', () => ({
fetchClaudeRateLimits: vi.fn(),
fetchManagedAccountUsage: vi.fn()
@@ -84,7 +91,7 @@ describe('RateLimitService', () => {
it('aborts inactive Codex preview fetches on stop', async () => {
const service = new RateLimitService()
const account = { id: 'account-1', managedHomePath: '/tmp/account-1/home' }
const account = inactiveCodexAccount('account-1', '/tmp/account-1/home')
const capturedSignals: { codex?: AbortSignal } = {}
service.setInactiveCodexAccountsResolver(() => [account])
vi.mocked(fetchCodexRateLimits).mockImplementation(
@@ -116,7 +123,7 @@ describe('RateLimitService', () => {
const wslCodexHome =
'\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home'
service.setInactiveCodexAccountsResolver(() => [
{ id: 'account-1', managedHomePath: wslCodexHome }
inactiveCodexAccount('account-1', wslCodexHome)
])
vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 33, Date.now()))
@@ -142,6 +149,52 @@ describe('RateLimitService', () => {
])
})
it('skips an unavailable inactive home without dropping its cache and recovers later', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-08-17T12:00:00Z'))
try {
const service = new RateLimitService()
let unavailable = false
service.setInactiveCodexAccountsResolver(() => [
{
id: 'account-1',
resolveHome: () =>
unavailable
? { kind: 'skip' as const }
: { kind: 'ready' as const, managedHomePath: '/tmp/account-1/home' }
}
])
vi.mocked(fetchCodexRateLimits)
.mockResolvedValueOnce(okProvider('codex', 33, Date.now()))
.mockResolvedValueOnce(okProvider('codex', 67, Date.now()))
await service.fetchInactiveCodexAccountsOnOpen()
unavailable = true
await vi.advanceTimersByTimeAsync(10 * 60_000)
await service.fetchInactiveCodexAccountsOnOpen()
expect(fetchCodexRateLimits).toHaveBeenCalledOnce()
expect(service.getState().inactiveCodexAccounts).toEqual([
expect.objectContaining({
accountId: 'account-1',
isFetching: false,
rateLimits: expect.objectContaining({
session: expect.objectContaining({ usedPercent: 33 })
})
})
])
unavailable = false
await vi.advanceTimersByTimeAsync(10 * 60_000)
await service.fetchInactiveCodexAccountsOnOpen()
expect(fetchCodexRateLimits).toHaveBeenCalledTimes(2)
expect(service.getState().inactiveCodexAccounts[0]?.rateLimits?.session?.usedPercent).toBe(67)
} finally {
vi.useRealTimers()
}
})
it('allows usage-panel Fable supplements for inactive Claude account previews', async () => {
const service = new RateLimitService()
const account = { id: 'account-1', managedAuthPath: '/tmp/account-1/auth' }
@@ -181,7 +234,7 @@ describe('RateLimitService', () => {
const service = new RateLimitService()
const accountFetch = deferred<ProviderRateLimits>()
service.setInactiveCodexAccountsResolver(() => [
{ id: 'account-1', managedHomePath: '/tmp/account-1/home' }
inactiveCodexAccount('account-1', '/tmp/account-1/home')
])
vi.mocked(fetchCodexRateLimits).mockReturnValueOnce(accountFetch.promise)
@@ -199,8 +252,8 @@ describe('RateLimitService', () => {
const service = new RateLimitService()
const accountFetch = deferred<ProviderRateLimits>()
let inactiveAccounts = [
{ id: 'account-a', managedHomePath: '/tmp/account-a/home' },
{ id: 'account-b', managedHomePath: '/tmp/account-b/home' }
inactiveCodexAccount('account-a', '/tmp/account-a/home'),
inactiveCodexAccount('account-b', '/tmp/account-b/home')
]
service.setInactiveCodexAccountsResolver(() => inactiveAccounts)
vi.mocked(fetchCodexRateLimits).mockReturnValueOnce(accountFetch.promise)
@@ -208,11 +261,10 @@ describe('RateLimitService', () => {
const fetchOnOpen = service.fetchInactiveCodexAccountsOnOpen()
await Promise.resolve()
expect(service.getState().inactiveCodexAccounts).toEqual([
{ accountId: 'account-a', rateLimits: null, updatedAt: 0, isFetching: true },
{ accountId: 'account-b', rateLimits: null, updatedAt: 0, isFetching: true }
{ accountId: 'account-a', rateLimits: null, updatedAt: 0, isFetching: true }
])
inactiveAccounts = [{ id: 'account-a', managedHomePath: '/tmp/account-a/home' }]
inactiveAccounts = [inactiveCodexAccount('account-a', '/tmp/account-a/home')]
service.evictInactiveCodexCache('account-b')
accountFetch.resolve(okProvider('codex', 64, Date.now()))
await fetchOnOpen
@@ -234,9 +286,12 @@ describe('RateLimitService', () => {
it('does not recache an inactive Codex account that becomes active during fetch-on-open', async () => {
const service = new RateLimitService()
const accountFetch = deferred<ProviderRateLimits>()
let inactiveAccounts = [{ id: 'account-b', managedHomePath: '/tmp/account-b/home' }]
let inactiveAccounts = [inactiveCodexAccount('account-b', '/tmp/account-b/home')]
service.setInactiveCodexAccountsResolver(() => inactiveAccounts)
service.setCodexHomePathResolver(() => '/tmp/account-b/home')
service.setCodexHomePathResolver(() => ({
kind: 'ready',
codexHomePath: '/tmp/account-b/home'
}))
vi.mocked(fetchCodexRateLimits)
.mockReturnValueOnce(accountFetch.promise)
.mockResolvedValueOnce(okProvider('codex', 7, Date.now()))
@@ -258,7 +313,7 @@ describe('RateLimitService', () => {
it('keeps the inactive Codex debounce across an account switch instead of re-probing', async () => {
const service = new RateLimitService()
service.setInactiveCodexAccountsResolver(() => [
{ id: 'account-b', managedHomePath: '/tmp/account-b/home' }
inactiveCodexAccount('account-b', '/tmp/account-b/home')
])
vi.mocked(fetchCodexRateLimits).mockImplementation(async () => okProvider('codex', 10))
@@ -280,8 +335,8 @@ describe('RateLimitService', () => {
try {
const service = new RateLimitService()
service.setInactiveCodexAccountsResolver(() => [
{ id: 'account-a', managedHomePath: '/tmp/account-a/home' },
{ id: 'account-b', managedHomePath: '/tmp/account-b/home' }
inactiveCodexAccount('account-a', '/tmp/account-a/home'),
inactiveCodexAccount('account-b', '/tmp/account-b/home')
])
vi.mocked(fetchCodexRateLimits).mockImplementation(async () => okProvider('codex', 5))
@@ -300,6 +355,34 @@ describe('RateLimitService', () => {
}
})
it('does not start another inactive Codex batch during the inter-account stagger', async () => {
vi.useFakeTimers()
try {
const service = new RateLimitService()
service.setInactiveCodexAccountsResolver(() => [
inactiveCodexAccount('account-a', '/tmp/account-a/home'),
inactiveCodexAccount('account-b', '/tmp/account-b/home')
])
vi.mocked(fetchCodexRateLimits).mockImplementation(async () => okProvider('codex', 5))
const fetchOnOpen = service.fetchInactiveCodexAccountsOnOpen()
await vi.advanceTimersByTimeAsync(0)
expect(fetchCodexRateLimits).toHaveBeenCalledOnce()
expect(service.getState().inactiveCodexAccounts).toEqual([
expect.objectContaining({ accountId: 'account-a', isFetching: false })
])
await service.fetchInactiveCodexAccountsOnOpen()
expect(fetchCodexRateLimits).toHaveBeenCalledOnce()
await vi.advanceTimersByTimeAsync(2_000)
await fetchOnOpen
expect(fetchCodexRateLimits).toHaveBeenCalledTimes(2)
} finally {
vi.useRealTimers()
}
})
it('does not recache an inactive Claude account removed during fetch-on-open', async () => {
const service = new RateLimitService()
const accountFetch = deferred<ProviderRateLimits>()
+95 -33
View File
@@ -32,13 +32,14 @@ import {
type CodexAccountSelectionTarget,
type NormalizedCodexAccountSelectionTarget
} from '../codex-accounts/runtime-selection'
import type { CodexRateLimitHomeResolution } from '../codex-accounts/runtime-home-service'
export type InactiveCodexAccountInfo = {
id: string
managedHomePath: string
resolveHome: () => { kind: 'ready'; managedHomePath: string } | { kind: 'skip' }
}
type CodexHomePathResolver = (target?: CodexAccountSelectionTarget) => string | null
type CodexHomePathResolver = (target?: CodexAccountSelectionTarget) => CodexRateLimitHomeResolution
type KimiHomeResolver = () => Promise<KimiHomeResolution>
type ClaudeAuthPreparationResolver = (
target?: ClaudeAccountSelectionTarget
@@ -241,6 +242,7 @@ export class RateLimitService {
private inactiveCodexCache = new Map<string, ProviderRateLimits>()
private inactiveClaudeFetching = new Set<string>()
private inactiveCodexFetching = new Set<string>()
private inactiveCodexFetchInFlight = false
private lastInactiveClaudeFetchAt = 0
private inactiveClaudeAccountsGeneration = 0
private lastInactiveCodexFetchAt = 0
@@ -260,6 +262,21 @@ export class RateLimitService {
this.codexHomePathResolver = resolver
}
// Why: `skip` and a `ready` null are different answers — null still means the
// system-default lane, so it must never stand in for "don't fetch" (#STA-4422).
private resolveCodexHome(target?: CodexAccountSelectionTarget): {
skip: boolean
homePath: string | null
} {
const resolution = this.codexHomePathResolver?.(target)
if (!resolution) {
return { skip: false, homePath: null }
}
return resolution.kind === 'skip'
? { skip: true, homePath: null }
: { skip: false, homePath: resolution.codexHomePath }
}
setCodexFetchTarget(target?: CodexAccountSelectionTarget): void {
this.codexFetchTarget = normalizeCodexAccountSelectionTarget(target)
}
@@ -635,7 +652,7 @@ export class RateLimitService {
return
}
this.pruneInactiveCodexState()
if (this.inactiveCodexFetching.size > 0) {
if (this.inactiveCodexFetchInFlight) {
return
}
const accounts = this.inactiveCodexAccountsResolver?.() ?? []
@@ -646,11 +663,7 @@ export class RateLimitService {
const fetchGeneration = this.inactiveCodexAccountsGeneration
const controller = this.beginFetchCycle()
const signal = controller.signal
for (const account of accounts) {
this.inactiveCodexFetching.add(account.id)
}
this.pushToRenderer()
this.inactiveCodexFetchInFlight = true
let staggerNextProbe = false
try {
@@ -683,12 +696,18 @@ export class RateLimitService {
continue
}
}
const home = account.resolveHome()
if (home.kind === 'skip') {
continue
}
staggerNextProbe = true
this.inactiveCodexFetching.add(account.id)
this.pushToRenderer()
try {
// Why: point fetchCodexRateLimits at the managed home directly, avoiding materializing credentials into the shared runtime location.
// Why: no PTY fallback — the switcher preview shouldn't spawn hidden PTYs per account (can crash ConPTY on Windows); RPC-only is enough.
const fresh = await fetchCodexRateLimits({
codexHomePath: account.managedHomePath,
codexHomePath: home.managedHomePath,
allowPtyFallback: false,
signal
})
@@ -724,6 +743,7 @@ export class RateLimitService {
this.lastInactiveCodexFetchAt = Date.now()
}
} finally {
this.inactiveCodexFetchInFlight = false
this.finishFetchCycle(controller)
}
}
@@ -1338,10 +1358,13 @@ export class RateLimitService {
}
const scopedCodex = this.applyStalePolicy(fresh, stateBeforeReset.codex)
const currentHomePath = this.codexHomePathResolver?.(target) ?? null
const currentCodexHome = this.resolveCodexHome(target)
// Why: a skip has no provenance to compare, so treat it as no longer active
// rather than publishing this result against the system-default lane.
const stillActive =
!currentCodexHome.skip &&
this.isSameCodexTarget(this.codexFetchTarget, target) &&
this.getCodexProvenance(target, currentHomePath) ===
this.getCodexProvenance(target, currentCodexHome.homePath) ===
this.getCodexProvenance(target, codexHomePath)
if (stillActive) {
// Why: this post-redemption read is newer than every Codex fetch that
@@ -1590,10 +1613,18 @@ export class RateLimitService {
this.rememberClaudeAuthSnapshot(claudeAuthPreparation, claudeGeneration, claudeTarget)
const claudeProvenance = claudeAuthPreparation?.provenance ?? 'system'
const codexTarget = this.codexFetchTarget
const codexHomePath = this.codexHomePathResolver?.(codexTarget) ?? null
const codexProvenance = this.getCodexProvenance(codexTarget, codexHomePath)
const codexGeneration = this.codexFetchGeneration
const previousState = this.state
// Why: a skipped Codex poll must not stop the other providers' cycle, so gate
// only the Codex slot instead of returning early (#STA-4422).
const codexHome = this.resolveCodexHome(codexTarget)
const codexFetchGated = codexHome.skip
const codexHomePath = codexHome.homePath
const codexStateBeforeFetch =
previousState.codex?.status === 'fetching' ? null : previousState.codex
const codexProvenance = codexFetchGated
? null
: this.getCodexProvenance(codexTarget, codexHomePath)
const codexGeneration = this.codexFetchGeneration
const openCodeGoConfig = this.openCodeGoConfigResolver?.()
const cookie = openCodeGoConfig?.sessionCookie ?? ''
const workspaceIdOverride = openCodeGoConfig?.workspaceIdOverride ?? ''
@@ -1627,7 +1658,10 @@ export class RateLimitService {
this.updateState({
...previousState,
claude: this.withFetchingStatus(previousState.claude, 'claude'),
codex: this.withFetchingStatus(previousState.codex, 'codex'),
// Why: a gated Codex cycle makes no attempt; a "fetching" chip would never settle.
codex: codexFetchGated
? codexStateBeforeFetch
: this.withFetchingStatus(previousState.codex, 'codex'),
gemini: this.withFetchingStatus(previousState.gemini, 'gemini'),
opencodeGo: opencodeConfigChanged
? this.withFetchingStatus(null, 'opencode-go')
@@ -1640,9 +1674,8 @@ export class RateLimitService {
grok: this.withFetchingStatus(previousState.grok, 'grok')
})
const missingWslCodexHome = codexHomePath
? null
: this.getMissingWslCodexHomeResult(codexTarget)
const missingWslCodexHome =
codexFetchGated || codexHomePath ? null : this.getMissingWslCodexHomeResult(codexTarget)
const grokResultPromise = fetchGrokRateLimits({
signal,
authReadResult: grokAuthReadResult
@@ -1666,12 +1699,14 @@ export class RateLimitService {
networkProxySettings: this.networkProxySettingsResolver?.(),
signal
}),
missingWslCodexHome ??
fetchCodexRateLimits({
codexHomePath,
allowPtyFallback: this.shouldAllowCodexPtyFallback(),
signal
}),
codexFetchGated
? Promise.resolve(previousState.codex as ProviderRateLimits)
: (missingWslCodexHome ??
fetchCodexRateLimits({
codexHomePath,
allowPtyFallback: this.shouldAllowCodexPtyFallback(),
signal
})),
fetchGeminiRateLimits(geminiCliOAuthEnabled),
fetchOpenCodeGoRateLimits(
cookie,
@@ -1780,15 +1815,21 @@ export class RateLimitService {
status: 'error'
} satisfies ProviderRateLimits)
const latestCodexHomePath = this.codexHomePathResolver?.(codexTarget) ?? null
const latestCodexHome = this.resolveCodexHome(codexTarget)
const latestClaudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget)
if (signal.aborted) {
return
}
const latestClaudeProvenance = latestClaudeAuthPreparation?.provenance ?? 'system'
const latestCodexProvenance = this.getCodexProvenance(codexTarget, latestCodexHomePath)
// Why: a finishing skip has no provenance, so an in-flight result must never be
// applied as though the target had become the system default (#STA-4422).
const shouldApplyCodex =
codexGeneration === this.codexFetchGeneration && codexProvenance === latestCodexProvenance
!codexFetchGated &&
!latestCodexHome.skip &&
codexGeneration === this.codexFetchGeneration &&
codexProvenance === this.getCodexProvenance(codexTarget, latestCodexHome.homePath)
const codexBecameUnavailable =
!codexFetchGated && latestCodexHome.skip && codexGeneration === this.codexFetchGeneration
// Why: a gated cycle made no Claude attempt; applying its passthrough result would grow the failure streak and reset stale-policy clocks for free.
const shouldApplyClaude =
!claudeFetchGated &&
@@ -1822,7 +1863,9 @@ export class RateLimitService {
: this.state.claude,
codex: shouldApplyCodex
? this.applyStalePolicy(codex, previousState.codex)
: this.state.codex,
: codexBecameUnavailable
? codexStateBeforeFetch
: this.state.codex,
gemini: this.applyStalePolicy(gemini, previousState.gemini),
opencodeGo: shouldApplyOpencode
? opencodeConfigChanged
@@ -1865,9 +1908,20 @@ export class RateLimitService {
return
}
const codexTarget = this.codexFetchTarget
const codexHomePath = this.codexHomePathResolver?.(codexTarget) ?? null
const codexProvenance = this.getCodexProvenance(codexTarget, codexHomePath)
const codexGeneration = this.codexFetchGeneration
const codexHome = this.resolveCodexHome(codexTarget)
// Why: return before the "fetching" mark — a skipped cycle never settles it (#STA-4422).
if (codexHome.skip) {
if (
codexGeneration === this.codexFetchGeneration &&
this.state.codex?.status === 'fetching'
) {
this.updateState({ ...this.state, codex: null })
}
return
}
const codexHomePath = codexHome.homePath
const codexProvenance = this.getCodexProvenance(codexTarget, codexHomePath)
const previousState = this.state
this.updateState({
@@ -1901,10 +1955,18 @@ export class RateLimitService {
return
}
const latestCodexHomePath = this.codexHomePathResolver?.(codexTarget) ?? null
const latestCodexProvenance = this.getCodexProvenance(codexTarget, latestCodexHomePath)
const latestCodexHome = this.resolveCodexHome(codexTarget)
if (latestCodexHome.skip && codexGeneration === this.codexFetchGeneration) {
this.updateState({
...this.state,
codex: previousState.codex?.status === 'fetching' ? null : previousState.codex
})
return
}
const shouldApplyCodex =
codexGeneration === this.codexFetchGeneration && codexProvenance === latestCodexProvenance
!latestCodexHome.skip &&
codexGeneration === this.codexFetchGeneration &&
codexProvenance === this.getCodexProvenance(codexTarget, latestCodexHome.homePath)
if (shouldApplyCodex) {
this.trackActiveFailureStreak('codex', codex)
@@ -3,6 +3,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { prepareLocalCommitMessageAgentEnv } from './commit-message-agent-environment'
import { ManagedCodexHomeTemporarilyUnavailableError } from '../codex-accounts/host-codex-managed-home-ownership'
const originalEnv = { ...process.env }
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
@@ -124,6 +125,26 @@ describe('prepareLocalCommitMessageAgentEnv', () => {
})
})
// Why (#STA-4422): launch prep throws when the managed home is unreadable.
// Falling through to an env would run the headless commit agent on the user's
// real ~/.codex while the UI shows the managed account selected.
it('fails commit generation instead of building a system-account env when the managed home is unreadable', async () => {
process.env.CODEX_HOME = '/home/me/.config/codex'
delete process.env.ORCA_CODEX_HOME
const result = await prepareLocalCommitMessageAgentEnv('codex', {
prepareForCodexLaunch: () => {
throw new ManagedCodexHomeTemporarilyUnavailableError()
}
})
expect(result).toEqual({
ok: false,
error: 'Failed to prepare the selected agent account for commit message generation.'
})
expect(result).not.toHaveProperty('env')
})
it('strips a nested-Orca CODEX_HOME override when the launch resolves to the real home', async () => {
process.env.CODEX_HOME = '/managed/runtime/home'
process.env.ORCA_CODEX_HOME = '/managed/runtime/home'
+5 -1
View File
@@ -11,7 +11,11 @@ export type CodexAccountsApi = {
runtime?: 'host' | 'wsl'
wslDistro?: string | null
}) => Promise<CodexRateLimitAccountsState>
reauthenticate: (args: { accountId: string }) => Promise<CodexRateLimitAccountsState>
reauthenticate: (args: {
accountId: string
/** Local-only: activate the re-authed account when its runtime lane had no selection. */
activateIfSelectionWasEmpty?: boolean
}) => Promise<CodexRateLimitAccountsState>
remove: (args: { accountId: string }) => Promise<CodexRateLimitAccountsState>
select: (args: {
accountId: string | null
+4 -2
View File
@@ -2184,8 +2184,10 @@ const api = {
list: (): Promise<unknown> => ipcRenderer.invoke('codexAccounts:list'),
add: (args?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }): Promise<unknown> =>
ipcRenderer.invoke('codexAccounts:add', args),
reauthenticate: (args: { accountId: string }): Promise<unknown> =>
ipcRenderer.invoke('codexAccounts:reauthenticate', args),
reauthenticate: (args: {
accountId: string
activateIfSelectionWasEmpty?: boolean
}): Promise<unknown> => ipcRenderer.invoke('codexAccounts:reauthenticate', args),
remove: (args: { accountId: string }): Promise<unknown> =>
ipcRenderer.invoke('codexAccounts:remove', args),
select: (args: {
@@ -96,6 +96,49 @@ import {
watchProviderAccounts
} from '@/runtime/runtime-provider-accounts-client'
// Why: bounded so a permanently unreadable home cannot poll forever; ~5 minutes
// total is long enough to outlast an antivirus scan or backup pass.
const CODEX_CONFIG_SYNC_RETRY_MS = 30_000
const CODEX_CONFIG_SYNC_RETRY_LIMIT = 10
function watchCodexConfigSyncStatus(
onStatus: (status: CodexConfigSyncStatus | null) => void
): () => void {
let cancelled = false
let attempts = 0
let retryTimer: ReturnType<typeof setTimeout> | null = null
const poll = (): void => {
void window.api.codexConfigSync
.status()
.then((status) => {
if (cancelled) {
return
}
onStatus(status)
if (
status.state === 'stalled' &&
status.reason === 'managed-home-unavailable' &&
attempts < CODEX_CONFIG_SYNC_RETRY_LIMIT
) {
attempts += 1
retryTimer = setTimeout(poll, CODEX_CONFIG_SYNC_RETRY_MS)
}
})
.catch(() => {
if (!cancelled) {
onStatus(null)
}
})
}
poll()
return () => {
cancelled = true
if (retryTimer !== null) {
clearTimeout(retryTimer)
}
}
}
export { getAccountsPaneSearchEntries }
const EMPTY_WSL_DISTROS: string[] = []
@@ -452,22 +495,12 @@ export function AccountsPane({
setCodexConfigSync(null)
return
}
let cancelled = false
void window.api.codexConfigSync
.status()
.then((status) => {
if (!cancelled) {
setCodexConfigSync(status)
}
})
.catch(() => {
if (!cancelled) {
setCodexConfigSync(null)
}
})
return () => {
cancelled = true
}
// Why: a temporarily locked managed home clears on its own, but this effect
// only reruns on scope/runtime/selection changes — none of which a lock
// release triggers. Without a retry the warning would stick until remount.
// Serialized (timeout, not interval) so a slow response can never be
// overwritten by an older one.
return watchCodexConfigSyncStatus(setCodexConfigSync)
// Why: the status resolves whichever home the ACTIVE selection mirrors into
// (per-account, shared, or none for the real-home lane), so switching
// accounts must refetch or the banner describes the previous account.
@@ -1180,23 +1213,28 @@ export function AccountsPane({
<div className="flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs text-destructive">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span>
{codexConfigSyncWarning === 'missing-source'
{codexConfigSyncWarning === 'managed-home-unavailable'
? translate(
'auto.components.settings.AccountsPane.codexConfigSyncMissingSource',
'Codex is still using the settings it last synced because {{value0}} is missing. Restore that file to resume syncing.',
{ value0: codexConfigSync?.systemConfigPath ?? '' }
'auto.components.settings.AccountsPane.codexConfigSyncManagedHomeUnavailable',
'Orca could not read this account\u2019s Codex files just now, so settings may not be syncing. This usually clears on its own \u2014 antivirus or a backup tool briefly locks them.'
)
: codexConfigSyncWarning === 'blank-source'
: codexConfigSyncWarning === 'missing-source'
? translate(
'auto.components.settings.AccountsPane.codexConfigSyncBlankSource',
'Codex is still using the settings it last synced because {{value0}} is empty. That is expected while a synced folder finishes downloading.',
'auto.components.settings.AccountsPane.codexConfigSyncMissingSource',
'Codex is still using the settings it last synced because {{value0}} is missing. Restore that file to resume syncing.',
{ value0: codexConfigSync?.systemConfigPath ?? '' }
)
: translate(
'auto.components.settings.AccountsPane.codexConfigSyncUnreadableSource',
"Codex is still using the settings it last synced because {{value0}} could not be read. Check that file's permissions.",
{ value0: codexConfigSync?.systemConfigPath ?? '' }
)}
: codexConfigSyncWarning === 'blank-source'
? translate(
'auto.components.settings.AccountsPane.codexConfigSyncBlankSource',
'Codex is still using the settings it last synced because {{value0}} is empty. That is expected while a synced folder finishes downloading.',
{ value0: codexConfigSync?.systemConfigPath ?? '' }
)
: translate(
'auto.components.settings.AccountsPane.codexConfigSyncUnreadableSource',
"Codex is still using the settings it last synced because {{value0}} could not be read. Check that file's permissions.",
{ value0: codexConfigSync?.systemConfigPath ?? '' }
)}
</span>
</div>
) : null}
@@ -96,6 +96,7 @@ import {
selectClaudeProviderAccount,
selectCodexProviderAccount
} from '@/runtime/runtime-provider-accounts-client'
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
import {
getDisplayedUsagePercentage,
@@ -1489,23 +1490,61 @@ export function CodexSwitcherMenu({
}
}
const handleSignInAccount = async (accountId: string): Promise<void> => {
const handleSignInAccount = async (
accountId: string,
target: CodexStatusRuntimeTarget
): Promise<void> => {
if (isSwitching || reauthenticatingAccountId !== null) {
return
}
const previousActiveAccountId = getCodexStatusActiveId(accountState, target)
setReauthenticatingAccountId(accountId)
try {
const next = await window.api.codexAccounts.reauthenticate({ accountId })
const next = await window.api.codexAccounts.reauthenticate({
accountId,
// Why: signing in from a signed-out status bar should leave the account
// usable; the main process still refuses to steal an existing selection.
activateIfSelectionWasEmpty: true
})
recordFeatureInteraction('codex-account-switching')
if (mountedRef.current) {
setAccounts(next)
}
await fetchSettings()
if (mountedRef.current && accountsExpandedRef.current) {
const nextActiveAccountId = getCodexStatusActiveId(next, target)
if (previousActiveAccountId !== nextActiveAccountId) {
// Why: sign-in that lands on a new active account changes pane credentials
// exactly like an explicit switch, so it owes the same restart prompt.
await markLiveCodexSessionsForRestart({
previousAccountLabel: resolveCodexRestartPromptAccountLabel(
accountState.accounts,
previousActiveAccountId
),
nextAccountLabel: resolveCodexRestartPromptAccountLabel(
next.accounts,
nextActiveAccountId
),
previousAccountId: previousActiveAccountId ?? null,
nextAccountId: nextActiveAccountId ?? null,
target
})
if (mountedRef.current) {
setAccountsExpanded(false)
}
} else if (mountedRef.current && accountsExpandedRef.current) {
await fetchInactiveCodexAccountUsage()
}
toast.success(
translate('auto.components.status.bar.StatusBar.codexSignInSuccess', 'Signed in to Codex')
)
} catch (error) {
console.error('Failed to re-authenticate Codex account from status bar:', error)
toast.error(
translate(
'auto.components.status.bar.StatusBar.codexSignInError',
'Codex sign-in failed. Please try again.'
)
)
} finally {
if (mountedRef.current) {
setReauthenticatingAccountId(null)
@@ -1802,7 +1841,7 @@ export function CodexSwitcherMenu({
onSignIn={() => {
suppressNextAccountSelect()
if (target.id !== null) {
void handleSignInAccount(target.id)
void handleSignInAccount(target.id, target.runtimeTarget)
}
}}
/>
@@ -0,0 +1,262 @@
// @vitest-environment happy-dom
/**
* STA-4422 symptom 2: the status bar's inline "Sign in" action re-authenticated
* an account and then reported nothing no toast on success or failure and
* never ran the session-restart workflow that an explicit account switch runs,
* so live panes kept the old credentials. These render the real switcher and
* click the real button so the wiring (intent flag, runtime target, restart
* workflow, toasts) cannot pass while the handler is mis-wired.
*/
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import React from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { GlobalSettings } from '../../../../shared/global-settings-types'
import type {
CodexManagedAccountSummary,
CodexRateLimitAccountsState
} from '../../../../shared/managed-account-types'
import type { ProviderRateLimits } from '../../../../shared/rate-limit-types'
const markLiveCodexSessionsForRestart = vi.fn(async () => {})
const toastSuccess = vi.fn()
const toastError = vi.fn()
const fetchInactiveCodexAccountUsage = vi.fn(async () => {})
const fetchSettings = vi.fn(async () => {})
const reauthenticate = vi.fn(async (_args: unknown) => codexSnapshot(null))
let storeSettings: GlobalSettings
function codexAccount(id: string, updatedAt: number): CodexManagedAccountSummary {
return {
id,
email: `${id}@example.com`,
managedHomeRuntime: 'host',
wslDistro: null,
providerAccountId: `provider-${id}`,
workspaceLabel: null,
workspaceAccountId: null,
createdAt: updatedAt,
updatedAt,
lastAuthenticatedAt: updatedAt
}
}
function codexSnapshot(activeAccountId: string | null): CodexRateLimitAccountsState {
return {
accounts: [codexAccount('account-1', 2), codexAccount('account-2', 1)],
activeAccountId,
activeAccountIdsByRuntime: { host: activeAccountId, wsl: {} }
}
}
function settingsWithActive(activeAccountId: string | null): GlobalSettings {
return {
codexManagedAccounts: [codexAccount('account-1', 2), codexAccount('account-2', 1)],
activeCodexManagedAccountId: activeAccountId,
activeCodexManagedAccountIdsByRuntime: { host: activeAccountId, wsl: {} },
claudeManagedAccounts: [],
activeClaudeManagedAccountId: null,
localAccountRuntime: 'host',
localAccountWslDistro: null,
skipCodexRateLimitResetConfirm: false
} as unknown as GlobalSettings
}
/** An inactive account whose usage probe failed — the state that renders "Sign in". */
const unavailableUsage: ProviderRateLimits = {
provider: 'codex',
session: null,
weekly: null,
status: 'error',
error: 'Not signed in',
updatedAt: 1
} as unknown as ProviderRateLimits
vi.mock('sonner', () => ({
toast: { success: toastSuccess, error: toastError }
}))
vi.mock('@/lib/codex-session-restart', () => ({
markLiveCodexSessionsForRestart,
resolveCodexRestartPromptAccountLabel: (
accounts: readonly { id: string; email: string }[],
accountId: string | null | undefined
) => accounts.find((entry) => entry.id === accountId)?.email ?? 'System default'
}))
vi.mock('@/runtime/runtime-provider-accounts-client', () => ({
fetchProviderAccountsSnapshot: vi.fn(async () => ({
codex: codexSnapshot(null),
claude: { accounts: [], activeAccountId: null },
failedProviders: []
})),
selectCodexProviderAccount: vi.fn(async () => codexSnapshot(null)),
selectClaudeProviderAccount: vi.fn(async () => ({ accounts: [], activeAccountId: null }))
}))
vi.mock('@/runtime/runtime-rpc-client', () => ({
getActiveRuntimeTarget: () => null
}))
vi.mock('@/lib/windows-terminal-capabilities', () => ({
useWindowsTerminalCapabilities: () => ({ wslDistros: [], isLoading: false }),
getWindowsTerminalCapabilityOwnerKey: () => 'local'
}))
vi.mock('./tooltip', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
ProviderIcon: () => null,
ProviderPanel: () => null
}))
// Why: Radix portals its menu behind pointer-capture the DOM shim cannot drive;
// passthrough shells render the same tree eagerly so the real handlers run.
vi.mock('@/components/ui/dropdown-menu', () => {
const passthrough =
(role?: string) =>
({
children,
onSelect
}: {
children?: React.ReactNode
onSelect?: (event: { preventDefault: () => void }) => void
}): React.JSX.Element =>
React.createElement(
'div',
{
role,
onClick: onSelect ? () => onSelect({ preventDefault: () => {} }) : undefined
},
children
)
return {
DropdownMenu: passthrough(),
DropdownMenuCheckboxItem: passthrough(),
DropdownMenuContent: passthrough(),
DropdownMenuItem: passthrough('menuitem'),
DropdownMenuLabel: passthrough(),
DropdownMenuSeparator: passthrough(),
DropdownMenuSub: passthrough(),
DropdownMenuSubContent: passthrough(),
DropdownMenuSubTrigger: passthrough(),
DropdownMenuTrigger: passthrough()
}
})
vi.mock('../../store', () => {
const state = (): Record<string, unknown> => ({
settings: storeSettings,
runtimeEnvironments: [],
usagePercentageDisplay: 'used',
openSettingsPage: vi.fn(),
openSettingsTarget: vi.fn(),
fetchSettings,
updateSettings: vi.fn(),
recordFeatureInteraction: vi.fn(),
refreshCodexRateLimitsForTarget: vi.fn(),
consumeCodexRateLimitResetCredit: vi.fn(),
fetchInactiveCodexAccountUsage,
rateLimits: {
inactiveCodexAccounts: [
{ accountId: 'account-2', isFetching: false, rateLimits: unavailableUsage }
],
codexTarget: { runtime: 'host', wslDistro: null }
}
})
const useAppStore = (selector: (value: Record<string, unknown>) => unknown): unknown =>
selector(state())
useAppStore.getState = state
return { useAppStore }
})
const codexProvider: ProviderRateLimits = {
provider: 'codex',
session: null,
weekly: null,
status: 'error',
error: 'Not signed in',
updatedAt: 1
} as unknown as ProviderRateLimits
async function renderSwitcherAndOpenAccounts(summaryLabel: string): Promise<void> {
const { CodexSwitcherMenu } = await import('./StatusBar')
render(
React.createElement(CodexSwitcherMenu, {
codex: codexProvider,
compact: false,
iconOnly: false
})
)
// The account list is collapsed behind the summary row, exactly as in the app.
fireEvent.click(screen.getByText(summaryLabel))
await waitFor(() => expect(screen.getByRole('button', { name: /Sign in/ })).toBeTruthy())
}
describe('status bar Codex sign-in action', () => {
beforeEach(() => {
vi.clearAllMocks()
storeSettings = settingsWithActive(null)
Object.defineProperty(window, 'api', {
configurable: true,
writable: true,
value: { codexAccounts: { reauthenticate } }
})
})
afterEach(() => {
cleanup()
})
it('activates the signed-in account and runs the same restart workflow a switch runs', async () => {
reauthenticate.mockResolvedValue(codexSnapshot('account-2'))
await renderSwitcherAndOpenAccounts('System default')
fireEvent.click(screen.getByRole('button', { name: /Sign in/ }))
await waitFor(() => expect(toastSuccess).toHaveBeenCalledTimes(1))
// Healthy anchor: the intent and the clicked row's lane both reach the main process.
expect(reauthenticate).toHaveBeenCalledWith({
accountId: 'account-2',
activateIfSelectionWasEmpty: true
})
expect(markLiveCodexSessionsForRestart).toHaveBeenCalledTimes(1)
expect(markLiveCodexSessionsForRestart).toHaveBeenCalledWith(
expect.objectContaining({
previousAccountId: null,
nextAccountId: 'account-2',
target: { runtime: 'host', wslDistro: null }
})
)
expect(toastError).not.toHaveBeenCalled()
})
it('does not disturb live panes when the effective account did not change', async () => {
storeSettings = settingsWithActive('account-1')
reauthenticate.mockResolvedValue(codexSnapshot('account-1'))
await renderSwitcherAndOpenAccounts('account-1@example.com')
fireEvent.click(screen.getByRole('button', { name: /Sign in/ }))
await waitFor(() => expect(toastSuccess).toHaveBeenCalledTimes(1))
// Healthy anchor: the same click reached the main process; only the follow-up differs.
expect(reauthenticate).toHaveBeenCalledWith({
accountId: 'account-2',
activateIfSelectionWasEmpty: true
})
expect(markLiveCodexSessionsForRestart).not.toHaveBeenCalled()
expect(toastError).not.toHaveBeenCalled()
})
it('reports a failed sign-in instead of leaving the click silent', async () => {
reauthenticate.mockRejectedValue(new Error('Codex login exited with code 1.'))
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
await renderSwitcherAndOpenAccounts('System default')
fireEvent.click(screen.getByRole('button', { name: /Sign in/ }))
await waitFor(() => expect(toastError).toHaveBeenCalledTimes(1))
expect(toastSuccess).not.toHaveBeenCalled()
expect(markLiveCodexSessionsForRestart).not.toHaveBeenCalled()
errorSpy.mockRestore()
})
})
+4 -1
View File
@@ -3489,7 +3489,9 @@
"antigravityUsageDetails": "Open Antigravity usage details",
"grokUsageAria": "Open Grok usage details",
"grokUsageMenu": "Grok Usage",
"floatingTerminalNewActivity": "{{label}}, new activity"
"floatingTerminalNewActivity": "{{label}}, new activity",
"codexSignInSuccess": "Signed in to Codex",
"codexSignInError": "Codex sign-in failed. Please try again."
},
"StatusBarUsageEmptyCta": {
"828c764a79": "Connect an account",
@@ -6125,6 +6127,7 @@
"codexSystemDefaultNeedsSignIn": "No Codex sign-in was found for {{value0}}.",
"codexConfigSyncMissingSource": "Codex is still using the settings it last synced because {{value0}} is missing. Restore that file to resume syncing.",
"codexConfigSyncBlankSource": "Codex is still using the settings it last synced because {{value0}} is empty. That is expected while a synced folder finishes downloading.",
"codexConfigSyncManagedHomeUnavailable": "Orca could not read this accounts Codex files just now, so settings may not be syncing. This usually clears on its own — antivirus or a backup tool briefly locks them.",
"codexConfigSyncUnreadableSource": "Codex is still using the settings it last synced because {{value0}} could not be read. Check that file's permissions."
},
"AdvancedPane": {
+4 -1
View File
@@ -3276,7 +3276,9 @@
"antigravityUsageDetails": "Abrir detalles de uso de Antigravity",
"grokUsageAria": "Abrir detalles de uso de Grok",
"grokUsageMenu": "Uso de Grok",
"floatingTerminalNewActivity": "{{label}}, nueva actividad"
"floatingTerminalNewActivity": "{{label}}, nueva actividad",
"codexSignInSuccess": "Sesión iniciada en Codex",
"codexSignInError": "No se pudo iniciar sesión en Codex. Inténtalo de nuevo."
},
"StatusBarUsageEmptyCta": {
"828c764a79": "Conectar una cuenta",
@@ -5338,6 +5340,7 @@
"codexSystemDefaultNeedsSignIn": "No se encontró ningún inicio de sesión de Codex para {{value0}}.",
"codexConfigSyncMissingSource": "Codex is still using the settings it last synced because {{value0}} is missing. Restore that file to resume syncing.",
"codexConfigSyncBlankSource": "Codex is still using the settings it last synced because {{value0}} is empty. That is expected while a synced folder finishes downloading.",
"codexConfigSyncManagedHomeUnavailable": "Orca no pudo leer los archivos de Codex de esta cuenta en este momento, por lo que es posible que la configuración no se esté sincronizando. Normalmente se resuelve solo: un antivirus o una herramienta de copia de seguridad puede bloquearlos brevemente.",
"codexConfigSyncUnreadableSource": "Codex is still using the settings it last synced because {{value0}} could not be read. Check that file's permissions."
},
"AdvancedPane": {
+4 -1
View File
@@ -3276,7 +3276,9 @@
"antigravityUsageDetails": "Antigravity の使用状況詳細を開く",
"grokUsageAria": "Grok の使用状況詳細を開く",
"grokUsageMenu": "Grok の使用状況",
"floatingTerminalNewActivity": "{{label}}、新規アクティビティ"
"floatingTerminalNewActivity": "{{label}}、新規アクティビティ",
"codexSignInSuccess": "Codex にサインインしました",
"codexSignInError": "Codex のサインインに失敗しました。もう一度お試しください。"
},
"StatusBarUsageEmptyCta": {
"828c764a79": "アカウントを接続する",
@@ -5323,6 +5325,7 @@
"codexSystemDefaultNeedsSignIn": "{{value0}} の Codex サインインが見つかりません。",
"codexConfigSyncMissingSource": "{{value0}} が見つからないため、Codex は最後に同期した設定を使い続けています。同期を再開するには、そのファイルを復元してください。",
"codexConfigSyncBlankSource": "{{value0}} が空のため、Codex は最後に同期した設定を使い続けています。同期フォルダーのダウンロードが完了するまでは正常な状態です。",
"codexConfigSyncManagedHomeUnavailable": "Orca は現在このアカウントの Codex ファイルを読み取れないため、設定が同期されていない可能性があります。ウイルス対策ソフトやバックアップツールによる一時的なロックが原因で、通常は自動的に解消されます。",
"codexConfigSyncUnreadableSource": "{{value0}} を読み取れなかったため、Codex は最後に同期した設定を使い続けています。そのファイルの権限を確認してください。"
},
"AdvancedPane": {
+4 -1
View File
@@ -3278,7 +3278,9 @@
"antigravityUsageDetails": "Antigravity 사용량 세부 정보 열기",
"grokUsageAria": "Grok 사용량 세부 정보 열기",
"grokUsageMenu": "Grok 사용량",
"floatingTerminalNewActivity": "{{label}}, 새 활동"
"floatingTerminalNewActivity": "{{label}}, 새 활동",
"codexSignInSuccess": "Codex에 로그인했습니다",
"codexSignInError": "Codex 로그인에 실패했습니다. 다시 시도해 주세요."
},
"StatusBarUsageEmptyCta": {
"828c764a79": "계정 연결",
@@ -5325,6 +5327,7 @@
"codexSystemDefaultNeedsSignIn": "{{value0}}에 대한 Codex 로그인을 찾을 수 없습니다.",
"codexConfigSyncMissingSource": "Codex is still using the settings it last synced because {{value0}} is missing. Restore that file to resume syncing.",
"codexConfigSyncBlankSource": "Codex is still using the settings it last synced because {{value0}} is empty. That is expected while a synced folder finishes downloading.",
"codexConfigSyncManagedHomeUnavailable": "현재 Orca가 이 계정의 Codex 파일을 읽을 수 없어 설정이 동기화되지 않을 수 있습니다. 바이러스 백신이나 백업 도구가 파일을 잠시 잠근 경우가 많으며, 일반적으로 자동으로 해결됩니다.",
"codexConfigSyncUnreadableSource": "Codex is still using the settings it last synced because {{value0}} could not be read. Check that file's permissions."
},
"AdvancedPane": {
+4 -1
View File
@@ -3288,7 +3288,9 @@
"antigravityUsageDetails": "打开 Antigravity 使用详情",
"grokUsageAria": "打开 Grok 使用详情",
"grokUsageMenu": "Grok 使用情况",
"floatingTerminalNewActivity": "{{label}},有新活动"
"floatingTerminalNewActivity": "{{label}},有新活动",
"codexSignInSuccess": "已登录 Codex",
"codexSignInError": "Codex 登录失败,请重试。"
},
"StatusBarUsageEmptyCta": {
"828c764a79": "连接账户",
@@ -5335,6 +5337,7 @@
"codexSystemDefaultNeedsSignIn": "未找到 {{value0}} 的 Codex 登录信息。",
"codexConfigSyncMissingSource": "由于缺少 {{value0}}Codex 仍在使用上次同步的设置。请恢复该文件以恢复同步。",
"codexConfigSyncBlankSource": "由于 {{value0}} 为空,Codex 仍在使用上次同步的设置。同步文件夹完成下载前出现这种情况是正常的。",
"codexConfigSyncManagedHomeUnavailable": "Orca 暂时无法读取此账户的 Codex 文件,因此设置可能尚未同步。这通常会自行恢复——防病毒软件或备份工具可能只是短暂锁定了这些文件。",
"codexConfigSyncUnreadableSource": "由于无法读取 {{value0}},Codex 仍在使用上次同步的设置。请检查该文件的权限。"
},
"AdvancedPane": {
+9 -1
View File
@@ -1,7 +1,15 @@
// Why: the managed runtime config keeps serving the last good settings while the
// source is unusable, so a stall is "working but not picking up your edits" —
// the reason is what makes it actionable, and the path is what the user fixes.
export type CodexConfigSyncStallReason = 'missing-source' | 'blank-source' | 'unreadable-source'
// Why: the first three describe the SYSTEM config source. 'managed-home-unavailable'
// describes the managed home itself being temporarily unreadable (e.g. an antivirus
// lock on Windows) — a different file and a different remedy, so it needs its own
// reason rather than borrowing 'unreadable-source' and blaming the wrong path.
export type CodexConfigSyncStallReason =
| 'missing-source'
| 'blank-source'
| 'unreadable-source'
| 'managed-home-unavailable'
export type CodexConfigSyncStatus =
| { state: 'synced'; reason: null; systemConfigPath: string }