fix(codex): prevent transient managed-auth onboarding (#11731)

* fix(codex): gate terminal spawn on managed auth readiness

* fix(codex): recover unavailable managed auth safely

---------

Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
This commit is contained in:
Neil 2026-07-31 14:40:46 -07:00 committed by GitHub
parent 7d24dad48a
commit 9f30a780f5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 1289 additions and 62 deletions

View File

@ -0,0 +1,241 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { CodexManagedAccount, GlobalSettings } from '../../shared/types'
import { waitForManagedCodexAuthReady } from './managed-codex-auth-readiness'
const roots: string[] = []
const testIdToken = 'e30.eyJlbWFpbCI6InVzZXJAZXhhbXBsZS5jb20ifQ.sig'
const testChatGptAuth = {
auth_mode: 'chatgpt',
tokens: {
access_token: 'access',
id_token: testIdToken,
refresh_token: 'refresh',
account_id: 'account'
},
last_refresh: '2026-07-31T00:00:00Z'
}
afterEach(() => {
vi.useRealTimers()
for (const root of roots.splice(0)) {
rmSync(root, { recursive: true, force: true })
}
})
describe('waitForManagedCodexAuthReady', () => {
it.each([
['ChatGPT', testChatGptAuth],
['ChatGPT auth tokens', { ...testChatGptAuth, auth_mode: 'chatgptAuthTokens' }],
['API key', { auth_mode: 'apikey', OPENAI_API_KEY: 'sk-test' }],
[
'agent identity',
{
auth_mode: 'agentIdentity',
agent_identity: { agent_runtime_id: 'runtime', agent_private_key: 'private-key' }
}
],
[
'future agent identity storage',
{ auth_mode: 'agentIdentity', agent_identity: { future_material: 'opaque' } }
],
[
'personal access token',
{ auth_mode: 'personalAccessToken', personal_access_token: 'pat-test' }
],
[
'Bedrock API key',
{
auth_mode: 'bedrockApiKey',
bedrock_api_key: { api_key: 'bedrock-key', region: 'us-east-1' }
}
],
['future auth mode', { auth_mode: 'futureAuthMode', future_credential: { value: 'opaque' } }],
['future auth shape', { future_credential: { value: 'opaque' } }],
[
'ChatGPT with agent identity metadata',
{
...testChatGptAuth,
agent_identity: { agent_runtime_id: 'runtime', agent_private_key: 'private-key' }
}
]
])('accepts a readable managed %s credential', (_label, auth) => {
const fixture = createFixture()
writeAuth(fixture.home, auth)
expect(waitForManagedCodexAuthReady(fixture.args)).toBeUndefined()
})
it('waits for a missing managed credential to be restored', async () => {
vi.useFakeTimers()
const fixture = createFixture()
const readiness = waitForManagedCodexAuthReady(fixture.args)
await vi.advanceTimersByTimeAsync(50)
writeAuth(fixture.home, { OPENAI_API_KEY: 'sk-test' })
await vi.runAllTimersAsync()
await expect(readiness).resolves.toBe(true)
})
it('waits for a partial managed ChatGPT credential to become complete', async () => {
vi.useFakeTimers()
const fixture = createFixture()
writeAuth(fixture.home, {
tokens: { access_token: 'access', id_token: testIdToken }
})
let resolved = false
const readiness = waitForManagedCodexAuthReady(fixture.args)
void readiness?.then(() => {
resolved = true
})
await vi.advanceTimersByTimeAsync(50)
expect(resolved).toBe(false)
writeAuth(fixture.home, testChatGptAuth)
await vi.advanceTimersByTimeAsync(25)
await expect(readiness).resolves.toBe(true)
})
it('waits for an empty managed ChatGPT refresh token to be restored', async () => {
vi.useFakeTimers()
const fixture = createFixture()
writeAuth(fixture.home, {
...testChatGptAuth,
tokens: { ...testChatGptAuth.tokens, refresh_token: '' }
})
let resolved = false
const readiness = waitForManagedCodexAuthReady(fixture.args)
void readiness?.then(() => {
resolved = true
})
await vi.advanceTimersByTimeAsync(50)
expect(resolved).toBe(false)
writeAuth(fixture.home, testChatGptAuth)
await vi.advanceTimersByTimeAsync(25)
await expect(readiness).resolves.toBe(true)
})
it.each([
[
'empty agent identity record',
{ auth_mode: 'agentIdentity', agent_identity: {} },
{
auth_mode: 'agentIdentity',
agent_identity: { agent_runtime_id: 'runtime', agent_private_key: 'private-key' }
}
],
[
'agent identity missing its private key',
{
auth_mode: 'agentIdentity',
agent_identity: { agent_runtime_id: 'runtime', plan_type: 'team' }
},
{
auth_mode: 'agentIdentity',
agent_identity: { agent_runtime_id: 'runtime', agent_private_key: 'private-key' }
}
],
[
'agent identity with an empty runtime id',
{
auth_mode: 'agentIdentity',
agent_identity: { agent_runtime_id: '', agent_private_key: 'private-key' }
},
{
auth_mode: 'agentIdentity',
agent_identity: { agent_runtime_id: 'runtime', agent_private_key: 'private-key' }
}
],
[
'object-valued personal access token',
{ auth_mode: 'personalAccessToken', personal_access_token: { token_id: 'pat' } },
{ auth_mode: 'personalAccessToken', personal_access_token: 'pat-test' }
],
[
'Bedrock metadata without an API key',
{ auth_mode: 'bedrockApiKey', bedrock_api_key: { region: 'us-east-1' } },
{
auth_mode: 'bedrockApiKey',
bedrock_api_key: { api_key: 'bedrock-key', region: 'us-east-1' }
}
]
])('waits for %s to become complete', async (_label, partial, complete) => {
vi.useFakeTimers()
const fixture = createFixture()
writeAuth(fixture.home, partial)
const readiness = waitForManagedCodexAuthReady(fixture.args)
await vi.advanceTimersByTimeAsync(50)
writeAuth(fixture.home, complete)
await vi.advanceTimersByTimeAsync(25)
await expect(readiness).resolves.toBe(true)
})
it('reports an unreadable managed credential after the retry window', async () => {
vi.useFakeTimers()
const fixture = createFixture()
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
writeFileSync(join(fixture.home, 'auth.json'), '{', 'utf8')
const readiness = waitForManagedCodexAuthReady(fixture.args)
await vi.advanceTimersByTimeAsync(2_000)
await expect(readiness).resolves.toBe(false)
expect(warnSpy).toHaveBeenCalledWith(
'[codex-auth-readiness] Managed credential remained unavailable after 1500ms'
)
})
it('does not gate system, WSL, or unmanaged custom homes', async () => {
const fixture = createFixture()
await waitForManagedCodexAuthReady({
...fixture.args,
codexHomePath: join(fixture.root, 'custom-home')
})
await waitForManagedCodexAuthReady({
...fixture.args,
target: { runtime: 'wsl', wslDistro: 'Ubuntu' }
})
await waitForManagedCodexAuthReady({
...fixture.args,
codexHomePath: null
})
})
})
function createFixture(): {
root: string
home: string
args: Parameters<typeof waitForManagedCodexAuthReady>[0]
} {
const root = mkdtempSync(join(tmpdir(), 'orca-managed-codex-auth-'))
roots.push(root)
const home = join(root, 'account', 'home')
mkdirSync(home, { recursive: true })
const account = {
id: 'account-1',
managedHomePath: home,
managedHomeRuntime: 'host'
} as CodexManagedAccount
return {
root,
home,
args: {
codexHomePath: home,
settings: { codexManagedAccounts: [account] } as GlobalSettings,
target: { runtime: 'host' }
}
}
}
function writeAuth(home: string, auth: object): void {
writeFileSync(join(home, 'auth.json'), JSON.stringify(auth), { mode: 0o600 })
}

View File

@ -0,0 +1,175 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'
import type { GlobalSettings } from '../../shared/types'
import type { CodexAccountSelectionTarget } from './runtime-selection'
const AUTH_READY_TIMEOUT_MS = 1_500
const AUTH_READY_RETRY_MS = 25
type StoredCodexAuth = {
auth_mode?: unknown
OPENAI_API_KEY?: unknown
agent_identity?: unknown
personal_access_token?: unknown
bedrock_api_key?: unknown
last_refresh?: unknown
tokens?: unknown
}
const KNOWN_CREDENTIAL_KEYS = [
'OPENAI_API_KEY',
'tokens',
'agent_identity',
'personal_access_token',
'bedrock_api_key'
] as const
const KNOWN_AGENT_IDENTITY_RECORD_KEYS = [
'agent_runtime_id',
'agent_private_key',
'plan_type',
'task_id'
] as const
export function waitForManagedCodexAuthReady(args: {
codexHomePath: string | null
settings: GlobalSettings | undefined
target: CodexAccountSelectionTarget
}): Promise<boolean> | undefined {
if (isCodexHomeAuthReadyForLaunch(args)) {
return
}
return waitForStoredCodexCredential(join(args.codexHomePath!, 'auth.json'))
}
export function isCodexHomeAuthReadyForLaunch(args: {
codexHomePath: string | null
settings: GlobalSettings | undefined
target: CodexAccountSelectionTarget
}): boolean {
return (
args.target.runtime !== 'host' ||
!args.codexHomePath ||
!isManagedHostCodexHome(args.codexHomePath, args.settings) ||
hasStoredCodexCredential(join(args.codexHomePath, 'auth.json'))
)
}
async function waitForStoredCodexCredential(authPath: string): Promise<boolean> {
const deadline = Date.now() + AUTH_READY_TIMEOUT_MS
do {
await delay(AUTH_READY_RETRY_MS)
if (hasStoredCodexCredential(authPath)) {
return true
}
} while (Date.now() < deadline)
console.warn(
`[codex-auth-readiness] Managed credential remained unavailable after ${AUTH_READY_TIMEOUT_MS}ms`
)
return false
}
function isManagedHostCodexHome(
codexHomePath: string,
settings: GlobalSettings | undefined
): boolean {
const expected = normalizeRuntimePathForComparison(codexHomePath)
return (
settings?.codexManagedAccounts?.some(
(account) =>
account.managedHomeRuntime !== 'wsl' &&
normalizeRuntimePathForComparison(account.managedHomePath) === expected
) === true
)
}
export function hasStoredCodexCredential(authPath: string): boolean {
try {
const parsed: unknown = JSON.parse(readFileSync(authPath, 'utf8'))
if (!isRecord(parsed) || Object.keys(parsed).length === 0) {
return false
}
const auth = parsed as StoredCodexAuth
return auth.auth_mode == null
? hasCredentialWithoutDeclaredMode(auth)
: hasCredentialForDeclaredMode(auth)
} catch {
return false
}
}
function hasCredentialForDeclaredMode(auth: StoredCodexAuth): boolean {
if (!isNonEmptyString(auth.auth_mode)) {
return false
}
switch (auth.auth_mode) {
case 'apikey':
return isNonEmptyString(auth.OPENAI_API_KEY)
case 'chatgpt':
case 'chatgptAuthTokens':
return hasChatGptCredential(auth.tokens)
case 'agentIdentity':
return hasAgentIdentityCredential(auth.agent_identity)
case 'personalAccessToken':
return isNonEmptyString(auth.personal_access_token)
case 'bedrockApiKey':
return hasBedrockApiKey(auth.bedrock_api_key)
default:
return true
}
}
function hasCredentialWithoutDeclaredMode(auth: StoredCodexAuth): boolean {
const knownCredentialPresent = KNOWN_CREDENTIAL_KEYS.some((key) => key in auth)
if (knownCredentialPresent) {
return (
isNonEmptyString(auth.OPENAI_API_KEY) ||
hasChatGptCredential(auth.tokens) ||
hasAgentIdentityCredential(auth.agent_identity) ||
isNonEmptyString(auth.personal_access_token) ||
hasBedrockApiKey(auth.bedrock_api_key)
)
}
return Object.keys(auth).some((key) => key !== 'auth_mode' && key !== 'last_refresh')
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0
}
function hasChatGptCredential(tokens: unknown): boolean {
if (!isRecord(tokens)) {
return false
}
return (
isNonEmptyString(tokens.access_token) &&
isNonEmptyString(tokens.id_token) &&
isNonEmptyString(tokens.refresh_token)
)
}
function hasAgentIdentityCredential(value: unknown): boolean {
if (isNonEmptyString(value)) {
return true
}
if (!isRecord(value) || Object.keys(value).length === 0) {
return false
}
return KNOWN_AGENT_IDENTITY_RECORD_KEYS.some((key) => key in value)
? isNonEmptyString(value.agent_runtime_id) && isNonEmptyString(value.agent_private_key)
: true
}
function hasBedrockApiKey(value: unknown): boolean {
return isRecord(value) && isNonEmptyString(value.api_key)
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value)
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}

View File

@ -179,9 +179,10 @@ describe('CodexRuntimeHomeService per-account takeover composition', () => {
rmSync(accountAuthPath)
writeFileSync(sharedAuthPath(), laterShared, 'utf-8')
expect(service.prepareForCodexLaunch()).toBeNull()
expect(service.prepareForCodexLaunch()).toBe(account.managedHomePath)
expect(service.prepareForRateLimitFetch()).toBe(account.managedHomePath)
expect(existsSync(accountAuthPath)).toBe(false)
expect(settings.activeCodexManagedAccountId).toBeNull()
expect(settings.activeCodexManagedAccountId).toBe(account.id)
expect(readFileSync(sharedAuthPath(), 'utf-8')).toBe(laterShared)
expect(readFileSync(systemAuthPath(), 'utf-8')).toBe('system auth sentinel\n')
})

View File

@ -268,7 +268,9 @@ function createCodexAuthJson(
].join('.')
return `${JSON.stringify({
auth_mode: 'chatgpt',
tokens: {
access_token: `access-${accountId}`,
id_token: idToken,
account_id: accountId,
...(expiresAt === undefined ? {} : { expires_at: expiresAt }),
@ -1194,6 +1196,12 @@ describe('CodexRuntimeHomeService', () => {
settings.activeCodexManagedAccountId = 'account-2'
settings.activeCodexManagedAccountIdsByRuntime = { host: 'account-2', wsl: {} }
expect(service.prepareForCodexLaunch()).toBe(home2)
expect(
service.prepareForCodexLaunch(undefined, undefined, {
unavailableManagedHomePath: home1
})
).toBe(home2)
expect(store.updateSettings).not.toHaveBeenCalled()
// Nothing is hot-swapped, so the still-running account-1 pane keeps seeing
// account-1's credentials — the single-auth.json race (GAP-5) is gone.
@ -1280,7 +1288,7 @@ describe('CodexRuntimeHomeService', () => {
expect(service.prepareForRateLimitFetch()).toBe(home1)
})
it('drops a managed selection whose auth.json vanished and resolves the real home (flag ON)', async () => {
it('preserves a managed selection whose auth.json is temporarily missing (flag ON)', async () => {
writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8')
// A managed home that has lost its auth.json (only the marker remains).
const brokenHome = join(testState.userDataDir, 'codex-accounts', 'account-1', 'home')
@ -1308,9 +1316,9 @@ describe('CodexRuntimeHomeService', () => {
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
const service = new CodexRuntimeHomeService(store as never)
// The broken account is dropped and the launch resolves to the real home.
expect(service.prepareForCodexLaunch()).toBeNull()
expect(store.getSettings().activeCodexManagedAccountId).toBeNull()
expect(service.prepareForCodexLaunch()).toBe(brokenHome)
expect(service.prepareForRateLimitFetch()).toBe(brokenHome)
expect(store.getSettings().activeCodexManagedAccountId).toBe('account-1')
})
it('keeps the shared runtime home + auth hot-swap for managed accounts when the flag is OFF', async () => {
@ -1641,13 +1649,18 @@ describe('CodexRuntimeHomeService', () => {
expect(syncSpy).not.toHaveBeenCalled()
})
it('fails closed when per-account auth disappears before a real-home fallback', async () => {
it('preserves selected identity when per-account auth disappears before launch', async () => {
const runtimeAuthPath = getRuntimeCodexAuthPath()
const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system')
const account1Auth = createCodexAuthJson('one@example.com', 'acct-1', 'one', 1)
const account1Refreshed = createCodexAuthJson('one@example.com', 'acct-1', 'one-refreshed', 2)
writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8')
const managedHomePath1 = createManagedAuth(testState.userDataDir, 'account-1', account1Auth)
const wslManagedHomePath = createManagedAuth(
testState.userDataDir,
'wsl-account',
createCodexAuthJson('wsl@example.com', 'acct-wsl', 'wsl')
)
const settings = createSettings({
codexSystemDefaultRealHomeEnabled: true,
codexManagedAccounts: [
@ -1661,10 +1674,24 @@ describe('CodexRuntimeHomeService', () => {
createdAt: 1,
updatedAt: 1,
lastAuthenticatedAt: 1
},
{
id: 'wsl-account',
email: 'wsl@example.com',
managedHomePath: wslManagedHomePath,
managedHomeRuntime: 'wsl',
wslDistro: 'Ubuntu',
wslLinuxHomePath: '/home/alice/.local/share/orca/codex-accounts/wsl-account/home',
createdAt: 2,
updatedAt: 2,
lastAuthenticatedAt: 2
}
],
activeCodexManagedAccountId: 'account-1',
activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} }
activeCodexManagedAccountIdsByRuntime: {
host: 'account-1',
wsl: { Ubuntu: 'wsl-account' }
}
})
const store = createStore(settings)
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
@ -1674,27 +1701,52 @@ describe('CodexRuntimeHomeService', () => {
// A stale pre-E process leaves a matching refresh in the shared runtime home.
writeFileSync(runtimeAuthPath, account1Refreshed, 'utf-8')
// The active account's canonical auth disappears before launch. This is the
// same-account auto-deselect path, where no ordinary outgoing switch runs.
// The active account's canonical auth disappears before launch.
rmSync(join(managedHomePath1, 'auth.json'), { force: true })
expect(service.prepareForCodexLaunch()).toBeNull()
expect(service.prepareForCodexLaunch()).toBe(managedHomePath1)
// Missing canonical auth clears selection without reviving shared bytes.
// Missing canonical auth preserves selection without reviving shared bytes.
expect(existsSync(join(managedHomePath1, 'auth.json'))).toBe(false)
expect(readFileSync(getSystemCodexAuthPath(), 'utf-8')).toBe(systemAuth)
expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(account1Refreshed)
expect(store.getSettings().activeCodexManagedAccountId).toBe('account-1')
expect(store.updateSettings).not.toHaveBeenCalled()
expect(warnSpy).not.toHaveBeenCalled()
expect(service.isHostSystemDefaultRealHome()).toBe(false)
expect(service.prepareForRateLimitFetch()).toBe(managedHomePath1)
expect(service.prepareForCodexLaunch()).toBe(managedHomePath1)
expect(existsSync(join(managedHomePath1, 'auth.json'))).toBe(false)
writeFileSync(join(managedHomePath1, 'auth.json'), account1Auth, 'utf-8')
expect(
service.prepareForCodexLaunch(undefined, undefined, {
unavailableManagedHomePath: managedHomePath1
})
).toBe(managedHomePath1)
expect(store.updateSettings).not.toHaveBeenCalled()
rmSync(join(managedHomePath1, 'auth.json'), { force: true })
expect(
service.prepareForCodexLaunch(undefined, undefined, {
unavailableManagedHomePath: managedHomePath1
})
).toBeNull()
expect(store.getSettings().activeCodexManagedAccountId).toBeNull()
expect(store.getSettings().activeCodexManagedAccountIdsByRuntime).toEqual({
host: null,
wsl: { Ubuntu: 'wsl-account' }
})
expect(store.getSettings().codexManagedAccounts).toHaveLength(2)
expect(store.updateSettings).toHaveBeenCalledWith(
expect.objectContaining({ activeCodexManagedAccountId: null })
)
expect(warnSpy).toHaveBeenCalled()
// The follow-up launch takes the real-home lane and remains fail-closed.
expect(service.isHostSystemDefaultRealHome()).toBe(true)
expect(service.prepareForCodexLaunch()).toBeNull()
expect(existsSync(join(managedHomePath1, 'auth.json'))).toBe(false)
expect(warnSpy).toHaveBeenCalledWith(
'[codex-runtime-home] Active managed account credential remained unavailable, clearing selection'
)
})
it('refuses mismatched runtime auth when the active managed auth is missing', async () => {
it('ignores mismatched runtime auth when the active managed auth is missing', async () => {
const runtimeAuthPath = getRuntimeCodexAuthPath()
const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system')
const managedAuth = createCodexAuthJson('user@example.com', 'acct-user', 'managed', 1)
@ -1726,12 +1778,13 @@ describe('CodexRuntimeHomeService', () => {
writeFileSync(runtimeAuthPath, mismatchedAuth, 'utf-8')
rmSync(join(managedHomePath, 'auth.json'), { force: true })
expect(service.prepareForCodexLaunch()).toBeNull()
expect(service.prepareForCodexLaunch()).toBe(managedHomePath)
expect(existsSync(join(managedHomePath, 'auth.json'))).toBe(false)
expect(readFileSync(getSystemCodexAuthPath(), 'utf-8')).toBe(systemAuth)
expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(mismatchedAuth)
expect(warnSpy).toHaveBeenCalled()
expect(store.getSettings().activeCodexManagedAccountId).toBe('account-1')
expect(warnSpy).not.toHaveBeenCalled()
})
it('ignores shared auth on an explicit managed-to-real-home deselect', async () => {

View File

@ -29,6 +29,7 @@ import {
} from 'node:path'
import { app } from 'electron'
import type { CodexManagedAccount } from '../../shared/types'
import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'
import type { Store } from '../persistence'
import { WSL_CODEX_RUNTIME_HOME_SEGMENTS } from '../pty/codex-home-wsl-env'
import { writeFileAtomically } from './fs-utils'
@ -72,6 +73,7 @@ import {
codexAuthMatchesSystemDefaultIdentity
} from './codex-auth-identity'
import { migrateLegacySharedAuthToPerAccountHome } from './legacy-shared-auth-migration'
import { hasStoredCodexCredential } from './managed-codex-auth-readiness'
type CodexSystemDefaultSnapshot = {
authJson: string | null
@ -153,7 +155,8 @@ export class CodexRuntimeHomeService {
*/
prepareForCodexLaunch(
target?: CodexAccountSelectionTarget,
launchEnv?: NodeJS.ProcessEnv
launchEnv?: NodeJS.ProcessEnv,
options?: { unavailableManagedHomePath?: string }
): string | null {
if (target?.runtime === 'wsl') {
const wslTarget = this.resolveWslDefaultTarget(target)
@ -165,12 +168,15 @@ export class CodexRuntimeHomeService {
}
const selfContainedAccount = this.getSelfContainedManagedHostAccount()
if (selfContainedAccount) {
const perAccountHome = this.prepareSelfContainedManagedHomeForLaunch(selfContainedAccount)
const perAccountHome = this.prepareSelfContainedManagedHomeForLaunch(
selfContainedAccount,
options?.unavailableManagedHomePath
)
if (perAccountHome) {
return perAccountHome
}
// Why: the account's home lost its auth.json, so the selection was just
// dropped. Fall through and resolve this launch as the system default.
// Why: only an untrusted home clears the selection; fall through to the
// system default without injecting a path Orca cannot prove it owns.
}
if (this.isHostSystemDefaultRealHome(launchEnv)) {
// Why (flag ON, system default): run Codex on the user's own ~/.codex.
@ -235,14 +241,24 @@ export class CodexRuntimeHomeService {
return homes
}
private prepareSelfContainedManagedHomeForLaunch(account: CodexManagedAccount): string | null {
private prepareSelfContainedManagedHomeForLaunch(
account: CodexManagedAccount,
unavailableManagedHomePath?: string
): string | null {
const perAccountHome = this.getTrustedSelfContainedManagedHomePath(account)
if (!perAccountHome || !existsSync(join(perAccountHome, 'auth.json'))) {
// Why: drop the selection so this and future launches resolve to the
// system default rather than a home codex cannot authenticate against.
if (!perAccountHome) {
this.clearSelfContainedManagedSelection(account)
return null
}
if (
unavailableManagedHomePath &&
normalizeRuntimePathForComparison(unavailableManagedHomePath) ===
normalizeRuntimePathForComparison(perAccountHome) &&
!hasStoredCodexCredential(join(perAccountHome, 'auth.json'))
) {
this.clearSelfContainedManagedSelection(account, 'credential remained unavailable')
return null
}
// Why: link the user's real ~/.codex resources and mirror config into THIS
// home (never symlinking into or mutating ~/.codex), so the per-account home
// is a complete CODEX_HOME. Hooks/trust are installed by the launch caller.
@ -282,11 +298,11 @@ export class CodexRuntimeHomeService {
// Why: the per-account home is both the launch CODEX_HOME and the credential
// store, so codex reads/refreshes auth.json in place — there is no shared-home
// hot-swap or token read-back to reconcile. Only validate the credential
// survives; a vanished auth.json drops the selection to the system default.
// 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)
if (perAccountHome && existsSync(join(perAccountHome, 'auth.json'))) {
if (perAccountHome) {
this.lastSyncedAccountId = account.id
this.lastHostAccountUsedSelfContainedHome = true
// Why: selection runs well before the user restarts a pane, so history is
@ -314,10 +330,11 @@ export class CodexRuntimeHomeService {
}
}
private clearSelfContainedManagedSelection(account: CodexManagedAccount): void {
console.warn(
'[codex-runtime-home] Active managed account home is invalid or missing auth.json, clearing selection'
)
private clearSelfContainedManagedSelection(
account: CodexManagedAccount,
reason = 'home is invalid'
): void {
console.warn(`[codex-runtime-home] Active managed account ${reason}, clearing selection`)
const settings = this.store.getSettings()
if (normalizeCodexRuntimeSelection(settings).host !== account.id) {
return
@ -516,14 +533,9 @@ export class CodexRuntimeHomeService {
const selfContainedHome = selfContainedAccount
? this.getTrustedSelfContainedManagedHomePath(selfContainedAccount)
: null
if (
selfContainedAccount &&
selfContainedHome &&
existsSync(join(selfContainedHome, 'auth.json'))
) {
if (selfContainedAccount && selfContainedHome) {
// Why: the quota fetch reads the account's own auth.json in place; no
// shared-home hot-swap, and no per-poll resource relink (that is launch
// prep). Config was mirrored on add/select and refreshed at launch.
// shared-home hot-swap or per-poll resource relink (that is launch prep).
return selfContainedHome
}
if (selfContainedAccount) {

View File

@ -28,7 +28,8 @@ import {
registerPaneKeyTeardownListener,
getLocalPtyProvider,
getSshPtyProvider,
registerHeadlessPtyRuntime
registerHeadlessPtyRuntime,
type CodexHomeLaunchContext
} from './ipc/pty'
import {
initDaemonPtyProvider,
@ -98,7 +99,7 @@ import {
resolveUpdateInstallMode
} from './updater'
import { configureRemoteServerUpdater } from './runtime/remote-server-updater'
import type { TuiAgent, UpdateCheckOptions } from '../shared/types'
import type { UpdateCheckOptions } from '../shared/types'
import { recordUpdaterLifecycle } from './updater-lifecycle-diagnostics'
import {
installServeSupervisorDisconnectQuit,
@ -890,7 +891,7 @@ function startTerminalRuntimeStartupServices(): Promise<void> {
function prepareCodexRuntimeHomeForLaunch(
target?: CodexAccountSelectionTarget,
launchEnv?: NodeJS.ProcessEnv,
launchContext?: { workspacePath?: string; launchAgent?: TuiAgent }
launchContext?: CodexHomeLaunchContext
): string | null {
if (
target?.runtime !== 'wsl' &&
@ -922,14 +923,18 @@ function prepareCodexRuntimeHomeForLaunch(
return true
}
let realHomeHooksPrepared = ensureRealHomeHooksIfSelected()
let runtimeHomePath = codexRuntimeHome!.prepareForCodexLaunch(target, launchEnv)
let runtimeHomePath = codexRuntimeHome!.prepareForCodexLaunch(target, launchEnv, {
unavailableManagedHomePath: launchContext?.unavailableManagedHomePath
})
if (runtimeHomePath === null && !realHomeHooksPrepared) {
// Why: a managed home can lose auth during launch prep, which clears its
// selection and falls through to real home. Establish hook capability for
// that newly selected lane, then re-resolve if the capability gate rejects it.
// Why: launch prep can reject an untrusted managed home and clear its
// selection. Establish hook capability for that newly selected lane, then
// re-resolve if the capability gate rejects it.
realHomeHooksPrepared = ensureRealHomeHooksIfSelected()
if (realHomeHooksPrepared) {
runtimeHomePath = codexRuntimeHome!.prepareForCodexLaunch(target, launchEnv)
runtimeHomePath = codexRuntimeHome!.prepareForCodexLaunch(target, launchEnv, {
unavailableManagedHomePath: launchContext?.unavailableManagedHomePath
})
}
}
if (runtimeHomePath === null && target?.runtime !== 'wsl') {

View File

@ -226,6 +226,7 @@ import {
setPtyOwnership,
setLocalPtyProvider,
rebindLocalProviderListeners,
resolveCodexHomeAfterManagedAuthReadiness,
unregisterSshPtyProvider,
getLocalPtyProvider,
isCurrentPtyExit,
@ -269,6 +270,15 @@ const TEST_CODEX_HOME =
process.platform === 'win32'
? 'C:\\Users\\test\\AppData\\Roaming\\orca\\codex-runtime-home\\home'
: '/tmp/orca-codex-home'
const TEST_CODEX_AUTH_JSON = JSON.stringify({
tokens: {
access_token: 'access',
id_token: 'e30.eyJlbWFpbCI6InVzZXJAZXhhbXBsZS5jb20ifQ.sig',
refresh_token: 'refresh',
account_id: 'account'
},
last_refresh: '2026-07-31T00:00:00Z'
})
function makeDisposable() {
return { dispose: vi.fn() }
@ -2768,6 +2778,70 @@ describe('registerPtyHandlers', () => {
expect(env.ORCA_CODEX_HOME).toBe(TEST_CODEX_HOME)
})
it('waits for managed Codex auth before spawning a local PTY', 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
})
handlers.clear()
registerPtyHandlers(mainWindow as never, undefined, () => TEST_CODEX_HOME, (() => ({
codexManagedAccounts: [
{
id: 'account-1',
managedHomePath: TEST_CODEX_HOME,
managedHomeRuntime: 'host'
}
]
})) as never)
const spawnPromise = handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
launchAgent: 'codex'
})
await vi.advanceTimersByTimeAsync(0)
expect(spawnMock).not.toHaveBeenCalled()
authReady = true
await vi.advanceTimersByTimeAsync(25)
await spawnPromise
expect(spawnMock.mock.calls.at(-1)?.[2].env).toMatchObject({
CODEX_HOME: TEST_CODEX_HOME,
ORCA_CODEX_HOME: TEST_CODEX_HOME
})
})
it('does not gate a bare local shell on managed Codex auth', async () => {
readFileSyncMock.mockImplementation((filePath: string) => {
if (filePath.endsWith('auth.json')) {
throw Object.assign(new Error('missing auth'), { code: 'ENOENT' })
}
return ''
})
handlers.clear()
registerPtyHandlers(mainWindow as never, undefined, () => TEST_CODEX_HOME, (() => ({
codexManagedAccounts: [
{
id: 'account-1',
managedHomePath: TEST_CODEX_HOME,
managedHomeRuntime: 'host'
}
]
})) as never)
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
expect(spawnMock).toHaveBeenCalledOnce()
})
it('leaves an inherited CODEX_HOME untouched for system default when the flag is OFF', async () => {
// Why: flag OFF must stay byte-identical to today. With no managed home
// selected (resolver null) and the real-home flag off, no CODEX_HOME
@ -3016,6 +3090,371 @@ describe('registerPtyHandlers', () => {
).env
}
it('waits for managed Codex auth before spawning a daemon PTY', 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()
handlers.clear()
registerPtyHandlers(mainWindow as never, undefined, () => TEST_CODEX_HOME, (() => ({
codexManagedAccounts: [
{
id: 'account-1',
managedHomePath: TEST_CODEX_HOME,
managedHomeRuntime: 'host'
}
]
})) as never)
const spawnPromise = handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
launchAgent: 'codex'
})
await vi.advanceTimersByTimeAsync(0)
expect(daemonSpawn).not.toHaveBeenCalled()
authReady = true
await vi.advanceTimersByTimeAsync(25)
await spawnPromise
expect(daemonSpawn.mock.calls.at(-1)?.[0].env).toMatchObject({
CODEX_HOME: TEST_CODEX_HOME,
ORCA_CODEX_HOME: TEST_CODEX_HOME
})
})
it('resolves valid managed Codex auth synchronously', () => {
readFileSyncMock.mockReturnValue(TEST_CODEX_AUTH_JSON)
const resolveCurrent = vi.fn(() => TEST_CODEX_HOME)
const resolveAfterUnavailable = vi.fn(() => null)
const settings = {
codexManagedAccounts: [
{
id: 'account-1',
managedHomePath: TEST_CODEX_HOME,
managedHomeRuntime: 'host'
}
]
}
const resolution = resolveCodexHomeAfterManagedAuthReadiness({
selectedCodexHomePath: TEST_CODEX_HOME,
getSettings: () => settings as never,
target: { runtime: 'host' },
resolveCurrent,
resolveAfterUnavailable
})
expect(resolution).toBe(TEST_CODEX_HOME)
expect(resolveCurrent).not.toHaveBeenCalled()
expect(resolveAfterUnavailable).not.toHaveBeenCalled()
})
it('uses the current account when the original auth recovers after a switch', async () => {
vi.useFakeTimers()
const nextHome = '/managed/next/home'
let originalAuthReady = false
let selectedHome = TEST_CODEX_HOME
readFileSyncMock.mockImplementation((filePath: string) => {
if (filePath === join(TEST_CODEX_HOME, 'auth.json') && !originalAuthReady) {
throw Object.assign(new Error('missing auth'), { code: 'ENOENT' })
}
if (filePath.endsWith('auth.json')) {
return TEST_CODEX_AUTH_JSON
}
return ''
})
const daemonSpawn = setupDaemonAdapter()
const resolveHome = vi.fn(() => selectedHome)
handlers.clear()
registerPtyHandlers(mainWindow as never, undefined, resolveHome, (() => ({
codexManagedAccounts: [TEST_CODEX_HOME, nextHome].map((managedHomePath, index) => ({
id: `account-${index + 1}`,
managedHomePath,
managedHomeRuntime: 'host'
}))
})) as never)
const spawnPromise = handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
launchAgent: 'codex'
})
await vi.advanceTimersByTimeAsync(0)
selectedHome = nextHome
originalAuthReady = true
await vi.advanceTimersByTimeAsync(25)
await spawnPromise
expect(resolveHome).toHaveBeenCalledTimes(2)
expect(daemonSpawn.mock.calls[0]?.[0].env).toMatchObject({
CODEX_HOME: nextHome,
ORCA_CODEX_HOME: nextHome
})
})
it('does not gate a non-Codex daemon PTY on managed Codex auth', async () => {
readFileSyncMock.mockImplementation((filePath: string) => {
if (filePath.endsWith('auth.json')) {
throw Object.assign(new Error('missing auth'), { code: 'ENOENT' })
}
return ''
})
const daemonSpawn = setupDaemonAdapter()
handlers.clear()
registerPtyHandlers(mainWindow as never, undefined, () => TEST_CODEX_HOME, (() => ({
codexManagedAccounts: [
{
id: 'account-1',
managedHomePath: TEST_CODEX_HOME,
managedHomeRuntime: 'host'
}
]
})) as never)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
launchAgent: 'claude'
})
expect(daemonSpawn).toHaveBeenCalledOnce()
})
it('does not gate a Codex daemon reattach on current managed auth', async () => {
readFileSyncMock.mockImplementation((filePath: string) => {
if (filePath.endsWith('auth.json')) {
throw Object.assign(new Error('missing auth'), { code: 'ENOENT' })
}
return ''
})
const daemonSpawn = setupDaemonAdapter()
handlers.clear()
registerPtyHandlers(mainWindow as never, undefined, () => TEST_CODEX_HOME, (() => ({
codexManagedAccounts: [
{
id: 'account-1',
managedHomePath: TEST_CODEX_HOME,
managedHomeRuntime: 'host'
}
]
})) as never)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
launchAgent: 'codex',
sessionId: 'retained-codex'
})
expect(daemonSpawn).toHaveBeenCalledOnce()
})
it('does not gate a runtime-created Codex reattach on current managed auth', async () => {
type RuntimeSpawnController = {
spawn(args: {
cols: number
rows: number
launchAgent: 'codex'
sessionId: string
}): Promise<{ id: string }>
}
readFileSyncMock.mockImplementation((filePath: string) => {
if (filePath.endsWith('auth.json')) {
throw Object.assign(new Error('missing auth'), { code: 'ENOENT' })
}
return ''
})
const daemonSpawn = setupDaemonAdapter()
const runtime = {
setPtyController: vi.fn(),
registerPty: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never, () => TEST_CODEX_HOME, (() => ({
codexManagedAccounts: [
{
id: 'account-1',
managedHomePath: TEST_CODEX_HOME,
managedHomeRuntime: 'host'
}
]
})) as never)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as RuntimeSpawnController
await controller.spawn({
cols: 80,
rows: 24,
launchAgent: 'codex',
sessionId: 'retained-runtime-codex'
})
expect(daemonSpawn).toHaveBeenCalledOnce()
})
it('falls back when managed Codex auth stays unavailable', async () => {
vi.useFakeTimers()
readFileSyncMock.mockImplementation((filePath: string) => {
if (filePath.endsWith('auth.json')) {
throw Object.assign(new Error('missing auth'), { code: 'ENOENT' })
}
return ''
})
const daemonSpawn = setupDaemonAdapter()
const resolveHome = vi.fn(
(
_target?: unknown,
_env?: NodeJS.ProcessEnv,
context?: { unavailableManagedHomePath?: string }
) => (context?.unavailableManagedHomePath ? null : TEST_CODEX_HOME)
)
handlers.clear()
registerPtyHandlers(mainWindow as never, undefined, resolveHome, (() => ({
codexManagedAccounts: [
{
id: 'account-1',
managedHomePath: TEST_CODEX_HOME,
managedHomeRuntime: 'host'
}
]
})) as never)
const spawnPromise = handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
launchAgent: 'codex'
})
await vi.advanceTimersByTimeAsync(2_000)
await spawnPromise
expect(resolveHome).toHaveBeenCalledTimes(2)
expect(resolveHome.mock.calls[1]?.[2]).toMatchObject({
unavailableManagedHomePath: TEST_CODEX_HOME
})
expect(daemonSpawn).toHaveBeenCalledOnce()
expect(daemonSpawn.mock.calls[0]?.[0].env).not.toHaveProperty('CODEX_HOME')
})
it('rejects when account changes keep resolving unavailable managed homes', async () => {
vi.useFakeTimers()
const secondHome = '/managed/second/home'
const thirdHome = '/managed/third/home'
readFileSyncMock.mockImplementation((filePath: string) => {
if (filePath.endsWith('auth.json')) {
throw Object.assign(new Error('missing auth'), { code: 'ENOENT' })
}
return ''
})
const daemonSpawn = setupDaemonAdapter()
const resolveHome = vi.fn(
(
_target?: unknown,
_env?: NodeJS.ProcessEnv,
context?: { unavailableManagedHomePath?: string }
) =>
!context?.unavailableManagedHomePath
? TEST_CODEX_HOME
: context.unavailableManagedHomePath === TEST_CODEX_HOME
? secondHome
: thirdHome
)
handlers.clear()
registerPtyHandlers(mainWindow as never, undefined, resolveHome, (() => ({
codexManagedAccounts: [TEST_CODEX_HOME, secondHome, thirdHome].map(
(managedHomePath, index) => ({
id: `account-${index + 1}`,
managedHomePath,
managedHomeRuntime: 'host'
})
)
})) as never)
const spawnPromise = handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
launchAgent: 'codex'
})
const rejection = expect(spawnPromise).rejects.toThrow(
'The selected Codex account credentials are temporarily unavailable. Try opening the terminal again.'
)
await vi.advanceTimersByTimeAsync(4_000)
await rejection
expect(resolveHome.mock.calls.map((call) => call[2]?.unavailableManagedHomePath)).toEqual([
undefined,
TEST_CODEX_HOME,
secondHome
])
expect(vi.getTimerCount()).toBe(0)
expect(daemonSpawn).not.toHaveBeenCalled()
})
it('falls back for a runtime-created Codex launch when auth stays unavailable', async () => {
type RuntimeSpawnController = {
spawn(args: { cols: number; rows: number; launchAgent: 'codex' }): Promise<{ id: string }>
}
vi.useFakeTimers()
readFileSyncMock.mockImplementation((filePath: string) => {
if (filePath.endsWith('auth.json')) {
throw Object.assign(new Error('missing auth'), { code: 'ENOENT' })
}
return ''
})
const daemonSpawn = setupDaemonAdapter()
const resolveHome = vi.fn(
(
_target?: unknown,
_env?: NodeJS.ProcessEnv,
context?: { unavailableManagedHomePath?: string }
) => (context?.unavailableManagedHomePath ? null : TEST_CODEX_HOME)
)
const runtime = {
setPtyController: vi.fn(),
registerPty: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never, resolveHome, (() => ({
codexManagedAccounts: [
{
id: 'account-1',
managedHomePath: TEST_CODEX_HOME,
managedHomeRuntime: 'host'
}
]
})) as never)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as RuntimeSpawnController
const spawnPromise = controller.spawn({ cols: 80, rows: 24, launchAgent: 'codex' })
await vi.advanceTimersByTimeAsync(0)
expect(daemonSpawn).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(2_000)
await spawnPromise
expect(resolveHome).toHaveBeenCalledTimes(2)
expect(resolveHome.mock.calls[1]?.[2]).toMatchObject({
unavailableManagedHomePath: TEST_CODEX_HOME
})
expect(daemonSpawn).toHaveBeenCalledOnce()
expect(daemonSpawn.mock.calls[0]?.[0].env).not.toHaveProperty('CODEX_HOME')
})
it('injects OpenCode plugin env (OPENCODE_CONFIG_DIR) on the daemon path', async () => {
const env = await daemonSpawnAndGetEnv({}, undefined, undefined, {
OPENCODE_CONFIG_DIR: undefined
@ -14574,6 +15013,70 @@ describe('registerPtyHandlers', () => {
])
})
it('does not resume under another account when the origin auth stays unavailable', async () => {
vi.useFakeTimers()
const spawn = vi.fn(async () => ({ id: 'pty-must-not-spawn' }))
setLocalPtyProvider({
spawn,
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(),
shutdown: vi.fn(),
onData: vi.fn(() => vi.fn()),
onExit: vi.fn(() => vi.fn()),
listProcesses: vi.fn(async () => []),
getForegroundProcess: vi.fn(async () => null)
} as never)
readFileSyncMock.mockImplementation((filePath: string) => {
if (filePath.endsWith('auth.json')) {
throw Object.assign(new Error('missing auth'), { code: 'ENOENT' })
}
return ''
})
const resolveHome = vi.fn(() => '/managed/current/home')
registerPtyHandlers(
mainWindow as never,
undefined,
resolveHome,
(() => ({
codexManagedAccounts: [
{ id: 'account-a', managedHomePath: '/managed/origin/home' },
{ id: 'account-b', managedHomePath: '/managed/current/home' }
]
})) as never,
undefined,
undefined,
{
prepareCodexSessionResume: async () => ({
outcome: 'resume' as const,
codexHomePath: '/managed/origin/home'
})
}
)
const launch = handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
command: 'codex resume session-a',
envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME'],
launchAgent: 'codex',
resumeProviderSession: {
key: 'session_id',
id: 'session-a',
transcriptPath: '/managed/origin/home/sessions/2026/07/20/rollout-a.jsonl'
}
})
const rejection = expect(launch).rejects.toThrow(
'The Codex account credentials for this session are temporarily unavailable. Try opening the terminal again.'
)
await vi.advanceTimersByTimeAsync(2_000)
await rejection
expect(resolveHome).not.toHaveBeenCalled()
expect(spawn).not.toHaveBeenCalled()
expect(recordCodexPaneAccountMock).not.toHaveBeenCalled()
})
it('records the origin account a resumed Codex pane is pinned to', async () => {
setLocalPtyProvider({
spawn: vi.fn(async () => ({ id: 'pty-resumed' })),
@ -14607,6 +15110,7 @@ describe('registerPtyHandlers', () => {
})
}
)
readFileSyncMock.mockReturnValue(TEST_CODEX_AUTH_JSON)
await handlers.get('pty:spawn')!(null, {
cols: 80,
@ -14625,6 +15129,7 @@ describe('registerPtyHandlers', () => {
expect(recordCodexPaneAccountMock.mock.calls).toEqual([
['pty-resumed', { selectionKey: 'host', accountId: 'account-a' }]
])
expect(readFileSyncMock).toHaveBeenCalledWith('/managed/origin/home/auth.json', 'utf8')
expect(forgetCodexPaneAccountMock).not.toHaveBeenCalled()
})
@ -14726,6 +15231,7 @@ describe('registerPtyHandlers', () => {
}
)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as RuntimeSpawnController
readFileSyncMock.mockReturnValue(TEST_CODEX_AUTH_JSON)
await controller.spawn({
cols: 80,
@ -14743,6 +15249,7 @@ describe('registerPtyHandlers', () => {
expect(recordCodexPaneAccountMock.mock.calls).toEqual([
['pty-runtime-resumed', { selectionKey: 'host', accountId: 'account-a' }]
])
expect(readFileSyncMock).toHaveBeenCalledWith('/managed/origin/home/auth.json', 'utf8')
expect(forgetCodexPaneAccountMock).not.toHaveBeenCalled()
})

View File

@ -187,6 +187,10 @@ import { setTerminalViewAttributes } from '../runtime/terminal-view-attribute-st
import { validateTerminalViewAttributes } from '../../shared/terminal-view-attributes'
import type { PtyModelRestoreReason } from '../../shared/pty-model-restore-marker'
import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection'
import {
isCodexHomeAuthReadyForLaunch,
waitForManagedCodexAuthReady
} from '../codex-accounts/managed-codex-auth-readiness'
import {
forgetCodexPaneAccount,
recordCodexPaneAccount
@ -823,10 +827,16 @@ function getLocalOrcaCodexHomeEnvKeysToDelete(env: Record<string, string>): stri
return keysToDelete
}
export type CodexHomeLaunchContext = {
workspacePath?: string
launchAgent?: TuiAgent
unavailableManagedHomePath?: string
}
export type GetSelectedCodexHomePath = (
target?: CodexAccountSelectionTarget,
launchEnv?: NodeJS.ProcessEnv,
launchContext?: { workspacePath?: string; launchAgent?: TuiAgent }
launchContext?: CodexHomeLaunchContext
) => string | null
export type PrepareCodexSessionResume = (args: {
providerSession: AgentProviderSessionMetadata
@ -866,6 +876,113 @@ function getCompatibleSelectedCodexHomePath(
: selectedCodexHomePath
}
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.'
type ManagedCodexAuthResolutionArgs = {
selectedCodexHomePath: string | null
getSettings: () => GlobalSettings | undefined
requiredCodexHomePath?: string
target: CodexAccountSelectionTarget
resolveCurrent: () => string | null
resolveAfterUnavailable: (unavailableManagedHomePath: string) => string | null
}
export function resolveCodexHomeAfterManagedAuthReadiness(
args: ManagedCodexAuthResolutionArgs
): string | null | Promise<string | null> {
const selectedCodexHomePath = args.selectedCodexHomePath
if (
args.requiredCodexHomePath &&
!codexHomePathsEqual(selectedCodexHomePath, args.requiredCodexHomePath)
) {
throw new Error(CODEX_RESUME_AUTH_UNAVAILABLE_MESSAGE)
}
const readiness = waitForManagedCodexAuthReady({
codexHomePath: selectedCodexHomePath,
settings: args.getSettings(),
target: args.target
})
return readiness
? continueCodexHomeAfterManagedAuthWait(args, selectedCodexHomePath, readiness)
: selectedCodexHomePath
}
async function continueCodexHomeAfterManagedAuthWait(
args: ManagedCodexAuthResolutionArgs,
initialCodexHomePath: string | null,
initialReadiness: Promise<boolean>
): Promise<string | null> {
let selectedCodexHomePath = initialCodexHomePath
let readiness = initialReadiness
for (let attempt = 0; attempt < 2; attempt += 1) {
if (await readiness) {
if (args.requiredCodexHomePath) {
return selectedCodexHomePath
}
const currentCodexHomePath = args.resolveCurrent()
if (codexHomeSelectionsEqual(selectedCodexHomePath, currentCodexHomePath)) {
return selectedCodexHomePath
}
selectedCodexHomePath = currentCodexHomePath
if (attempt === 1) {
break
}
const nextReadiness = waitForManagedCodexAuthReady({
codexHomePath: selectedCodexHomePath,
settings: args.getSettings(),
target: args.target
})
if (!nextReadiness) {
return selectedCodexHomePath
}
readiness = nextReadiness
continue
}
if (args.requiredCodexHomePath) {
throw new Error(CODEX_RESUME_AUTH_UNAVAILABLE_MESSAGE)
}
selectedCodexHomePath = args.resolveAfterUnavailable(selectedCodexHomePath!)
if (attempt === 1) {
break
}
const nextReadiness = waitForManagedCodexAuthReady({
codexHomePath: selectedCodexHomePath,
settings: args.getSettings(),
target: args.target
})
if (!nextReadiness) {
return selectedCodexHomePath
}
readiness = nextReadiness
}
if (
isCodexHomeAuthReadyForLaunch({
codexHomePath: selectedCodexHomePath,
settings: args.getSettings(),
target: args.target
})
) {
return selectedCodexHomePath
}
throw new Error(MANAGED_CODEX_AUTH_UNAVAILABLE_MESSAGE)
}
function codexHomeSelectionsEqual(left: string | null, right: string | null): boolean {
return (
left === right ||
(left !== null &&
right !== null &&
normalizeRuntimePathForComparison(left) === normalizeRuntimePathForComparison(right))
)
}
function codexHomePathsEqual(left: string | null, right: string): boolean {
return codexHomeSelectionsEqual(left, right)
}
// Why: CODEX_HOME is fixed in a shell's environment at spawn and the daemon
// keeps that shell alive across app restarts, so the launch account is the only
// way to tell later that a pane still runs Codex as the previously selected
@ -3775,7 +3892,7 @@ export function registerPtyHandlers(
if (args.preAllocatedHandle) {
env = { ...env, ORCA_TERMINAL_HANDLE: args.preAllocatedHandle }
}
const selectedCodexHomePath = isDaemonHostSpawn
let selectedCodexHomePath = !args.connectionId
? getCompatibleSelectedCodexHomePath(
codexSelectionTarget,
codexResumeHome
@ -3786,6 +3903,35 @@ export function registerPtyHandlers(
}) ?? null)
)
: null
if (args.launchAgent === 'codex' && callerRequestedSessionId === undefined) {
const resolution = resolveCodexHomeAfterManagedAuthReadiness({
selectedCodexHomePath,
getSettings: () => getSettings?.(),
requiredCodexHomePath: codexResumeHome?.codexHomePath,
target: codexSelectionTarget,
resolveCurrent: () =>
getCompatibleSelectedCodexHomePath(
codexSelectionTarget,
getSelectedCodexHomePath?.(codexSelectionTarget, env, {
workspacePath: cwd,
launchAgent: 'codex'
}) ?? null
),
resolveAfterUnavailable: (unavailableManagedHomePath) =>
getCompatibleSelectedCodexHomePath(
codexSelectionTarget,
getSelectedCodexHomePath?.(codexSelectionTarget, env, {
workspacePath: cwd,
launchAgent: 'codex',
unavailableManagedHomePath
}) ?? null
)
})
selectedCodexHomePath = resolution instanceof Promise ? await resolution : resolution
}
const codexResumeHomeSelected = Boolean(
codexResumeHome && codexHomePathsEqual(selectedCodexHomePath, codexResumeHome.codexHomePath)
)
const skipCodexHomeEnv =
isDaemonHostSpawn &&
shouldSkipCodexHomeEnvForWindowsShell(daemonShellOverride, cwd) &&
@ -3836,8 +3982,8 @@ export function registerPtyHandlers(
env,
...(isMintedSessionId ? { isNewSession: true } : {})
}
if (!isDaemonHostSpawn && codexResumeHome) {
spawnOptions.codexHomePathOverride = { value: codexResumeHome.codexHomePath }
if (!args.connectionId && !isDaemonHostSpawn) {
spawnOptions.codexHomePathOverride = { value: selectedCodexHomePath }
}
const startupTerminalColorQueryReplyColors = getStartupTerminalColorQueryReplyColors(args)
if (startupTerminalColorQueryReplyColors) {
@ -3873,7 +4019,7 @@ export function registerPtyHandlers(
'ORCA_CODEX_HOME'
])
}
if (codexResumeHome?.codexHomePath) {
if (codexResumeHomeSelected) {
spawnOptions.envToDelete = removeCodexHomeDeletionRequests(spawnOptions.envToDelete)
}
deleteRequestedEnvKeys(env, spawnOptions.envToDelete)
@ -4213,7 +4359,7 @@ export function registerPtyHandlers(
ptyId: result.id,
isDaemonHostSpawn,
isReattach: result.isReattach === true,
pinnedByResume: Boolean(codexResumeHome),
pinnedByResume: codexResumeHomeSelected,
launchCodexHomePath: selectedCodexHomePath,
target: codexSelectionTarget,
settings: getSettings?.()
@ -4953,7 +5099,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
const selectedCodexHomePath = isDaemonHostSpawn
let selectedCodexHomePath = !args.connectionId
? getCompatibleSelectedCodexHomePath(
codexSelectionTarget,
codexResumeHome
@ -4964,6 +5110,35 @@ export function registerPtyHandlers(
}) ?? null)
)
: null
if (args.launchAgent === 'codex' && args.sessionId === undefined) {
const resolution = resolveCodexHomeAfterManagedAuthReadiness({
selectedCodexHomePath,
getSettings: () => getSettings?.(),
requiredCodexHomePath: codexResumeHome?.codexHomePath,
target: codexSelectionTarget,
resolveCurrent: () =>
getCompatibleSelectedCodexHomePath(
codexSelectionTarget,
getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv, {
workspacePath: cwd,
launchAgent: 'codex'
}) ?? null
),
resolveAfterUnavailable: (unavailableManagedHomePath) =>
getCompatibleSelectedCodexHomePath(
codexSelectionTarget,
getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv, {
workspacePath: cwd,
launchAgent: 'codex',
unavailableManagedHomePath
}) ?? null
)
})
selectedCodexHomePath = resolution instanceof Promise ? await resolution : resolution
}
const codexResumeHomeSelected = Boolean(
codexResumeHome && codexHomePathsEqual(selectedCodexHomePath, codexResumeHome.codexHomePath)
)
const skipCodexHomeEnv =
isDaemonHostSpawn &&
shouldSkipCodexHomeEnvForWindowsShell(effectiveShellOverride, cwd) &&
@ -5038,7 +5213,7 @@ export function registerPtyHandlers(
// main cannot safely decide ownership for a process it may not parent.
stripInheritedOrcaCodexHome ? ['ORCA_CODEX_HOME'] : []
)
if (codexResumeHome?.codexHomePath) {
if (codexResumeHomeSelected) {
combinedEnvToDelete = removeCodexHomeDeletionRequests(combinedEnvToDelete)
}
deleteRequestedEnvKeys(spawnEnv, combinedEnvToDelete)
@ -5050,8 +5225,8 @@ export function registerPtyHandlers(
env: spawnEnv,
...(isMintedSessionId ? { isNewSession: true } : {})
}
if (!isDaemonHostSpawn && codexResumeHome) {
spawnOptions.codexHomePathOverride = { value: codexResumeHome.codexHomePath }
if (!args.connectionId && !isDaemonHostSpawn) {
spawnOptions.codexHomePathOverride = { value: selectedCodexHomePath }
}
if (combinedEnvToDelete) {
spawnOptions.envToDelete = combinedEnvToDelete
@ -5268,7 +5443,7 @@ export function registerPtyHandlers(
ptyId: result.id,
isDaemonHostSpawn,
isReattach: result.isReattach === true,
pinnedByResume: Boolean(codexResumeHome),
pinnedByResume: codexResumeHomeSelected,
launchCodexHomePath: selectedCodexHomePath,
target: codexSelectionTarget,
settings: getSettings?.()

View File

@ -177,4 +177,37 @@ describe('fetchCodexRateLimits auth errors', () => {
error: authError
})
})
it('stops a PTY probe when Codex renders its sign-in screen', async () => {
const ptyHandlers: { onData?: (data: string) => void } = {}
const ptyWrite = vi.fn()
const ptyKill = vi.fn()
childSpawnMock.mockImplementation(() => {
throw new Error('rpc unavailable')
})
ptySpawnMock.mockReturnValue({
onData: vi.fn((callback) => {
ptyHandlers.onData = callback
return makeDisposable()
}),
onExit: vi.fn(() => makeDisposable()),
write: ptyWrite,
kill: ptyKill
})
const resultPromise = fetchCodexRateLimits()
await vi.advanceTimersByTimeAsync(0)
ptyHandlers.onData?.('\u001b[2JSign in with ChatGPT\r\n')
await expect(resultPromise).resolves.toMatchObject({
provider: 'codex',
session: null,
weekly: null,
status: 'error',
error: 'Sign in with ChatGPT'
})
expect(ptyWrite).not.toHaveBeenCalled()
expect(ptyKill).toHaveBeenCalledOnce()
})
})

View File

@ -1035,6 +1035,29 @@ async function fetchViaPty(options?: FetchCodexRateLimitsOptions): Promise<Provi
output = output.slice(-MAX_DIAGNOSTIC_OUTPUT_LENGTH)
}
const authError = extractCodexAuthError(output)
if (authError) {
resolved = true
if (timeout) {
clearTimeout(timeout)
timeout = null
}
if (settleTimer) {
clearTimeout(settleTimer)
settleTimer = null
}
cleanupHiddenRateLimitPty(term, termDisposables, { kill: true })
resolve({
provider: 'codex',
session: null,
weekly: null,
updatedAt: Date.now(),
error: authError,
status: 'error'
})
return
}
armStatusNudge()
// Wait for prompt, then send /status

View File

@ -8,6 +8,7 @@ afterEach(() => {
describe('isCodexAuthError', () => {
it('matches Codex authentication refresh failures', () => {
expect(isCodexAuthError('Access token could not be refreshed')).toBe(true)
expect(isCodexAuthError('Sign in with ChatGPT')).toBe(true)
expect(isCodexAuthError('plain provider error')).toBe(false)
expect(isCodexAuthError(null)).toBe(false)
})

View File

@ -6,6 +6,7 @@ const CODEX_AUTH_ERROR_PATTERNS = [
/please (?:log out and )?sign in again/i,
/please reauthenticate/i,
/not logged in/i,
/sign in with chatgpt/i,
/token data is not available/i,
/auth (?:is missing|tokens are missing|does not expose)/i,
// Why: app-server rejects account/rateLimits/read with this when auth.json