mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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 })
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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: () =>
|
||||
|
||||
@@ -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,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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
@@ -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>()
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 account’s 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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user