diff --git a/src/main/codex-accounts/legacy-shared-home-compatibility.test.ts b/src/main/codex-accounts/legacy-shared-home-compatibility.test.ts new file mode 100644 index 00000000000..1cd025322bb --- /dev/null +++ b/src/main/codex-accounts/legacy-shared-home-compatibility.test.ts @@ -0,0 +1,113 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + existsSync, + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + statSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { syncLegacySharedCodexHomeForRetainedPanes } from './legacy-shared-home-compatibility' + +let root: string +let sharedRuntimeHome: string +let systemCodexHome: string + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-legacy-codex-home-')) + sharedRuntimeHome = join(root, 'codex-runtime-home', 'home') + systemCodexHome = join(root, 'system-home', '.codex') + mkdirSync(sharedRuntimeHome, { recursive: true }) + mkdirSync(systemCodexHome, { recursive: true }) +}) + +afterEach(() => { + rmSync(root, { recursive: true, force: true }) + vi.restoreAllMocks() +}) + +describe('legacy shared Codex home compatibility', () => { + it('refreshes retained panes with the authenticated production provider', () => { + const systemAuth = '{"tokens":{"access_token":"system"}}\n' + const systemConfig = [ + 'model_provider = "codex-lb"', + '', + '[model_providers.codex-lb]', + 'base_url = "https://codex-lb.example.test/v1"', + 'requires_openai_auth = true', + '' + ].join('\n') + writeFileSync(join(systemCodexHome, 'auth.json'), systemAuth, 'utf-8') + writeFileSync(join(systemCodexHome, 'config.toml'), systemConfig, 'utf-8') + writeFileSync(join(sharedRuntimeHome, 'auth.json'), '{"tokens":{"access_token":"stale"}}\n') + writeFileSync( + join(sharedRuntimeHome, 'config.toml'), + [ + 'model_provider = "stale-provider"', + '', + '[hooks.state."orca:stop:0:0"]', + 'enabled = true', + '' + ].join('\n') + ) + + syncLegacySharedCodexHomeForRetainedPanes({ sharedRuntimeHome, systemCodexHome }) + + expect(readFileSync(join(sharedRuntimeHome, 'auth.json'), 'utf-8')).toBe(systemAuth) + const sharedConfig = readFileSync(join(sharedRuntimeHome, 'config.toml'), 'utf-8') + expect(sharedConfig).toContain('model_provider = "codex-lb"') + expect(sharedConfig).toContain('requires_openai_auth = true') + expect(sharedConfig).not.toContain('stale-provider') + expect(sharedConfig).toContain('[hooks.state."orca:stop:0:0"]') + expect(readFileSync(join(systemCodexHome, 'config.toml'), 'utf-8')).toBe(systemConfig) + if (process.platform !== 'win32') { + expect(statSync(join(sharedRuntimeHome, 'auth.json')).mode & 0o777).toBe(0o600) + } + }) + + it('reflects a real-home logout without deleting a transiently missing config', () => { + const staleConfig = 'model_provider = "stale-provider"\n' + writeFileSync(join(sharedRuntimeHome, 'auth.json'), '{"tokens":{"access_token":"stale"}}\n') + writeFileSync(join(sharedRuntimeHome, 'config.toml'), staleConfig, 'utf-8') + + syncLegacySharedCodexHomeForRetainedPanes({ sharedRuntimeHome, systemCodexHome }) + + expect(existsSync(join(sharedRuntimeHome, 'auth.json'))).toBe(false) + expect(readFileSync(join(sharedRuntimeHome, 'config.toml'), 'utf-8')).toBe(staleConfig) + }) + + it('does not promote retained-pane settings into the real home', () => { + writeFileSync(join(systemCodexHome, 'auth.json'), '{"tokens":{"access_token":"system"}}\n') + writeFileSync(join(systemCodexHome, 'config.toml'), 'model = "canonical"\n', 'utf-8') + writeFileSync(join(sharedRuntimeHome, 'config.toml'), 'model = "retained-pane-change"\n') + + syncLegacySharedCodexHomeForRetainedPanes({ sharedRuntimeHome, systemCodexHome }) + + expect(readFileSync(join(systemCodexHome, 'config.toml'), 'utf-8')).toBe( + 'model = "canonical"\n' + ) + expect(readFileSync(join(sharedRuntimeHome, 'config.toml'), 'utf-8')).toBe( + 'model = "canonical"\n' + ) + }) + + it('still refreshes config when the compatibility auth write fails', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + writeFileSync(join(systemCodexHome, 'auth.json'), '{"tokens":{"access_token":"system"}}\n') + writeFileSync(join(systemCodexHome, 'config.toml'), 'model = "canonical"\n', 'utf-8') + mkdirSync(join(sharedRuntimeHome, 'auth.json')) + + syncLegacySharedCodexHomeForRetainedPanes({ sharedRuntimeHome, systemCodexHome }) + + expect(readFileSync(join(sharedRuntimeHome, 'config.toml'), 'utf-8')).toBe( + 'model = "canonical"\n' + ) + expect(warnSpy).toHaveBeenCalledWith( + '[codex-runtime-home] Failed to refresh legacy shared auth:', + expect.anything() + ) + }) +}) diff --git a/src/main/codex-accounts/legacy-shared-home-compatibility.ts b/src/main/codex-accounts/legacy-shared-home-compatibility.ts new file mode 100644 index 00000000000..749ef331488 --- /dev/null +++ b/src/main/codex-accounts/legacy-shared-home-compatibility.ts @@ -0,0 +1,58 @@ +import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from '../codex/codex-home-paths' +import { syncSystemConfigIntoLegacySharedCodexHome } from '../codex/codex-config-mirror' +import { writeFileAtomically } from './fs-utils' + +type LegacySharedCodexHomePaths = { + sharedRuntimeHome: string + systemCodexHome: string +} + +export function syncLegacySharedCodexHomeForRetainedPanes( + paths?: LegacySharedCodexHomePaths +): void { + let resolvedPaths: LegacySharedCodexHomePaths + try { + resolvedPaths = paths ?? { + sharedRuntimeHome: getOrcaManagedCodexHomePath(), + systemCodexHome: getSystemCodexHomePath() + } + } catch (error) { + console.warn('[codex-runtime-home] Failed to resolve legacy shared home:', error) + return + } + runCompatibilitySync('auth', () => syncSystemAuthIntoLegacySharedHome(resolvedPaths)) + runCompatibilitySync('config', () => + syncSystemConfigIntoLegacySharedCodexHome({ + runtimeHomePath: resolvedPaths.sharedRuntimeHome, + systemHomePath: resolvedPaths.systemCodexHome + }) + ) +} + +function syncSystemAuthIntoLegacySharedHome(paths: LegacySharedCodexHomePaths): void { + const systemAuthPath = join(paths.systemCodexHome, 'auth.json') + const sharedAuthPath = join(paths.sharedRuntimeHome, 'auth.json') + if (!existsSync(systemAuthPath)) { + rmSync(sharedAuthPath, { force: true }) + return + } + + const systemAuth = readFileSync(systemAuthPath, 'utf-8') + mkdirSync(paths.sharedRuntimeHome, { recursive: true }) + if (existsSync(sharedAuthPath) && readFileSync(sharedAuthPath, 'utf-8') === systemAuth) { + chmodSync(sharedAuthPath, 0o600) + return + } + writeFileAtomically(sharedAuthPath, systemAuth, { mode: 0o600 }) +} + +function runCompatibilitySync(resource: 'auth' | 'config', sync: () => void): void { + try { + sync() + } catch (error) { + // Why: compatibility for a retained pane must never block real-home Codex. + console.warn(`[codex-runtime-home] Failed to refresh legacy shared ${resource}:`, error) + } +} diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 7351fd17a91..b07068a9fe7 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -1041,12 +1041,14 @@ describe('CodexRuntimeHomeService', () => { const service = new CodexRuntimeHomeService(store as never) expect(service.isHostSystemDefaultRealHome()).toBe(true) + expect(service.getSelectedHostCodexHomeRoute()).toBe('real-home') expect(service.prepareForCodexLaunch()).toBeNull() expect(service.getHostCodexHomePathsForSessionDiscovery()).toEqual([ getRuntimeCodexHomePath(), getSystemCodexHomePath() ]) service.setRealHomeLaneGate(() => false) + expect(service.getSelectedHostCodexHomeRoute()).toBe('shared-home') expect(service.getHostCodexHomePathsForSessionDiscovery()).toEqual([getRuntimeCodexHomePath()]) const markerPath = join( testState.userDataDir, @@ -1107,6 +1109,35 @@ describe('CodexRuntimeHomeService', () => { } }) + it('keeps pre-rollout shared-home panes authenticated on the real-home lane', async () => { + const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system') + const systemConfig = [ + 'model_provider = "codex-lb"', + '', + '[model_providers.codex-lb]', + 'base_url = "https://codex-lb.example.test/v1"', + 'requires_openai_auth = true', + '' + ].join('\n') + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + writeFileSync(join(getSystemCodexHomePath(), 'config.toml'), systemConfig, 'utf-8') + writeFileSync(getRuntimeCodexAuthPath(), '{"tokens":{"access_token":"stale"}}\n', 'utf-8') + writeFileSync( + join(getRuntimeCodexHomePath(), 'config.toml'), + 'model_provider = "stale-provider"\n', + 'utf-8' + ) + + const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: true })) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + service.setRealHomeLaneGate(() => true) + expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(systemAuth) + expect(readFileSync(join(getRuntimeCodexHomePath(), 'config.toml'), 'utf-8')).toBe(systemConfig) + expect(service.prepareForCodexLaunch()).toBeNull() + }) + it('routes a host MANAGED account to its own self-contained home when the flag is ON', async () => { writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8') const runtimeAuthPath = getRuntimeCodexAuthPath() @@ -1596,9 +1627,10 @@ describe('CodexRuntimeHomeService', () => { expect(service.isHostSystemDefaultRealHome()).toBe(true) expect(service.prepareForCodexLaunch()).toBeNull() - // E owns refreshes in place, so takeover ignores later shared-mirror bytes. + // E owns refreshes in place, so takeover never adopts later shared bytes; + // retained pre-E panes receive the selected system-default identity instead. expect(readFileSync(join(managedHomePath, 'auth.json'), 'utf-8')).toBe(managedAuth) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(refreshedManagedAuth) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(systemAuth) }) it('does not read shared auth when polling observes a managed-to-real-home transition', async () => { diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index 5813e4b0d68..8bd50b2b616 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -74,6 +74,8 @@ import { } from './codex-auth-identity' import { migrateLegacySharedAuthToPerAccountHome } from './legacy-shared-auth-migration' import { hasStoredCodexCredential } from './managed-codex-auth-readiness' +import { syncLegacySharedCodexHomeForRetainedPanes } from './legacy-shared-home-compatibility' +import type { CodexPaneHomeRoute } from '../codex/codex-pane-account-registry' type CodexSystemDefaultSnapshot = { authJson: string | null @@ -181,8 +183,8 @@ export class CodexRuntimeHomeService { if (this.isHostSystemDefaultRealHome(launchEnv)) { // Why (flag ON, system default): run Codex on the user's own ~/.codex. // Returning null tells the PTY/env layer to inject no managed CODEX_HOME; - // sessions, auth, and config all live in the native home. No system-> - // managed session bridge runs, so the real home stays the single source. + // the retired mirror is refreshed only for pre-rollout PTYs. + syncLegacySharedCodexHomeForRetainedPanes() return null } this.invalidateBackfillAfterManagedSystemDefaultLaunch(launchEnv) @@ -424,6 +426,13 @@ export class CodexRuntimeHomeService { : null } + getSelectedHostCodexHomeRoute(): CodexPaneHomeRoute { + if (this.getSelfContainedManagedHostAccount()) { + return 'account-home' + } + return this.isHostSystemDefaultRealHome() ? 'real-home' : 'shared-home' + } + // Why: the real-home hook installer flips this gate off when the trust-grant // client reports the host incapable, keeping that host byte-identical to the // managed lane instead of shipping status-blind panes. @@ -431,6 +440,9 @@ export class CodexRuntimeHomeService { setRealHomeLaneGate(gate: () => boolean): void { this.realHomeLaneGate = gate + if (this.isHostSystemDefaultRealHome()) { + syncLegacySharedCodexHomeForRetainedPanes() + } } // Why: real-home routing applies only to the host system-default selection diff --git a/src/main/codex/codex-config-mirror.ts b/src/main/codex/codex-config-mirror.ts index 4b439e20372..484828b406a 100644 --- a/src/main/codex/codex-config-mirror.ts +++ b/src/main/codex/codex-config-mirror.ts @@ -87,6 +87,43 @@ export function syncSystemConfigIntoManagedCodexHome( ) } +/** + * Refreshes the retired shared home for PTYs that survived real-home rollout. + * + * This is deliberately one-way: a retained PTY may hold pre-rollout settings, + * so treating that home as a promotion source could overwrite the live config. + */ +export function syncSystemConfigIntoLegacySharedCodexHome( + homes: CodexSettingsPromotionHomes = { + runtimeHomePath: getOrcaManagedCodexHomePath(), + systemHomePath: getSystemCodexHomePath() + } +): void { + const systemConfigPath = join(homes.systemHomePath, 'config.toml') + const runtimeConfigPath = join(homes.runtimeHomePath, 'config.toml') + const rawSystemConfig = existsSync(systemConfigPath) + ? readAgentStateFileSync(systemConfigPath) + : '' + // Why: a missing cloud-synced source is not proof the user cleared config. + if (rawSystemConfig.trim() === '') { + return + } + + const sourceConfigDir = resolveCodexConfigMirrorSourceDirectory(homes.systemHomePath) + const nextRuntimeConfig = existsSync(runtimeConfigPath) + ? mergeSystemCodexConfigIntoRuntime( + readAgentStateFileSync(runtimeConfigPath), + prepareSystemConfigForRuntimeMirror(rawSystemConfig, sourceConfigDir) + ) + : prepareSystemConfigForFreshRuntimeMirror(rawSystemConfig, sourceConfigDir) + if ( + !existsSync(runtimeConfigPath) || + readAgentStateFileSync(runtimeConfigPath) !== nextRuntimeConfig + ) { + writeFileAtomically(runtimeConfigPath, nextRuntimeConfig) + } +} + type CodexConfigMirrorResult = | { status: 'skipped-missing-source' } | { status: 'mirrored'; preservedConflictKeys: ReadonlySet } diff --git a/src/main/codex/codex-pane-account-registry.ts b/src/main/codex/codex-pane-account-registry.ts index f1d83989453..9aeed82dbbd 100644 --- a/src/main/codex/codex-pane-account-registry.ts +++ b/src/main/codex/codex-pane-account-registry.ts @@ -12,15 +12,19 @@ import { getOrcaUserDataPath } from './codex-home-paths' * the old account and the user is stuck there with no prompt to escape it. */ +export type CodexPaneHomeRoute = 'real-home' | 'shared-home' | 'account-home' | 'wsl-home' + export type CodexPaneAccountRecord = { /** 'host' or 'wsl:' — the selection lane this pane launched from. */ selectionKey: string /** Managed account id, or null for the system-default account. */ accountId: string | null + /** Absent only on records written before route provenance was introduced. */ + homeRoute?: CodexPaneHomeRoute } type RegistryFile = { - version: 1 + version: 2 panes: Record } @@ -51,7 +55,7 @@ function readRegistryFile(): unknown { } function parseRegistry(parsed: unknown): RegistryFile { - const empty: RegistryFile = { version: 1, panes: {} } + const empty: RegistryFile = { version: 2, panes: {} } if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { return empty } @@ -61,7 +65,11 @@ function parseRegistry(parsed: unknown): RegistryFile { } for (const [ptyId, record] of Object.entries(panes)) { if (isPaneAccountRecord(record)) { - empty.panes[ptyId] = { selectionKey: record.selectionKey, accountId: record.accountId } + empty.panes[ptyId] = { + selectionKey: record.selectionKey, + accountId: record.accountId, + ...(isPaneHomeRoute(record.homeRoute) ? { homeRoute: record.homeRoute } : {}) + } } } return empty @@ -78,6 +86,15 @@ function isPaneAccountRecord(value: unknown): value is CodexPaneAccountRecord { ) } +function isPaneHomeRoute(value: unknown): value is CodexPaneHomeRoute { + return ( + value === 'real-home' || + value === 'shared-home' || + value === 'account-home' || + value === 'wsl-home' + ) +} + function writeRegistry(registry: RegistryFile): void { const registryPath = getRegistryPath() const temporaryPath = `${registryPath}.${process.pid}.tmp` @@ -112,7 +129,11 @@ export function recordCodexPaneAccount(ptyId: string, record: CodexPaneAccountRe return } const existing = registry.panes[ptyId] - if (existing?.selectionKey === record.selectionKey && existing.accountId === record.accountId) { + if ( + existing?.selectionKey === record.selectionKey && + existing.accountId === record.accountId && + existing.homeRoute === record.homeRoute + ) { return } registry.panes[ptyId] = record diff --git a/src/main/codex/codex-pane-launch-account.test.ts b/src/main/codex/codex-pane-launch-account.test.ts index f7cb49eb95b..135629342dc 100644 --- a/src/main/codex/codex-pane-launch-account.test.ts +++ b/src/main/codex/codex-pane-launch-account.test.ts @@ -35,10 +35,22 @@ describe('resolveCodexPaneLaunchAccount', () => { pinnedByResume: false, launchCodexHomePath: '/data/codex-accounts/account-b/home', systemCodexHomePath: SYSTEM_HOME, - settings: settings({ host: 'account-b' }), + settings: settings({ + host: 'account-b', + accounts: [ + managedAccount({ + id: 'account-b', + managedHomePath: '/data/codex-accounts/account-b/home' + }) + ] + }), target: { runtime: 'host' } }) - ).toEqual({ selectionKey: 'host', accountId: 'account-b' }) + ).toEqual({ + selectionKey: 'host', + accountId: 'account-b', + homeRoute: 'account-home' + }) }) it('records the origin account a resume pinned the pane to, not the selection', () => { @@ -55,7 +67,24 @@ describe('resolveCodexPaneLaunchAccount', () => { settings: settings({ host: 'account-b', accounts }), target: { runtime: 'host' } }) - ).toEqual({ selectionKey: 'host', accountId: 'account-a' }) + ).toEqual({ + selectionKey: 'host', + accountId: 'account-a', + homeRoute: 'account-home' + }) + }) + + it('omits route provenance for a pane-local custom home', () => { + expect( + resolveCodexPaneLaunchAccount({ + pinnedByResume: false, + launchCodexHomePath: '/data/codex-runtime-home/home', + recordHomeRoute: false, + systemCodexHomePath: SYSTEM_HOME, + settings: settings({ host: null }), + target: { runtime: 'host' } + }) + ).toEqual({ selectionKey: 'host', accountId: null }) }) it('records the same account a resume pinned to when it is already selected', () => { @@ -71,7 +100,11 @@ describe('resolveCodexPaneLaunchAccount', () => { settings: settings({ host: 'account-a', accounts }), target: { runtime: 'host' } }) - ).toEqual({ selectionKey: 'host', accountId: 'account-a' }) + ).toEqual({ + selectionKey: 'host', + accountId: 'account-a', + homeRoute: 'account-home' + }) }) it('maps a resume redirected to the real system home to the system-default account', () => { @@ -86,7 +119,7 @@ describe('resolveCodexPaneLaunchAccount', () => { }), target: { runtime: 'host' } }) - ).toEqual({ selectionKey: 'host', accountId: null }) + ).toEqual({ selectionKey: 'host', accountId: null, homeRoute: 'real-home' }) }) it('maps a resume that injects no CODEX_HOME to the system-default account', () => { @@ -98,7 +131,7 @@ describe('resolveCodexPaneLaunchAccount', () => { settings: settings({ host: 'account-a', accounts: [managedAccount({ id: 'account-a' })] }), target: { runtime: 'host' } }) - ).toEqual({ selectionKey: 'host', accountId: null }) + ).toEqual({ selectionKey: 'host', accountId: null, homeRoute: 'real-home' }) }) it('refuses to attribute a resume home no account owns', () => { @@ -150,7 +183,11 @@ describe('resolveCodexPaneLaunchAccount', () => { settings: settings({ wsl: { Ubuntu: 'other-wsl-account' }, accounts }), target: { runtime: 'wsl', wslDistro: 'Ubuntu' } }) - ).toEqual({ selectionKey: 'wsl:Ubuntu', accountId: 'wsl-account' }) + ).toEqual({ + selectionKey: 'wsl:Ubuntu', + accountId: 'wsl-account', + homeRoute: 'wsl-home' + }) }) it('tolerates settings that carry no managed account roster', () => { diff --git a/src/main/codex/codex-pane-launch-account.ts b/src/main/codex/codex-pane-launch-account.ts index 33d091cef7c..c1aafe50c6f 100644 --- a/src/main/codex/codex-pane-launch-account.ts +++ b/src/main/codex/codex-pane-launch-account.ts @@ -6,7 +6,7 @@ import { getSelectedCodexAccountIdForTarget, type CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' -import type { CodexPaneAccountRecord } from './codex-pane-account-registry' +import type { CodexPaneAccountRecord, CodexPaneHomeRoute } from './codex-pane-account-registry' type CodexPaneLaunchAccountSettings = Pick< GlobalSettings, @@ -28,19 +28,49 @@ type CodexPaneLaunchAccountSettings = Pick< export function resolveCodexPaneLaunchAccount(args: { pinnedByResume: boolean launchCodexHomePath: string | null + recordHomeRoute?: boolean systemCodexHomePath: string settings: CodexPaneLaunchAccountSettings target: CodexAccountSelectionTarget }): CodexPaneAccountRecord | null { const selectionKey = getCodexSelectionLaneKey(args.target) + const homeRoute = args.recordHomeRoute === false ? undefined : resolveCodexPaneHomeRoute(args) if (!args.pinnedByResume) { return { selectionKey, - accountId: getSelectedCodexAccountIdForTarget(args.settings, args.target) + accountId: getSelectedCodexAccountIdForTarget(args.settings, args.target), + ...(homeRoute ? { homeRoute } : {}) } } const accountId = resolveCodexHomeOwnerAccountId(args) - return accountId === undefined ? null : { selectionKey, accountId } + return accountId === undefined + ? null + : { selectionKey, accountId, ...(homeRoute ? { homeRoute } : {}) } +} + +function resolveCodexPaneHomeRoute(args: { + launchCodexHomePath: string | null + systemCodexHomePath: string + settings: CodexPaneLaunchAccountSettings + target: CodexAccountSelectionTarget +}): CodexPaneHomeRoute { + if (args.target.runtime === 'wsl') { + return 'wsl-home' + } + if ( + !args.launchCodexHomePath || + normalizeRuntimePathForComparison(args.launchCodexHomePath) === + normalizeRuntimePathForComparison(args.systemCodexHomePath) + ) { + return 'real-home' + } + const launchHome = normalizeRuntimePathForComparison(args.launchCodexHomePath) + const accountOwnsHome = args.settings.codexManagedAccounts?.some( + (account) => + getCodexSelectionTargetForAccount(account).runtime === 'host' && + normalizeRuntimePathForComparison(account.managedHomePath) === launchHome + ) + return accountOwnsHome ? 'account-home' : 'shared-home' } /** undefined when no account owns the home; null means the system-default account. */ diff --git a/src/main/codex/codex-stale-pane-accounts.test.ts b/src/main/codex/codex-stale-pane-accounts.test.ts index ed078f756dc..c3ea9c7c551 100644 --- a/src/main/codex/codex-stale-pane-accounts.test.ts +++ b/src/main/codex/codex-stale-pane-accounts.test.ts @@ -43,11 +43,32 @@ afterEach(() => { describe('codex pane account registry', () => { it('survives a process restart so a daemon-backed shell stays attributable', () => { - recordCodexPaneAccount('pty-1', { selectionKey: 'host', accountId: 'account-a' }) + recordCodexPaneAccount('pty-1', { + selectionKey: 'host', + accountId: 'account-a', + homeRoute: 'account-home' + }) _internals.resetCache() - expect(getCodexPaneAccount('pty-1')).toEqual({ selectionKey: 'host', accountId: 'account-a' }) + expect(getCodexPaneAccount('pty-1')).toEqual({ + selectionKey: 'host', + accountId: 'account-a', + homeRoute: 'account-home' + }) + }) + + it('keeps pre-route records readable without inventing provenance', () => { + writeFileSync( + join(userDataPath, 'codex-pane-accounts.json'), + JSON.stringify({ + version: 1, + panes: { 'pty-1': { selectionKey: 'host', accountId: null } } + }) + ) + _internals.resetCache() + + expect(getCodexPaneAccount('pty-1')).toEqual({ selectionKey: 'host', accountId: null }) }) it('forgets a PTY so a reused id cannot inherit a dead pane account', () => { @@ -144,6 +165,34 @@ describe('listStaleCodexPanes', () => { ).toEqual([]) }) + it('reports a system-default pane after its home route changes', () => { + recordCodexPaneAccount('pty-1', { + selectionKey: 'host', + accountId: null, + homeRoute: 'shared-home' + }) + + expect( + listStaleCodexPanes({ + ptyIds: ['pty-1'], + settings: settingsWithSelection(null), + activeHostHomeRoute: 'real-home' + }) + ).toEqual([{ ptyId: 'pty-1', launchAccountId: null, activeAccountId: null }]) + }) + + it('does not guess a route for panes recorded before route provenance', () => { + recordCodexPaneAccount('pty-1', { selectionKey: 'host', accountId: null }) + + expect( + listStaleCodexPanes({ + ptyIds: ['pty-1'], + settings: settingsWithSelection(null), + activeHostHomeRoute: 'real-home' + }) + ).toEqual([]) + }) + it('never reports an unrecorded PTY, so an upgrade cannot invent a prompt', () => { expect( listStaleCodexPanes({ ptyIds: ['pty-unknown'], settings: settingsWithSelection('account-b') }) diff --git a/src/main/codex/codex-stale-pane-accounts.ts b/src/main/codex/codex-stale-pane-accounts.ts index ba1aa6eef94..61346d668db 100644 --- a/src/main/codex/codex-stale-pane-accounts.ts +++ b/src/main/codex/codex-stale-pane-accounts.ts @@ -1,6 +1,10 @@ import type { GlobalSettings } from '../../shared/types' import { getSelectedCodexAccountIdForTarget } from '../codex-accounts/runtime-selection' -import { forgetCodexPaneAccount, getCodexPaneAccount } from './codex-pane-account-registry' +import { + forgetCodexPaneAccount, + getCodexPaneAccount, + type CodexPaneHomeRoute +} from './codex-pane-account-registry' export type StaleCodexPane = { ptyId: string @@ -15,6 +19,7 @@ export type StaleCodexPane = { export function listStaleCodexPanes(args: { ptyIds: readonly string[] settings: GlobalSettings + activeHostHomeRoute?: CodexPaneHomeRoute }): StaleCodexPane[] { const stalePanes: StaleCodexPane[] = [] for (const ptyId of args.ptyIds) { @@ -26,7 +31,12 @@ export function listStaleCodexPanes(args: { args.settings, parseSelectionLaneKey(record.selectionKey) ) - if (record.accountId !== activeAccountId) { + const homeRouteChanged = + record.selectionKey === 'host' && + record.homeRoute !== undefined && + args.activeHostHomeRoute !== undefined && + record.homeRoute !== args.activeHostHomeRoute + if (record.accountId !== activeAccountId || homeRouteChanged) { stalePanes.push({ ptyId, launchAccountId: record.accountId, activeAccountId }) } } diff --git a/src/main/ipc/codex-accounts.ts b/src/main/ipc/codex-accounts.ts index 22cee5340d2..0c111a5f6d8 100644 --- a/src/main/ipc/codex-accounts.ts +++ b/src/main/ipc/codex-accounts.ts @@ -16,7 +16,8 @@ export function registerCodexAccountHandlers( } return listStaleCodexPanes({ ptyIds: args.ptyIds.filter((ptyId): ptyId is string => typeof ptyId === 'string'), - settings + settings, + activeHostHomeRoute: codexAccounts.runtimeHomeService.getSelectedHostCodexHomeRoute() }) }) ipcMain.handle('codexAccounts:listRecordedPaneLanes', (_event, args: { ptyIds?: unknown }) => { diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index ccc39e1b3ff..27ea55b8c08 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -15002,14 +15002,20 @@ describe('registerPtyHandlers', () => { const getSettings = vi.fn().mockReturnValue({ activeCodexManagedAccountId: 'account-a' }) registerPtyHandlers(mainWindow as never, undefined, undefined, getSettings as never) - await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 }) - await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, sessionId: 'pty-reattached' }) + const nativeCodexEnv = { CODEX_HOME: '', ORCA_CODEX_HOME: '' } + await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, env: nativeCodexEnv }) + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + env: nativeCodexEnv, + sessionId: 'pty-reattached' + }) // Why: a reattached shell keeps the CODEX_HOME baked in at its original // spawn, so re-recording it under the current selection would erase the only // evidence that the pane is stale. expect(recordCodexPaneAccountMock.mock.calls).toEqual([ - ['pty-fresh', { selectionKey: 'host', accountId: 'account-a' }] + ['pty-fresh', { selectionKey: 'host', accountId: 'account-a', homeRoute: 'real-home' }] ]) }) @@ -15127,7 +15133,7 @@ describe('registerPtyHandlers', () => { // Why: the resume deliberately overrides the selection, so the pane really // is on account-a. Recording that is what makes the restart prompt appear. expect(recordCodexPaneAccountMock.mock.calls).toEqual([ - ['pty-resumed', { selectionKey: 'host', accountId: 'account-a' }] + ['pty-resumed', { selectionKey: 'host', accountId: 'account-a', homeRoute: 'account-home' }] ]) expect(readFileSyncMock).toHaveBeenCalledWith('/managed/origin/home/auth.json', 'utf8') expect(forgetCodexPaneAccountMock).not.toHaveBeenCalled() @@ -15247,7 +15253,10 @@ describe('registerPtyHandlers', () => { }) expect(recordCodexPaneAccountMock.mock.calls).toEqual([ - ['pty-runtime-resumed', { selectionKey: 'host', accountId: 'account-a' }] + [ + 'pty-runtime-resumed', + { selectionKey: 'host', accountId: 'account-a', homeRoute: 'account-home' } + ] ]) 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 bd8f3a515d4..e2de0e9c1a8 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -198,6 +198,7 @@ import { import { resolveCodexPaneLaunchAccount } from '../codex/codex-pane-launch-account' import { getSystemCodexHomePath } from '../codex/codex-home-paths' import { isCodexSystemDefaultRealHomeEnabled } from '../codex/codex-real-home-flag' +import { hasCustomCodexHomeOverride } from '../codex/codex-real-home-path' import type { CodexSessionResumePreparation } from '../codex/codex-session-resume-home' import { dropUnverifiedCodexResumeArgv } from '../codex/codex-unverified-resume-launch' import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env' @@ -995,6 +996,7 @@ function recordCodexPaneAccountForSpawn(args: { isReattach: boolean pinnedByResume: boolean launchCodexHomePath: string | null + launchEnv?: NodeJS.ProcessEnv target: CodexAccountSelectionTarget settings: GlobalSettings | undefined }): void { @@ -1005,6 +1007,10 @@ function recordCodexPaneAccountForSpawn(args: { ? resolveCodexPaneLaunchAccount({ pinnedByResume: args.pinnedByResume, launchCodexHomePath: args.launchCodexHomePath, + // Why: a pane-local custom CODEX_HOME is intentional and will be + // present again after restart, so route comparison would prompt forever. + recordHomeRoute: + args.pinnedByResume || !hasCustomCodexHomeOverride({ ...process.env, ...args.launchEnv }), systemCodexHomePath: getSystemCodexHomePath(), settings: args.settings, target: args.target @@ -4361,6 +4367,7 @@ export function registerPtyHandlers( isReattach: result.isReattach === true, pinnedByResume: codexResumeHomeSelected, launchCodexHomePath: selectedCodexHomePath, + launchEnv: args.env, target: codexSelectionTarget, settings: getSettings?.() }) @@ -5445,6 +5452,7 @@ export function registerPtyHandlers( isReattach: result.isReattach === true, pinnedByResume: codexResumeHomeSelected, launchCodexHomePath: selectedCodexHomePath, + launchEnv: baseEnv, target: codexSelectionTarget, settings: getSettings?.() })