diff --git a/src/main/codex-accounts/managed-codex-auth-readiness.test.ts b/src/main/codex-accounts/managed-codex-auth-readiness.test.ts new file mode 100644 index 000000000..f32bfbd15 --- /dev/null +++ b/src/main/codex-accounts/managed-codex-auth-readiness.test.ts @@ -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[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 }) +} diff --git a/src/main/codex-accounts/managed-codex-auth-readiness.ts b/src/main/codex-accounts/managed-codex-auth-readiness.ts new file mode 100644 index 000000000..4c9f28eec --- /dev/null +++ b/src/main/codex-accounts/managed-codex-auth-readiness.ts @@ -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 | 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 { + 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 { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} diff --git a/src/main/codex-accounts/runtime-home-service-per-account-migration.test.ts b/src/main/codex-accounts/runtime-home-service-per-account-migration.test.ts index 965a51cc8..4012cf3b1 100644 --- a/src/main/codex-accounts/runtime-home-service-per-account-migration.test.ts +++ b/src/main/codex-accounts/runtime-home-service-per-account-migration.test.ts @@ -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') }) diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index b6076db0e..7351fd17a 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -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 () => { diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index f26839f8d..5813e4b0d 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -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) { diff --git a/src/main/index.ts b/src/main/index.ts index 212f9e636..a53801a60 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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 { 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') { diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 06d736088..ccc39e1b3 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -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() }) diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 1567eca12..bd8f3a515 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -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): 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 { + 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 +): Promise { + 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 | 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?.() diff --git a/src/main/rate-limits/codex-fetcher-auth-errors.test.ts b/src/main/rate-limits/codex-fetcher-auth-errors.test.ts index b172551e1..5f5944191 100644 --- a/src/main/rate-limits/codex-fetcher-auth-errors.test.ts +++ b/src/main/rate-limits/codex-fetcher-auth-errors.test.ts @@ -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() + }) }) diff --git a/src/main/rate-limits/codex-fetcher.ts b/src/main/rate-limits/codex-fetcher.ts index d28fdee01..685aa1d50 100644 --- a/src/main/rate-limits/codex-fetcher.ts +++ b/src/main/rate-limits/codex-fetcher.ts @@ -1035,6 +1035,29 @@ async function fetchViaPty(options?: FetchCodexRateLimitsOptions): Promise { 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) }) diff --git a/src/shared/codex-auth-errors.ts b/src/shared/codex-auth-errors.ts index 9f25a4b44..366a129c3 100644 --- a/src/shared/codex-auth-errors.ts +++ b/src/shared/codex-auth-errors.ts @@ -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