diff --git a/src/main/codex-accounts/fs-utils.test.ts b/src/main/codex-accounts/fs-utils.test.ts index 0932ef41215..7684ed4be96 100644 --- a/src/main/codex-accounts/fs-utils.test.ts +++ b/src/main/codex-accounts/fs-utils.test.ts @@ -1,13 +1,32 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' +import type * as NodeFs from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { copyFileWithWindowsRetry, + removeFileAtomicallyIfUnchanged, renameFileWithWindowsRetry, - writeFileAtomically + writeFileAtomically, + writeFileAtomicallyIfUnchanged } from './fs-utils' +const fsMock = vi.hoisted(() => ({ linkSync: vi.fn(), renameSync: vi.fn() })) + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + fsMock.linkSync.mockImplementation(actual.linkSync) + fsMock.renameSync.mockImplementation(actual.renameSync) + return { ...actual, linkSync: fsMock.linkSync, renameSync: fsMock.renameSync } +}) + +const originalPlatform = process.platform + +afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform }) + vi.restoreAllMocks() +}) + describe('writeFileAtomically', () => { let dir: string @@ -81,6 +100,128 @@ describe('writeFileAtomically', () => { cleanup() } }) + + it('replaces only the expected file generation', () => { + setup() + try { + const target = join(dir, 'guarded.toml') + writeFileSync(target, 'baseline') + + expect(writeFileAtomicallyIfUnchanged(target, 'baseline', 'next')).toBe(true) + expect(readFileSync(target, 'utf-8')).toBe('next') + + writeFileSync(target, 'concurrent') + expect(writeFileAtomicallyIfUnchanged(target, 'next', 'stale-write')).toBe(false) + expect(readFileSync(target, 'utf-8')).toBe('concurrent') + } finally { + cleanup() + } + }) + + it('validates the generation moved after a Windows rename retry', () => { + setup() + try { + const target = join(dir, 'guarded.toml') + writeFileSync(target, 'baseline') + Object.defineProperty(process, 'platform', { value: 'win32' }) + fsMock.renameSync.mockClear() + fsMock.renameSync.mockImplementationOnce(() => { + writeFileSync(target, 'concurrent') + throw Object.assign(new Error('busy'), { code: 'EBUSY' }) + }) + + expect(writeFileAtomicallyIfUnchanged(target, 'baseline', 'stale-write')).toBe(false) + expect(readFileSync(target, 'utf-8')).toBe('concurrent') + expect(fsMock.renameSync).toHaveBeenCalledTimes(2) + } finally { + cleanup() + } + }) + + it('preserves a replacement created before guarded publication', () => { + setup() + try { + const target = join(dir, 'guarded.toml') + writeFileSync(target, 'baseline') + const rename = fsMock.renameSync.getMockImplementation() + fsMock.renameSync.mockImplementationOnce((source: string, destination: string) => { + rename!(source, destination) + writeFileSync(target, 'concurrent') + }) + + expect(writeFileAtomicallyIfUnchanged(target, 'baseline', 'stale-write')).toBe(false) + expect(readFileSync(target, 'utf-8')).toBe('concurrent') + } finally { + cleanup() + } + }) + + it('recovers an interrupted guarded operation before retrying', () => { + setup() + try { + const target = join(dir, 'guarded.toml') + const held = `${target}.orca-guarded` + writeFileSync(held, 'baseline') + + expect(writeFileAtomicallyIfUnchanged(target, 'baseline', 'next')).toBe(true) + expect(readFileSync(target, 'utf-8')).toBe('next') + expect(existsSync(held)).toBe(false) + } finally { + cleanup() + } + }) + + it('fails before moving the target when hard-link publication is unsupported', () => { + setup() + try { + const target = join(dir, 'guarded.toml') + writeFileSync(target, 'baseline') + fsMock.linkSync.mockImplementationOnce(() => { + throw Object.assign(new Error('unsupported'), { code: 'ENOTSUP' }) + }) + + expect(() => writeFileAtomicallyIfUnchanged(target, 'baseline', 'next')).toThrow( + 'unsupported' + ) + expect(readFileSync(target, 'utf-8')).toBe('baseline') + expect(existsSync(`${target}.orca-guarded`)).toBe(false) + } finally { + cleanup() + } + }) + + it('removes only the expected file generation', () => { + setup() + try { + const target = join(dir, 'guarded-auth.json') + writeFileSync(target, 'baseline') + + expect(removeFileAtomicallyIfUnchanged(target, 'other')).toBe(false) + expect(readFileSync(target, 'utf-8')).toBe('baseline') + expect(removeFileAtomicallyIfUnchanged(target, 'baseline')).toBe(true) + expect(existsSync(target)).toBe(false) + } finally { + cleanup() + } + }) + + it('preserves a replacement created while the expected generation is quarantined', () => { + setup() + try { + const target = join(dir, 'guarded-auth.json') + writeFileSync(target, 'baseline') + const rename = fsMock.renameSync.getMockImplementation() + fsMock.renameSync.mockImplementationOnce((source: string, destination: string) => { + rename!(source, destination) + writeFileSync(target, 'concurrent') + }) + + expect(removeFileAtomicallyIfUnchanged(target, 'baseline')).toBe(false) + expect(readFileSync(target, 'utf-8')).toBe('concurrent') + } finally { + cleanup() + } + }) }) describe('retrying file operations', () => { diff --git a/src/main/codex-accounts/fs-utils.ts b/src/main/codex-accounts/fs-utils.ts index 7773e8bd2ab..850fe7b3870 100644 --- a/src/main/codex-accounts/fs-utils.ts +++ b/src/main/codex-accounts/fs-utils.ts @@ -1,7 +1,8 @@ import { randomUUID } from 'node:crypto' -import { copyFileSync, renameSync, rmSync, writeFileSync } from 'node:fs' +import { copyFileSync, existsSync, linkSync, renameSync, rmSync, writeFileSync } from 'node:fs' import { dirname } from 'node:path' import { grantDirAcl, isPermissionError } from '../win32-utils' +import { nodeFileContentsEqualSync } from '../../shared/node-file-content-equality' export function writeFileAtomically( targetPath: string, @@ -39,6 +40,151 @@ export function writeFileAtomically( } } +export function writeFileAtomicallyIfUnchanged( + targetPath: string, + expectedContents: string | null, + contents: string, + options?: { mode?: number } +): boolean { + try { + return attemptGuardedAtomicWrite(targetPath, expectedContents, contents, options) + } catch (error) { + if (!isPermissionError(error) || process.platform !== 'win32') { + throw error + } + grantDirAcl(dirname(targetPath)) + return attemptGuardedAtomicWrite(targetPath, expectedContents, contents, options) + } +} + +export function removeFileAtomicallyIfUnchanged( + targetPath: string, + expectedContents: string +): boolean { + const heldPath = getGuardedOperationHeldPath(targetPath) + recoverInterruptedGuardedOperation(heldPath, targetPath) + assertHardLinkPublicationSupported(targetPath, targetPath) + try { + renameFileWithWindowsRetry(targetPath, heldPath) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return false + } + throw error + } + try { + if (!nodeFileContentsEqualSync(heldPath, expectedContents)) { + restoreMovedFileWithoutOverwrite(heldPath, targetPath) + return false + } + rmSync(heldPath, { force: true }) + return !existsSync(targetPath) + } catch (error) { + restoreMovedFileWithoutOverwrite(heldPath, targetPath) + throw error + } +} + +export function recoverInterruptedGuardedFileOperation(targetPath: string): void { + recoverInterruptedGuardedOperation(getGuardedOperationHeldPath(targetPath), targetPath) +} + +function restoreMovedFileWithoutOverwrite(sourcePath: string, targetPath: string): void { + if (!existsSync(sourcePath)) { + return + } + publishFileWithoutOverwrite(sourcePath, targetPath) + rmSync(sourcePath, { force: true }) +} + +function getGuardedOperationHeldPath(targetPath: string): string { + return `${targetPath}.orca-guarded` +} + +function recoverInterruptedGuardedOperation(heldPath: string, targetPath: string): void { + if (!existsSync(heldPath)) { + return + } + publishFileWithoutOverwrite(heldPath, targetPath) + rmSync(heldPath, { force: true }) +} + +function attemptGuardedAtomicWrite( + targetPath: string, + expectedContents: string | null, + contents: string, + options?: { mode?: number } +): boolean { + const tmpPath = `${targetPath}.${process.pid}.${randomUUID()}.tmp` + const heldPath = getGuardedOperationHeldPath(targetPath) + recoverInterruptedGuardedOperation(heldPath, targetPath) + try { + writeFileSync(tmpPath, contents, { encoding: 'utf-8', mode: options?.mode }) + if (expectedContents === null) { + return publishFileWithoutOverwrite(tmpPath, targetPath) + } + assertHardLinkPublicationSupported(tmpPath, targetPath) + try { + renameFileWithWindowsRetry(targetPath, heldPath) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return false + } + throw error + } + if (!nodeFileContentsEqualSync(heldPath, expectedContents)) { + restoreMovedFileWithoutOverwrite(heldPath, targetPath) + return false + } + if (!publishFileWithoutOverwrite(tmpPath, targetPath)) { + rmSync(heldPath, { force: true }) + return false + } + rmSync(heldPath, { force: true }) + return true + } catch (error) { + restoreMovedFileWithoutOverwrite(heldPath, targetPath) + throw error + } finally { + rmSync(tmpPath, { force: true }) + } +} + +function assertHardLinkPublicationSupported(sourcePath: string, targetPath: string): void { + const probePath = `${targetPath}.${process.pid}.${randomUUID()}.link-probe` + try { + if (!publishFileWithoutOverwrite(sourcePath, probePath)) { + throw new Error(`Guarded file publication probe already exists: ${probePath}`) + } + } finally { + rmSync(probePath, { force: true }) + } +} + +function publishFileWithoutOverwrite(sourcePath: string, targetPath: string): boolean { + try { + linkSync(sourcePath, targetPath) + return true + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EEXIST') { + return false + } + if (isPermissionError(error) && process.platform === 'win32') { + grantDirAcl(dirname(targetPath)) + try { + linkSync(sourcePath, targetPath) + return true + } catch (retryError) { + if ((retryError as NodeJS.ErrnoException).code === 'EEXIST') { + return false + } + throw retryError + } + } + throw error + } +} + // Why: on Windows, file replacement and backup-copy operations can fail with // EPERM/EACCES/EBUSY if another process (antivirus, Claude CLI, Codex CLI) // holds the target file open. A short retry avoids transient failures without diff --git a/src/main/codex-accounts/legacy-shared-config-compatibility.test.ts b/src/main/codex-accounts/legacy-shared-config-compatibility.test.ts new file mode 100644 index 00000000000..4e2dcdf3f74 --- /dev/null +++ b/src/main/codex-accounts/legacy-shared-config-compatibility.test.ts @@ -0,0 +1,144 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { syncLegacySharedCodexConfigForRetainedPanes } from './legacy-shared-config-compatibility' +import type * as FsUtils from './fs-utils' + +const generationRace = vi.hoisted(() => ({ + path: null as string | null, + beforeGuardedReplace: null as (() => void) | null +})) + +vi.mock('./fs-utils', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + writeFileAtomicallyIfUnchanged: ( + path: string, + expectedContents: string | null, + contents: string, + options?: { mode?: number } + ) => { + if (path === generationRace.path) { + generationRace.beforeGuardedReplace?.() + } + return actual.writeFileAtomicallyIfUnchanged(path, expectedContents, contents, options) + } + } +}) + +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 }) + generationRace.path = null + generationRace.beforeGuardedReplace = null + vi.restoreAllMocks() +}) + +describe('legacy shared Codex config compatibility', () => { + it('refreshes retained panes with the authenticated production provider config', () => { + const staleSharedAuth = '{"tokens":{"access_token":"stale"}}\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, 'config.toml'), systemConfig, 'utf-8') + writeFileSync(join(sharedRuntimeHome, 'auth.json'), staleSharedAuth) + writeFileSync( + join(sharedRuntimeHome, 'config.toml'), + [ + 'model_provider = "stale-provider"', + '', + '[hooks.state."orca:stop:0:0"]', + 'enabled = true', + '' + ].join('\n') + ) + + syncLegacySharedCodexConfigForRetainedPanes({ sharedRuntimeHome, systemCodexHome }) + + 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) + expect(readFileSync(join(sharedRuntimeHome, 'auth.json'), 'utf-8')).toBe(staleSharedAuth) + }) + + it('does not delete config when the canonical source is transiently missing', () => { + const staleConfig = 'model_provider = "stale-provider"\n' + writeFileSync(join(sharedRuntimeHome, 'config.toml'), staleConfig, 'utf-8') + + syncLegacySharedCodexConfigForRetainedPanes({ sharedRuntimeHome, systemCodexHome }) + + expect(readFileSync(join(sharedRuntimeHome, 'config.toml'), 'utf-8')).toBe(staleConfig) + }) + + it('does not promote retained-pane settings into the real home', () => { + writeFileSync(join(systemCodexHome, 'config.toml'), 'model = "canonical"\n', 'utf-8') + writeFileSync(join(sharedRuntimeHome, 'config.toml'), 'model = "retained-pane-change"\n') + + syncLegacySharedCodexConfigForRetainedPanes({ 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('does not overwrite trust appended by a retained Codex during the mirror', () => { + const runtimeConfigPath = join(sharedRuntimeHome, 'config.toml') + const runtimeConfig = 'model = "runtime-before"\n' + const concurrentConfig = [ + runtimeConfig.trimEnd(), + '', + '[projects."/newly-trusted"]', + 'trust_level = "trusted"', + '' + ].join('\n') + writeFileSync(join(systemCodexHome, 'config.toml'), 'model = "canonical"\n', 'utf-8') + writeFileSync(runtimeConfigPath, runtimeConfig, 'utf-8') + generationRace.path = runtimeConfigPath + generationRace.beforeGuardedReplace = () => { + writeFileSync(runtimeConfigPath, concurrentConfig, 'utf-8') + } + + syncLegacySharedCodexConfigForRetainedPanes({ sharedRuntimeHome, systemCodexHome }) + + expect(readFileSync(runtimeConfigPath, 'utf-8')).toBe(concurrentConfig) + }) + + it('does not block real-home launch when compatibility config refresh fails', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + writeFileSync(join(systemCodexHome, 'config.toml'), 'model = "canonical"\n', 'utf-8') + mkdirSync(join(sharedRuntimeHome, 'config.toml')) + + expect(() => + syncLegacySharedCodexConfigForRetainedPanes({ sharedRuntimeHome, systemCodexHome }) + ).not.toThrow() + + expect(warnSpy).toHaveBeenCalledWith( + '[codex-runtime-home] Failed to refresh legacy shared config:', + expect.anything() + ) + }) +}) diff --git a/src/main/codex-accounts/legacy-shared-config-compatibility.ts b/src/main/codex-accounts/legacy-shared-config-compatibility.ts new file mode 100644 index 00000000000..65e663b0faf --- /dev/null +++ b/src/main/codex-accounts/legacy-shared-config-compatibility.ts @@ -0,0 +1,25 @@ +import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from '../codex/codex-home-paths' +import { syncSystemConfigIntoLegacySharedCodexHome } from '../codex/codex-config-mirror' + +type LegacySharedCodexConfigPaths = { + sharedRuntimeHome: string + systemCodexHome: string +} + +export function syncLegacySharedCodexConfigForRetainedPanes( + paths?: LegacySharedCodexConfigPaths +): void { + try { + const resolvedPaths = paths ?? { + sharedRuntimeHome: getOrcaManagedCodexHomePath(), + systemCodexHome: getSystemCodexHomePath() + } + syncSystemConfigIntoLegacySharedCodexHome({ + runtimeHomePath: resolvedPaths.sharedRuntimeHome, + systemHomePath: resolvedPaths.systemCodexHome + }) + } catch (error) { + // Why: compatibility for a retained pane must never block real-home Codex. + console.warn('[codex-runtime-home] Failed to refresh legacy shared config:', error) + } +} diff --git a/src/main/codex-accounts/legacy-shared-home-compatibility.test.ts b/src/main/codex-accounts/legacy-shared-home-compatibility.test.ts deleted file mode 100644 index 1cd025322bb..00000000000 --- a/src/main/codex-accounts/legacy-shared-home-compatibility.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -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 deleted file mode 100644 index 749ef331488..00000000000 --- a/src/main/codex-accounts/legacy-shared-home-compatibility.ts +++ /dev/null @@ -1,58 +0,0 @@ -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 b07068a9fe7..10b44743a21 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -8,6 +8,7 @@ import { mkdirSync, readlinkSync, readFileSync, + renameSync, rmSync, statSync, symlinkSync, @@ -201,6 +202,10 @@ function getRuntimeCodexAuthPath(): string { return join(getRuntimeCodexHomePath(), 'auth.json') } +function getSharedRuntimeAuthProvenancePath(): string { + return join(testState.userDataDir, 'codex-runtime-home', 'shared-runtime-auth-provenance.json') +} + function getLegacyActiveHostCodexHomePath(): string { return join(testState.userDataDir, 'codex-runtime-home', 'active', 'host', 'home') } @@ -1110,6 +1115,7 @@ describe('CodexRuntimeHomeService', () => { }) it('keeps pre-rollout shared-home panes authenticated on the real-home lane', async () => { + const oldSystemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'old-system') const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system') const systemConfig = [ 'model_provider = "codex-lb"', @@ -1119,23 +1125,443 @@ describe('CodexRuntimeHomeService', () => { 'requires_openai_auth = true', '' ].join('\n') + writeFileSync(getSystemCodexAuthPath(), oldSystemAuth, 'utf-8') + const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + new CodexRuntimeHomeService(store as never) + expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(oldSystemAuth) + + setRealHomeLaneForTest(true) 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(oldSystemAuth) + expect(readFileSync(join(getRuntimeCodexHomePath(), 'config.toml'), 'utf-8')).toContain( + 'stale-provider' + ) + expect(service.prepareForCodexLaunch()).toBeNull() expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(systemAuth) expect(readFileSync(join(getRuntimeCodexHomePath(), 'config.toml'), 'utf-8')).toBe(systemConfig) - expect(service.prepareForCodexLaunch()).toBeNull() + }) + + it('preserves retained managed auth across a real-home main-process restart', async () => { + const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system') + const managedAuth = createCodexAuthJson('managed@example.com', 'acct-managed', 'managed') + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + new CodexRuntimeHomeService(store as never) + writeFileSync(getRuntimeCodexAuthPath(), managedAuth, 'utf-8') + + setRealHomeLaneForTest(true) + const restartedService = new CodexRuntimeHomeService(store as never) + restartedService.setRealHomeLaneGate(() => true) + + expect(restartedService.prepareForCodexLaunch()).toBeNull() + expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(managedAuth) + expect(readFileSync(getSystemCodexAuthPath(), 'utf-8')).toBe(systemAuth) + }) + + it('preserves same-identity retained managed auth after restart and source logout', async () => { + const systemAuth = createCodexAuthJson('shared@example.com', 'acct-shared', 'system') + const managedAuth = createCodexAuthJson('shared@example.com', 'acct-shared', 'managed') + const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth) + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'shared@example.com', + managedHomePath, + providerAccountId: 'acct-shared', + workspaceLabel: null, + workspaceAccountId: 'acct-shared', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ] + }) + const store = createStore(settings) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + settings.activeCodexManagedAccountId = 'account-1' + settings.activeCodexManagedAccountIdsByRuntime = { host: 'account-1', wsl: {} } + service.syncForCurrentSelection() + + settings.activeCodexManagedAccountId = null + settings.activeCodexManagedAccountIdsByRuntime = { host: null, wsl: {} } + setRealHomeLaneForTest(true) + rmSync(getSystemCodexAuthPath()) + const restartedService = new CodexRuntimeHomeService(store as never) + + expect(restartedService.prepareForCodexLaunch()).toBeNull() + expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(managedAuth) + }) + + it('fences retained shared auth when a self-contained managed transition begins', async () => { + const systemAuth = createCodexAuthJson('shared@example.com', 'acct-shared', 'system') + const managedAuth = createCodexAuthJson('shared@example.com', 'acct-shared', 'managed') + const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth) + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'shared@example.com', + managedHomePath, + providerAccountId: 'acct-shared', + workspaceLabel: null, + workspaceAccountId: 'acct-shared', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ] + }) + const store = createStore(settings) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + setRealHomeLaneForTest(true) + settings.activeCodexManagedAccountId = 'account-1' + settings.activeCodexManagedAccountIdsByRuntime = { host: 'account-1', wsl: {} } + service.syncForCurrentSelection() + writeFileSync(getRuntimeCodexAuthPath(), managedAuth, 'utf-8') + settings.activeCodexManagedAccountId = null + settings.activeCodexManagedAccountIdsByRuntime = { host: null, wsl: {} } + service.syncForCurrentSelection() + rmSync(getSystemCodexAuthPath()) + + service.prepareForRateLimitFetch() + service.prepareForRateLimitFetch() + expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(managedAuth) + }) + + it('restores retained system ownership when a self-contained transition leaves it untouched', async () => { + const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system') + const refreshedSystemAuth = createCodexAuthJson( + 'system@example.com', + 'acct-system', + 'refreshed' + ) + const managedHomePath = createManagedAuth( + testState.userDataDir, + 'account-1', + createCodexAuthJson('managed@example.com', 'acct-managed', 'managed') + ) + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'managed@example.com', + managedHomePath, + providerAccountId: 'acct-managed', + workspaceLabel: null, + workspaceAccountId: 'acct-managed', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ] + }) + const store = createStore(settings) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + setRealHomeLaneForTest(true) + settings.activeCodexManagedAccountId = 'account-1' + settings.activeCodexManagedAccountIdsByRuntime = { host: 'account-1', wsl: {} } + service.syncForCurrentSelection() + settings.activeCodexManagedAccountId = null + settings.activeCodexManagedAccountIdsByRuntime = { host: null, wsl: {} } + service.syncForCurrentSelection() + writeFileSync(getSystemCodexAuthPath(), refreshedSystemAuth, 'utf-8') + + service.prepareForRateLimitFetch() + service.prepareForRateLimitFetch() + expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(refreshedSystemAuth) + }) + + it('restores authoritative system auth when the shared lane follows a managed transition', async () => { + const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system') + const managedAuth = createCodexAuthJson('managed@example.com', 'acct-managed', 'managed') + const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth) + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'managed@example.com', + managedHomePath, + providerAccountId: 'acct-managed', + workspaceLabel: null, + workspaceAccountId: 'acct-managed', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ] + }) + const store = createStore(settings) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + setRealHomeLaneForTest(true) + settings.activeCodexManagedAccountId = 'account-1' + settings.activeCodexManagedAccountIdsByRuntime = { host: 'account-1', wsl: {} } + service.syncForCurrentSelection() + writeFileSync(getRuntimeCodexAuthPath(), managedAuth, 'utf-8') + settings.activeCodexManagedAccountId = null + settings.activeCodexManagedAccountIdsByRuntime = { host: null, wsl: {} } + setRealHomeLaneForTest(false) + service.syncForCurrentSelection() + + expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(systemAuth) + }) + + it('refreshes untouched retained-pane auth during real-home rate polling', async () => { + const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'old-token') + const refreshedSystemAuth = createCodexAuthJson( + 'system@example.com', + 'acct-system', + 'refreshed-token' + ) + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(systemAuth) + + setRealHomeLaneForTest(true) + writeFileSync(getSystemCodexAuthPath(), refreshedSystemAuth, 'utf-8') + + expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) + expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(refreshedSystemAuth) + }) + + it('clears managed transition state before later retained-auth reconciliation', async () => { + const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'old-token') + const refreshedSystemAuth = createCodexAuthJson( + 'system@example.com', + 'acct-system', + 'refreshed-token' + ) + const managedHomePath = createManagedAuth( + testState.userDataDir, + 'account-1', + createCodexAuthJson('managed@example.com', 'acct-managed', 'managed-token') + ) + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'managed@example.com', + managedHomePath, + providerAccountId: 'acct-managed', + workspaceLabel: null, + workspaceAccountId: 'acct-managed', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ] + }) + const store = createStore(settings) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + settings.activeCodexManagedAccountId = 'account-1' + settings.activeCodexManagedAccountIdsByRuntime = { host: 'account-1', wsl: {} } + service.syncForCurrentSelection() + + writeFileSync(getRuntimeCodexAuthPath(), systemAuth, 'utf-8') + writeFileSync( + join(testState.userDataDir, 'codex-runtime-home', 'shared-runtime-auth-provenance.json'), + `${JSON.stringify({ owner: 'system-default', authJson: systemAuth })}\n` + ) + settings.activeCodexManagedAccountId = null + settings.activeCodexManagedAccountIdsByRuntime = { host: null, wsl: {} } + setRealHomeLaneForTest(true) + service.syncForCurrentSelection() + writeFileSync(getSystemCodexAuthPath(), refreshedSystemAuth, 'utf-8') + + expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) + expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(systemAuth) + expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) + expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(refreshedSystemAuth) + }) + + it('does not overwrite auth changed by a retained Codex process', async () => { + const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'old-token') + const retainedAuth = createCodexAuthJson('system@example.com', 'acct-system', 'retained-token') + const refreshedSystemAuth = createCodexAuthJson( + 'system@example.com', + 'acct-system', + 'refreshed-token' + ) + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: true })) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + writeFileSync(getRuntimeCodexAuthPath(), retainedAuth, 'utf-8') + writeFileSync(getSystemCodexAuthPath(), refreshedSystemAuth, 'utf-8') + + expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) + expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(retainedAuth) + expect(readFileSync(getSystemCodexAuthPath(), 'utf-8')).toBe(refreshedSystemAuth) + }) + + it('applies source logout after a retained pane refreshes the same identity', async () => { + const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'old-token') + const retainedAuth = createCodexAuthJson('system@example.com', 'acct-system', 'retained-token') + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + new CodexRuntimeHomeService(store as never) + + setRealHomeLaneForTest(true) + const service = new CodexRuntimeHomeService(store as never) + writeFileSync(getRuntimeCodexAuthPath(), retainedAuth, 'utf-8') + rmSync(getSystemCodexAuthPath()) + + expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) + expect(existsSync(getRuntimeCodexAuthPath())).toBe(false) + }) + + it('repairs a completed pending system-auth replacement after restart', async () => { + const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'old-token') + const refreshedSystemAuth = createCodexAuthJson( + 'system@example.com', + 'acct-system', + 'refreshed-token' + ) + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + writeFileSync( + getSharedRuntimeAuthProvenancePath(), + `${JSON.stringify({ + owner: 'pending', + next: { owner: 'system-default', authJson: systemAuth }, + runtimeAuthJson: systemAuth + })}\n` + ) + + setRealHomeLaneForTest(true) + writeFileSync(getSystemCodexAuthPath(), refreshedSystemAuth, 'utf-8') + expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) + expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(refreshedSystemAuth) + }) + + it('recovers runtime auth quarantined by an interrupted guarded update', async () => { + const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system') + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + new CodexRuntimeHomeService(store as never) + const runtimeAuthPath = getRuntimeCodexAuthPath() + const heldAuthPath = `${runtimeAuthPath}.orca-guarded` + renameSync(runtimeAuthPath, heldAuthPath) + + setRealHomeLaneForTest(true) + new CodexRuntimeHomeService(store as never) + + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(systemAuth) + expect(existsSync(heldAuthPath)).toBe(false) + }) + + it('fences a pending auth replacement when runtime bytes do not match its intent', async () => { + const systemAuth = createCodexAuthJson('shared@example.com', 'acct-shared', 'system') + const retainedAuth = createCodexAuthJson('shared@example.com', 'acct-shared', 'retained') + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + writeFileSync( + getSharedRuntimeAuthProvenancePath(), + `${JSON.stringify({ + owner: 'pending', + next: { owner: 'system-default', authJson: systemAuth }, + runtimeAuthJson: systemAuth + })}\n` + ) + writeFileSync(getRuntimeCodexAuthPath(), retainedAuth, 'utf-8') + + setRealHomeLaneForTest(true) + rmSync(getSystemCodexAuthPath()) + expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) + expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(retainedAuth) + }) + + it('does not treat malformed provenance as a missing migration marker', async () => { + const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'old-token') + const refreshedSystemAuth = createCodexAuthJson( + 'system@example.com', + 'acct-system', + 'refreshed-token' + ) + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + writeFileSync(getSharedRuntimeAuthProvenancePath(), '{"owner":"pending"}\n') + + setRealHomeLaneForTest(true) + writeFileSync(getSystemCodexAuthPath(), refreshedSystemAuth, 'utf-8') + expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) + expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(systemAuth) + }) + + it('recreates proven logged-out shared auth after a real-home re-login', async () => { + const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'old-token') + const reloginAuth = createCodexAuthJson('system@example.com', 'acct-system', 'relogin-token') + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + new CodexRuntimeHomeService(store as never) + + setRealHomeLaneForTest(true) + const service = new CodexRuntimeHomeService(store as never) + rmSync(getSystemCodexAuthPath()) + expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) + expect(existsSync(getRuntimeCodexAuthPath())).toBe(false) + + writeFileSync(getSystemCodexAuthPath(), reloginAuth, 'utf-8') + expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) + expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(reloginAuth) + }) + + it('preserves shared config changes when a pending real-home lane falls back', async () => { + const systemConfigPath = join(getSystemCodexHomePath(), 'config.toml') + const runtimeConfigPath = join(getRuntimeCodexHomePath(), 'config.toml') + writeFileSync(systemConfigPath, 'model = "baseline"\n', 'utf-8') + const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath()) + writeFileSync(runtimeConfigPath, 'model = "runtime-change"\n', 'utf-8') + + setRealHomeLaneForTest(true) + let realHomeLaneUsable = true + service.setRealHomeLaneGate(() => realHomeLaneUsable) + expect(readFileSync(runtimeConfigPath, 'utf-8')).toBe('model = "runtime-change"\n') + + realHomeLaneUsable = false + expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath()) + expect(readFileSync(systemConfigPath, 'utf-8')).toBe('model = "runtime-change"\n') + expect(readFileSync(runtimeConfigPath, 'utf-8')).toBe('model = "runtime-change"\n') }) it('routes a host MANAGED account to its own self-contained home when the flag is ON', async () => { @@ -1627,10 +2053,9 @@ describe('CodexRuntimeHomeService', () => { expect(service.isHostSystemDefaultRealHome()).toBe(true) expect(service.prepareForCodexLaunch()).toBeNull() - // E owns refreshes in place, so takeover never adopts later shared bytes; - // retained pre-E panes receive the selected system-default identity instead. + // E owns refreshes in place, so takeover ignores later shared-mirror bytes. expect(readFileSync(join(managedHomePath, 'auth.json'), 'utf-8')).toBe(managedAuth) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(systemAuth) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(refreshedManagedAuth) }) 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 8bd50b2b616..b9ca850a4d2 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -32,7 +32,12 @@ 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' +import { + recoverInterruptedGuardedFileOperation, + removeFileAtomicallyIfUnchanged, + writeFileAtomically, + writeFileAtomicallyIfUnchanged +} from './fs-utils' import { getOrcaManagedCodexHomePath, getOrcaUserDataPath, @@ -63,9 +68,8 @@ import { } from './runtime-selection' import { getDefaultWslDistro, getWslHome } from '../wsl' import { isCodexSystemDefaultRealHomeEnabled } from '../codex/codex-real-home-flag' -import { hasCustomCodexHomeOverride } from '../codex/codex-real-home-path' +import { hasCustomCodexHomeOverrideForLaunch } from '../codex/codex-real-home-path' import { invalidateCodexSessionBackfillMarker } from '../codex/codex-session-backfill-marker' -import { isShellStartupEnvProbeSupported, readShellStartupEnvVar } from '../pty/shell-startup-env' import { assertOwnedHostCodexManagedHomePath } from './host-codex-managed-home-ownership' import { codexAuthIsFresher, @@ -74,7 +78,7 @@ 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 { syncLegacySharedCodexConfigForRetainedPanes } from './legacy-shared-config-compatibility' import type { CodexPaneHomeRoute } from '../codex/codex-pane-account-registry' type CodexSystemDefaultSnapshot = { @@ -86,6 +90,26 @@ type CodexRuntimeLogoutMarker = { loggedOutAt: number } +type CodexSharedRuntimeAuthProvenance = + | { owner: 'system-default'; authJson: string | null } + | { + owner: 'managed' + accountId: string + systemDefaultBaseline?: { authJson: string | null } + } +type CodexSharedRuntimeAuthPendingProvenance = { + owner: 'pending' + next: CodexSharedRuntimeAuthProvenance + runtimeAuthJson: string | null +} +type CodexSharedRuntimeAuthProvenanceFile = + | CodexSharedRuntimeAuthProvenance + | CodexSharedRuntimeAuthPendingProvenance + | { owner: 'fenced' } +type CodexSharedRuntimeAuthProvenanceStatus = + | { kind: 'missing' | 'fenced' } + | { kind: 'committed'; provenance: CodexSharedRuntimeAuthProvenance } + type CodexRuntimeLogoutMarkerStatus = | { kind: 'missing' } | { kind: 'applies' } @@ -101,20 +125,6 @@ type CodexReadBackMatch = } | { kind: 'none' | 'ambiguous' } -function readLaunchEnvValue( - launchEnv: NodeJS.ProcessEnv, - key: 'CODEX_HOME' | 'ORCA_CODEX_HOME' | 'HOME' | 'SHELL' -): string | undefined { - return Object.prototype.hasOwnProperty.call(launchEnv, key) ? launchEnv[key] : process.env[key] -} - -function getEffectiveCodexHomeEnv(launchEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv { - return { - CODEX_HOME: readLaunchEnvValue(launchEnv, 'CODEX_HOME'), - ORCA_CODEX_HOME: readLaunchEnvValue(launchEnv, 'ORCA_CODEX_HOME') - } -} - export class CodexRuntimeHomeService { // Which managed account runtime auth.json mirrors; null means it follows system-default ~/.codex instead of a managed account. private lastSyncedAccountId: string | null = null @@ -128,8 +138,10 @@ export class CodexRuntimeHomeService { // Why: a flag-ON host account refreshes auth in its own home. Remember that // provenance so a later deselect/rollback never adopts stale shared bytes. private lastHostAccountUsedSelfContainedHome = false + private sharedAuthRefreshBlockedByManagedTransition = false constructor(private readonly store: Store) { + this.safeRecoverInterruptedRuntimeAuthOperation() this.safeMigrateLegacySharedAuth() this.safeMigrateLegacyManagedState() this.safeMigrateLegacyActiveHomePointer() @@ -184,7 +196,8 @@ export class CodexRuntimeHomeService { // 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; // the retired mirror is refreshed only for pre-rollout PTYs. - syncLegacySharedCodexHomeForRetainedPanes() + this.syncLegacySharedSystemDefaultAuthForRetainedPanes() + syncLegacySharedCodexConfigForRetainedPanes() return null } this.invalidateBackfillAfterManagedSystemDefaultLaunch(launchEnv) @@ -266,6 +279,8 @@ export class CodexRuntimeHomeService { // is a complete CODEX_HOME. Hooks/trust are installed by the launch caller. this.lastSyncedAccountId = account.id this.lastHostAccountUsedSelfContainedHome = true + this.sharedAuthRefreshBlockedByManagedTransition = true + this.markSharedRuntimeAuthManaged(account.id) syncSystemCodexResourcesIntoManagedHome(perAccountHome) syncSystemConfigIntoManagedCodexHome({ runtimeHomePath: perAccountHome, @@ -307,6 +322,8 @@ export class CodexRuntimeHomeService { if (perAccountHome) { this.lastSyncedAccountId = account.id this.lastHostAccountUsedSelfContainedHome = true + this.sharedAuthRefreshBlockedByManagedTransition = true + this.markSharedRuntimeAuthManaged(account.id) // Why: selection runs well before the user restarts a pane, so history is // already linked in by the time the newly launched Codex opens /resume. this.startSelfContainedSessionBridgeForLaunch(perAccountHome) @@ -440,9 +457,6 @@ 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 @@ -456,23 +470,7 @@ export class CodexRuntimeHomeService { ) { return false } - // Why: PTY callers can overlay environment values that the Electron main - // process never inherited. Those custom homes must keep the managed lane. - const effectiveEnv = launchEnv ? getEffectiveCodexHomeEnv(launchEnv) : process.env - if (hasCustomCodexHomeOverride(effectiveEnv)) { - return false - } - if (!isShellStartupEnvProbeSupported()) { - return false - } - // Why: Finder/Dock launches do not inherit shell exports, but the login - // shell can re-export a custom home after spawn and bypass the trusted lane. - const shellCodexHome = readShellStartupEnvVar( - 'CODEX_HOME', - launchEnv ? readLaunchEnvValue(launchEnv, 'HOME') : process.env.HOME, - launchEnv ? readLaunchEnvValue(launchEnv, 'SHELL') : process.env.SHELL - ) - return !hasCustomCodexHomeOverride({ CODEX_HOME: shellCodexHome }) + return !hasCustomCodexHomeOverrideForLaunch(launchEnv) } isHostSystemDefaultRealHome(launchEnv?: NodeJS.ProcessEnv): boolean { @@ -558,6 +556,7 @@ export class CodexRuntimeHomeService { // CODEX_HOME before ~/.codex. Nested Orca launches can inherit the // managed home, restarting the background OAuth conflict (#5370), so // pin this non-interactive lane to the native home explicitly. + this.syncLegacySharedSystemDefaultAuthForRetainedPanes() return getSystemCodexHomePath() } this.syncForCurrentSelection() @@ -579,7 +578,6 @@ export class CodexRuntimeHomeService { this.syncSelfContainedManagedSelection(selfContainedAccount) return } - const settings = this.store.getSettings() if (this.lastHostAccountUsedSelfContainedHome) { // Why: E auth is already canonical in the per-account home. Reset the @@ -592,6 +590,16 @@ export class CodexRuntimeHomeService { return } } + if (this.isHostSystemDefaultRealHome()) { + // Why: retained daemon panes may own shared auth from a managed launch; + // compatibility reconciliation runs later with durable provenance. + if (this.lastSyncedAccountId !== null) { + this.sharedAuthRefreshBlockedByManagedTransition = true + this.lastSyncedAccountId = null + this.lastWrittenAuthJson = null + } + return + } const runtimeAuthExistedBeforeSync = existsSync(this.getRuntimeAuthPath()) if (this.lastSyncedAccountId === null) { this.captureSystemDefaultSnapshot({ force: false }) @@ -713,7 +721,10 @@ export class CodexRuntimeHomeService { this.skipNextReadBackForAccountId = null } this.lastSyncedAccountId = activeAccount.id - this.writeRuntimeAuth(readFileSync(activeAuthPath, 'utf-8')) + this.writeRuntimeAuth(readFileSync(activeAuthPath, 'utf-8'), { + owner: 'managed', + accountId: activeAccount.id + }) } // Why: re-auth/add-account write fresh managed tokens, so skip the next read-back to avoid clobbering them with stale runtime tokens. @@ -847,6 +858,14 @@ export class CodexRuntimeHomeService { } } + private safeRecoverInterruptedRuntimeAuthOperation(): void { + try { + recoverInterruptedGuardedFileOperation(this.getRuntimeAuthPath()) + } catch (error) { + console.warn('[codex-runtime-home] Failed to recover interrupted auth update:', error) + } + } + private getActiveAccount( accounts: CodexManagedAccount[], activeAccountId: string | null @@ -1256,6 +1275,10 @@ export class CodexRuntimeHomeService { return join(this.getRuntimeMetadataDir(), 'system-default-runtime-logout.json') } + private getSharedRuntimeAuthProvenancePath(): string { + return join(this.getRuntimeMetadataDir(), 'shared-runtime-auth-provenance.json') + } + private getRuntimeMetadataDir(): string { const metadataDir = join(app.getPath('userData'), 'codex-runtime-home') mkdirSync(metadataDir, { recursive: true }) @@ -1546,14 +1569,33 @@ export class CodexRuntimeHomeService { try { const runtimeAuth = readFileSync(runtimeAuthPath, 'utf-8') + const provenanceStatus = this.resolveSharedRuntimeAuthProvenanceStatus() + const provenance = provenanceStatus.kind === 'committed' ? provenanceStatus.provenance : null + if (provenance?.owner === 'managed') { + this.captureSystemDefaultSnapshot({ force: true }) + if (!existsSync(systemDefaultAuthPath)) { + this.clearRuntimeAuthAfterSystemDefaultLogout(runtimeAuthPath) + return + } + this.writeRuntimeAuth(readFileSync(systemDefaultAuthPath, 'utf-8'), { + owner: 'system-default' + }) + return + } + const snapshot = this.readSystemDefaultSnapshot(this.getSystemDefaultSnapshotPath()) + const mirroredSystemDefaultAuth = + provenance?.owner === 'system-default' + ? provenance.authJson + : provenanceStatus.kind === 'missing' + ? (this.lastWrittenAuthJson ?? snapshot?.authJson ?? null) + : null if (!existsSync(systemDefaultAuthPath)) { - const snapshot = this.readSystemDefaultSnapshot(this.getSystemDefaultSnapshotPath()) - const mirroredSystemDefaultAuth = this.lastWrittenAuthJson ?? snapshot?.authJson ?? null if (mirroredSystemDefaultAuth !== null && runtimeAuth === mirroredSystemDefaultAuth) { this.clearRuntimeAuthAfterSystemDefaultLogout(runtimeAuthPath) return } if ( + provenance?.owner === 'system-default' && mirroredSystemDefaultAuth !== null && this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuth, mirroredSystemDefaultAuth) ) { @@ -1562,29 +1604,128 @@ export class CodexRuntimeHomeService { return } const systemDefaultAuth = readFileSync(systemDefaultAuthPath, 'utf-8') - if (runtimeAuth !== systemDefaultAuth) { - const snapshot = this.readSystemDefaultSnapshot(this.getSystemDefaultSnapshotPath()) - const mirroredSystemDefaultAuth = this.lastWrittenAuthJson ?? snapshot?.authJson ?? null - if ( - mirroredSystemDefaultAuth !== null && - systemDefaultAuth === mirroredSystemDefaultAuth && - this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuth, mirroredSystemDefaultAuth) - ) { - // Why: Codex refreshes tokens in the runtime CODEX_HOME; read that back to ~/.codex so the next sync won't clobber fresh creds with stale ones. - this.writeSystemDefaultAuth(runtimeAuth) - this.captureSystemDefaultSnapshot({ force: true }) - this.lastWrittenAuthJson = runtimeAuth - return - } - // Why: mirror external logins/logouts into Orca's runtime home so unmanaged Codex sessions keep matching the current system-default state. - this.captureSystemDefaultSnapshot({ force: true }) - this.writeRuntimeAuth(systemDefaultAuth) + if (runtimeAuth === systemDefaultAuth) { + this.writeRuntimeAuth(systemDefaultAuth, { owner: 'system-default' }) + return } + if ( + provenance?.owner === 'system-default' && + mirroredSystemDefaultAuth !== null && + systemDefaultAuth === mirroredSystemDefaultAuth && + this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuth, mirroredSystemDefaultAuth) + ) { + // Why: Codex refreshes tokens in the runtime CODEX_HOME; read that back to ~/.codex so the next sync won't clobber fresh creds with stale ones. + this.writeSystemDefaultAuth(runtimeAuth) + this.captureSystemDefaultSnapshot({ force: true }) + this.writeRuntimeAuth(runtimeAuth, { owner: 'system-default' }) + return + } + // Why: mirror external logins/logouts into Orca's runtime home so unmanaged Codex sessions keep matching the current system-default state. + this.captureSystemDefaultSnapshot({ force: true }) + this.writeRuntimeAuth(systemDefaultAuth, { owner: 'system-default' }) } catch (error) { console.warn('[codex-runtime-home] Failed to sync system-default auth:', error) } } + private syncLegacySharedSystemDefaultAuthForRetainedPanes(): void { + if (this.sharedAuthRefreshBlockedByManagedTransition || this.lastSyncedAccountId !== null) { + this.sharedAuthRefreshBlockedByManagedTransition = false + return + } + const runtimeAuthPath = this.getRuntimeAuthPath() + try { + let provenanceStatus = this.resolveSharedRuntimeAuthProvenanceStatus() + if ( + provenanceStatus.kind === 'committed' && + provenanceStatus.provenance.owner === 'managed' + ) { + const restoredProvenance = this.restoreUntouchedSystemDefaultProvenance( + provenanceStatus.provenance + ) + if (restoredProvenance) { + provenanceStatus = { kind: 'committed', provenance: restoredProvenance } + } + } + if ( + provenanceStatus.kind === 'fenced' || + (provenanceStatus.kind === 'committed' && provenanceStatus.provenance.owner === 'managed') + ) { + return + } + const systemAuth = this.readSystemDefaultAuth() + if (!existsSync(runtimeAuthPath)) { + const logoutMarkerStatus = this.getRuntimeLogoutMarkerStatus() + if ( + logoutMarkerStatus.kind === 'system-default-changed' && + logoutMarkerStatus.systemDefaultAuthJson !== null + ) { + const replaced = this.writeRuntimeAuth( + logoutMarkerStatus.systemDefaultAuthJson, + { + owner: 'system-default' + }, + { expectedContents: null } + ) + if (replaced) { + this.captureSystemDefaultSnapshot({ force: true }) + } + } + return + } + const runtimeAuthBeforeSync = readFileSync(runtimeAuthPath, 'utf-8') + const snapshot = this.readSystemDefaultSnapshot(this.getSystemDefaultSnapshotPath()) + const provenance = provenanceStatus.kind === 'committed' ? provenanceStatus.provenance : null + const knownSharedAuth = + provenance?.owner === 'system-default' + ? provenance.authJson + : provenanceStatus.kind === 'missing' + ? (this.lastWrittenAuthJson ?? snapshot?.authJson ?? null) + : null + // Why: only bytes Orca can prove it wrote belong to the compatibility + // mirror; retained Codex or a managed transition owns every other value. + if (knownSharedAuth === null) { + return + } + const sharedAuthOwnedBySystemDefault = + runtimeAuthBeforeSync === knownSharedAuth || + (provenance?.owner === 'system-default' && + systemAuth === null && + this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuthBeforeSync, knownSharedAuth)) + if (!sharedAuthOwnedBySystemDefault) { + return + } + if (systemAuth === null) { + removeFileAtomicallyIfUnchanged(runtimeAuthPath, runtimeAuthBeforeSync) + if (existsSync(runtimeAuthPath)) { + this.persistSharedRuntimeAuthProvenance({ owner: 'fenced' }) + return + } + this.captureSystemDefaultSnapshot({ force: true }) + this.persistRuntimeLogoutMarker(null) + this.lastWrittenAuthJson = null + this.persistSharedRuntimeAuthProvenance({ + owner: 'system-default', + authJson: null + }) + return + } + if (runtimeAuthBeforeSync !== knownSharedAuth) { + return + } + const replaced = this.writeRuntimeAuth( + systemAuth, + { owner: 'system-default' }, + { expectedContents: runtimeAuthBeforeSync } + ) + if (replaced) { + this.captureSystemDefaultSnapshot({ force: true }) + } + } catch (error) { + console.warn('[codex-runtime-home] Failed to refresh retained-pane auth:', error) + } + } + private restoreSystemDefaultSnapshot(options: { detectExternalLogin: boolean }): void { const snapshotPath = this.getSystemDefaultSnapshotPath() const runtimeAuthPath = this.getRuntimeAuthPath() @@ -1592,7 +1733,7 @@ export class CodexRuntimeHomeService { if (existsSync(systemDefaultAuthPath)) { const systemDefaultAuth = readFileSync(systemDefaultAuthPath, 'utf-8') this.captureSystemDefaultSnapshot({ force: true }) - this.writeRuntimeAuth(systemDefaultAuth) + this.writeRuntimeAuth(systemDefaultAuth, { owner: 'system-default' }) return } @@ -1632,7 +1773,7 @@ export class CodexRuntimeHomeService { this.lastWrittenAuthJson = null return } - this.writeRuntimeAuth(refreshedSnapshot.authJson) + this.writeRuntimeAuth(refreshedSnapshot.authJson, { owner: 'system-default' }) return } if (snapshot.authJson === null) { @@ -1640,7 +1781,7 @@ export class CodexRuntimeHomeService { this.lastWrittenAuthJson = null return } - this.writeRuntimeAuth(snapshot.authJson) + this.writeRuntimeAuth(snapshot.authJson, { owner: 'system-default' }) } private writeSystemDefaultAuth(contents: string): void { @@ -1656,6 +1797,10 @@ export class CodexRuntimeHomeService { this.captureSystemDefaultSnapshot({ force: true }) this.persistRuntimeLogoutMarker() this.lastWrittenAuthJson = null + this.persistSharedRuntimeAuthProvenance({ + owner: 'system-default', + authJson: null + }) } private readSystemDefaultAuth(): string | null { @@ -1663,16 +1808,42 @@ export class CodexRuntimeHomeService { return existsSync(systemDefaultAuthPath) ? readFileSync(systemDefaultAuthPath, 'utf-8') : null } - private writeRuntimeAuth(contents: string): void { + private writeRuntimeAuth( + contents: string, + owner: { owner: 'system-default' } | { owner: 'managed'; accountId: string }, + options?: { expectedContents: string | null } + ): boolean { // Why: auth.json holds credentials; restrict to owner-only so other users on a shared machine cannot read it. - this.clearRuntimeLogoutMarker() + const runtimeAuthPath = this.getRuntimeAuthPath() + if (options && !this.fileContentsMatchExpected(runtimeAuthPath, options.expectedContents)) { + return false + } + const provenance: CodexSharedRuntimeAuthProvenance = + owner.owner === 'system-default' ? { owner: 'system-default', authJson: contents } : owner + this.persistSharedRuntimeAuthProvenance({ + owner: 'pending', + next: provenance, + runtimeAuthJson: contents + }) if (this.fileContentsEqual(this.getRuntimeAuthPath(), contents)) { - this.ensureOwnerOnlyMode(this.getRuntimeAuthPath()) + this.ensureOwnerOnlyMode(runtimeAuthPath) this.lastWrittenAuthJson = contents - return + this.persistSharedRuntimeAuthProvenance(provenance) + this.clearRuntimeLogoutMarker() + return true + } + const replaced = options + ? writeFileAtomicallyIfUnchanged(runtimeAuthPath, options.expectedContents, contents, { + mode: 0o600 + }) + : (writeFileAtomically(runtimeAuthPath, contents, { mode: 0o600 }), true) + if (!replaced) { + return false } - writeFileAtomically(this.getRuntimeAuthPath(), contents, { mode: 0o600 }) this.lastWrittenAuthJson = contents + this.persistSharedRuntimeAuthProvenance(provenance) + this.clearRuntimeLogoutMarker() + return true } private writeRuntimeAuthAtPath(authPath: string, contents: string): void { @@ -1692,6 +1863,13 @@ export class CodexRuntimeHomeService { } } + private fileContentsMatchExpected(targetPath: string, expectedContents: string | null): boolean { + if (expectedContents === null) { + return !existsSync(targetPath) + } + return this.fileContentsEqual(targetPath, expectedContents) + } + private ensureOwnerOnlyMode(targetPath: string): void { if (process.platform === 'win32') { return @@ -1756,6 +1934,167 @@ export class CodexRuntimeHomeService { rmSync(this.getRuntimeLogoutMarkerPath(), { force: true }) } + private persistSharedRuntimeAuthProvenance( + provenance: CodexSharedRuntimeAuthProvenanceFile + ): void { + writeFileAtomically( + this.getSharedRuntimeAuthProvenancePath(), + `${JSON.stringify(provenance, null, 2)}\n`, + { mode: 0o600 } + ) + } + + private markSharedRuntimeAuthManaged(accountId: string): void { + const status = this.resolveSharedRuntimeAuthProvenanceStatus() + if ( + status.kind === 'committed' && + status.provenance.owner === 'managed' && + status.provenance.accountId === accountId + ) { + return + } + const runtimeAuthJson = this.readRuntimeAuthForProvenance() + const systemDefaultBaseline = this.getUntouchedSystemDefaultBaseline(status, runtimeAuthJson) + const provenance: CodexSharedRuntimeAuthProvenance = { + owner: 'managed', + accountId, + ...(systemDefaultBaseline ? { systemDefaultBaseline } : {}) + } + this.persistSharedRuntimeAuthProvenance({ + owner: 'pending', + next: provenance, + runtimeAuthJson + }) + if (this.readRuntimeAuthForProvenance() === runtimeAuthJson) { + this.persistSharedRuntimeAuthProvenance(provenance) + } + } + + private getUntouchedSystemDefaultBaseline( + status: CodexSharedRuntimeAuthProvenanceStatus, + runtimeAuthJson: string | null + ): { authJson: string | null } | null { + if (status.kind !== 'committed') { + return null + } + const baseline = + status.provenance.owner === 'system-default' + ? { authJson: status.provenance.authJson } + : status.provenance.systemDefaultBaseline + return baseline && runtimeAuthJson === baseline.authJson ? baseline : null + } + + private restoreUntouchedSystemDefaultProvenance( + provenance: Extract + ): Extract | null { + const baseline = provenance.systemDefaultBaseline + if (!baseline || this.readRuntimeAuthForProvenance() !== baseline.authJson) { + return null + } + const restored = { owner: 'system-default' as const, authJson: baseline.authJson } + this.persistSharedRuntimeAuthProvenance({ + owner: 'pending', + next: restored, + runtimeAuthJson: baseline.authJson + }) + if (this.readRuntimeAuthForProvenance() !== baseline.authJson) { + return null + } + this.persistSharedRuntimeAuthProvenance(restored) + return restored + } + + private resolveSharedRuntimeAuthProvenanceStatus(): CodexSharedRuntimeAuthProvenanceStatus { + const provenancePath = this.getSharedRuntimeAuthProvenancePath() + if (!existsSync(provenancePath)) { + return { kind: 'missing' } + } + let parsed: unknown + try { + parsed = JSON.parse(readFileSync(provenancePath, 'utf-8')) as unknown + } catch { + return { kind: 'fenced' } + } + const committed = this.parseSharedRuntimeAuthProvenance(parsed) + if (committed) { + return { kind: 'committed', provenance: committed } + } + const pending = this.parsePendingSharedRuntimeAuthProvenance(parsed) + if (!pending || this.readRuntimeAuthForProvenance() !== pending.runtimeAuthJson) { + return { kind: 'fenced' } + } + try { + this.persistSharedRuntimeAuthProvenance(pending.next) + return { kind: 'committed', provenance: pending.next } + } catch { + return { kind: 'fenced' } + } + } + + private parseSharedRuntimeAuthProvenance( + value: unknown + ): CodexSharedRuntimeAuthProvenance | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null + } + const provenance = value as Record + if ( + provenance.owner === 'system-default' && + (typeof provenance.authJson === 'string' || provenance.authJson === null) + ) { + return { owner: 'system-default', authJson: provenance.authJson } + } + if ( + provenance.owner !== 'managed' || + typeof provenance.accountId !== 'string' || + provenance.accountId.length === 0 + ) { + return null + } + const baseline = this.parseSystemDefaultBaseline(provenance.systemDefaultBaseline) + if ('systemDefaultBaseline' in provenance && !baseline) { + return null + } + return { + owner: 'managed', + accountId: provenance.accountId, + ...(baseline ? { systemDefaultBaseline: baseline } : {}) + } + } + + private parseSystemDefaultBaseline(value: unknown): { authJson: string | null } | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null + } + const baseline = value as Record + return typeof baseline.authJson === 'string' || baseline.authJson === null + ? { authJson: baseline.authJson } + : null + } + + private parsePendingSharedRuntimeAuthProvenance( + value: unknown + ): CodexSharedRuntimeAuthPendingProvenance | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null + } + const pending = value as Record + const next = this.parseSharedRuntimeAuthProvenance(pending.next) + return pending.owner === 'pending' && + next && + (typeof pending.runtimeAuthJson === 'string' || pending.runtimeAuthJson === null) + ? { owner: 'pending', next, runtimeAuthJson: pending.runtimeAuthJson } + : null + } + + private readRuntimeAuthForProvenance(): string | null { + try { + return readFileSync(this.getRuntimeAuthPath(), 'utf-8') + } catch { + return null + } + } + private readSystemDefaultSnapshot(snapshotPath: string): CodexSystemDefaultSnapshot | null { let rawContents: string try { diff --git a/src/main/codex/codex-config-mirror.ts b/src/main/codex/codex-config-mirror.ts index 484828b406a..047256ef18e 100644 --- a/src/main/codex/codex-config-mirror.ts +++ b/src/main/codex/codex-config-mirror.ts @@ -1,7 +1,7 @@ import { existsSync } from 'node:fs' import { dirname, join } from 'node:path' import { readAgentStateFileSync } from '../agent-state-file-reader' -import { writeFileAtomically } from '../codex-accounts/fs-utils' +import { writeFileAtomically, writeFileAtomicallyIfUnchanged } from '../codex-accounts/fs-utils' import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths' import { rewriteRelativePathConfigValues } from './codex-config-path-reference-rewrite' import { normalizeDeprecatedCodexHookFeatureFlag } from './config-toml-deprecated-hook-flag' @@ -110,18 +110,22 @@ export function syncSystemConfigIntoLegacySharedCodexHome( } 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) + const runtimeConfigBeforeMirror = existsSync(runtimeConfigPath) + ? readAgentStateFileSync(runtimeConfigPath) + : null + const nextRuntimeConfig = + runtimeConfigBeforeMirror !== null + ? mergeSystemCodexConfigIntoRuntime( + runtimeConfigBeforeMirror, + prepareSystemConfigForRuntimeMirror(rawSystemConfig, sourceConfigDir) + ) + : prepareSystemConfigForFreshRuntimeMirror(rawSystemConfig, sourceConfigDir) + if (runtimeConfigBeforeMirror === nextRuntimeConfig) { + return } + // Why: stage first, then compare immediately before replace so a retained + // Codex trust write during mirror preparation wins. + writeFileAtomicallyIfUnchanged(runtimeConfigPath, runtimeConfigBeforeMirror, nextRuntimeConfig) } type CodexConfigMirrorResult = diff --git a/src/main/codex/codex-pane-account-registry.ts b/src/main/codex/codex-pane-account-registry.ts index 9aeed82dbbd..872d245bf68 100644 --- a/src/main/codex/codex-pane-account-registry.ts +++ b/src/main/codex/codex-pane-account-registry.ts @@ -1,6 +1,10 @@ import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { getOrcaUserDataPath } from './codex-home-paths' +import type { + CodexEnvironmentHomeOverride, + CodexShellStartupHomeOverride +} from './codex-real-home-path' /** * Remembers which Codex account each live PTY was launched under. @@ -21,6 +25,10 @@ export type CodexPaneAccountRecord = { accountId: string | null /** Absent only on records written before route provenance was introduced. */ homeRoute?: CodexPaneHomeRoute + /** Rechecked when CODEX_HOME came from process-global shell startup. */ + shellStartupHomeOverride?: CodexShellStartupHomeOverride + /** Rechecked after restart when CODEX_HOME came from the process environment. */ + environmentHomeOverride?: CodexEnvironmentHomeOverride } type RegistryFile = { @@ -68,13 +76,41 @@ function parseRegistry(parsed: unknown): RegistryFile { empty.panes[ptyId] = { selectionKey: record.selectionKey, accountId: record.accountId, - ...(isPaneHomeRoute(record.homeRoute) ? { homeRoute: record.homeRoute } : {}) + ...(isPaneHomeRoute(record.homeRoute) ? { homeRoute: record.homeRoute } : {}), + ...(isShellStartupHomeOverride(record.shellStartupHomeOverride) + ? { shellStartupHomeOverride: record.shellStartupHomeOverride } + : {}), + ...(isEnvironmentHomeOverride(record.environmentHomeOverride) + ? { environmentHomeOverride: record.environmentHomeOverride } + : {}) } } } return empty } +function isEnvironmentHomeOverride(value: unknown): value is CodexEnvironmentHomeOverride { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false + } + const context = value as Partial + return typeof context.codexHome === 'string' && context.codexHome.length > 0 +} + +function isShellStartupHomeOverride(value: unknown): value is CodexShellStartupHomeOverride { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false + } + const context = value as Partial + return ( + typeof context.home === 'string' && + context.home.length > 0 && + (context.shell === undefined || typeof context.shell === 'string') && + typeof context.codexHome === 'string' && + context.codexHome.length > 0 + ) +} + function isPaneAccountRecord(value: unknown): value is CodexPaneAccountRecord { if (!value || typeof value !== 'object' || Array.isArray(value)) { return false @@ -132,7 +168,12 @@ export function recordCodexPaneAccount(ptyId: string, record: CodexPaneAccountRe if ( existing?.selectionKey === record.selectionKey && existing.accountId === record.accountId && - existing.homeRoute === record.homeRoute + existing.homeRoute === record.homeRoute && + shellStartupHomeOverridesEqual( + existing.shellStartupHomeOverride, + record.shellStartupHomeOverride + ) && + environmentHomeOverridesEqual(existing.environmentHomeOverride, record.environmentHomeOverride) ) { return } @@ -146,6 +187,24 @@ export function recordCodexPaneAccount(ptyId: string, record: CodexPaneAccountRe writeRegistry(registry) } +function environmentHomeOverridesEqual( + left: CodexEnvironmentHomeOverride | undefined, + right: CodexEnvironmentHomeOverride | undefined +): boolean { + return left?.codexHome === right?.codexHome +} + +function shellStartupHomeOverridesEqual( + left: CodexShellStartupHomeOverride | undefined, + right: CodexShellStartupHomeOverride | undefined +): boolean { + return ( + left?.home === right?.home && + left?.shell === right?.shell && + left?.codexHome === right?.codexHome + ) +} + /** * Drops a PTY's record once it exits. */ diff --git a/src/main/codex/codex-pane-launch-account.ts b/src/main/codex/codex-pane-launch-account.ts index c1aafe50c6f..0b8044aff8d 100644 --- a/src/main/codex/codex-pane-launch-account.ts +++ b/src/main/codex/codex-pane-launch-account.ts @@ -7,6 +7,10 @@ import { type CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' import type { CodexPaneAccountRecord, CodexPaneHomeRoute } from './codex-pane-account-registry' +import type { + CodexEnvironmentHomeOverride, + CodexShellStartupHomeOverride +} from './codex-real-home-path' type CodexPaneLaunchAccountSettings = Pick< GlobalSettings, @@ -29,6 +33,8 @@ export function resolveCodexPaneLaunchAccount(args: { pinnedByResume: boolean launchCodexHomePath: string | null recordHomeRoute?: boolean + shellStartupHomeOverride?: CodexShellStartupHomeOverride + environmentHomeOverride?: CodexEnvironmentHomeOverride systemCodexHomePath: string settings: CodexPaneLaunchAccountSettings target: CodexAccountSelectionTarget @@ -39,13 +45,29 @@ export function resolveCodexPaneLaunchAccount(args: { return { selectionKey, accountId: getSelectedCodexAccountIdForTarget(args.settings, args.target), - ...(homeRoute ? { homeRoute } : {}) + ...(homeRoute ? { homeRoute } : {}), + ...(args.shellStartupHomeOverride + ? { shellStartupHomeOverride: args.shellStartupHomeOverride } + : {}), + ...(args.environmentHomeOverride + ? { environmentHomeOverride: args.environmentHomeOverride } + : {}) } } const accountId = resolveCodexHomeOwnerAccountId(args) return accountId === undefined ? null - : { selectionKey, accountId, ...(homeRoute ? { homeRoute } : {}) } + : { + selectionKey, + accountId, + ...(homeRoute ? { homeRoute } : {}), + ...(args.shellStartupHomeOverride + ? { shellStartupHomeOverride: args.shellStartupHomeOverride } + : {}), + ...(args.environmentHomeOverride + ? { environmentHomeOverride: args.environmentHomeOverride } + : {}) + } } function resolveCodexPaneHomeRoute(args: { diff --git a/src/main/codex/codex-real-home-path.test.ts b/src/main/codex/codex-real-home-path.test.ts index 3d91682d7cc..7dc961c7a18 100644 --- a/src/main/codex/codex-real-home-path.test.ts +++ b/src/main/codex/codex-real-home-path.test.ts @@ -1,6 +1,23 @@ -import { sep } from 'node:path' -import { describe, expect, it } from 'vitest' -import { hasCustomCodexHomeOverride } from './codex-real-home-path' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, sep } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + getCustomCodexHomeOverrideForLaunch, + hasCustomCodexHomeOverride, + hasCustomCodexHomeOverrideForLaunch, + shellStartupCodexHomeOverrideMatches +} from './codex-real-home-path' +import { __resetShellStartupEnvCache } from '../pty/shell-startup-env' + +const temporaryHomes: string[] = [] + +afterEach(() => { + __resetShellStartupEnvCache() + for (const path of temporaryHomes.splice(0)) { + rmSync(path, { recursive: true, force: true }) + } +}) describe('hasCustomCodexHomeOverride', () => { it('recognizes normalized aliases of Orca-owned CODEX_HOME', () => { @@ -22,4 +39,40 @@ describe('hasCustomCodexHomeOverride', () => { }) ).toBe(true) }) + + it('captures explicit environment provenance for restart comparison', () => { + const codexHome = join(process.cwd(), 'custom-codex-home') + + expect(getCustomCodexHomeOverrideForLaunch({ CODEX_HOME: codexHome })).toEqual({ + source: 'environment', + context: { codexHome } + }) + }) + + it.skipIf(process.platform === 'win32')( + 'detects a pane-local shell startup override from its launch HOME', + () => { + const paneHome = mkdtempSync(join(tmpdir(), 'orca-codex-pane-home-')) + temporaryHomes.push(paneHome) + writeFileSync(join(paneHome, '.zshrc'), 'export CODEX_HOME="$HOME/custom-codex-home"\n') + + expect(hasCustomCodexHomeOverrideForLaunch({ HOME: paneHome, SHELL: '/bin/zsh' })).toBe(true) + const override = getCustomCodexHomeOverrideForLaunch({ + HOME: paneHome, + SHELL: '/bin/zsh' + }) + expect(override).toEqual({ + source: 'shell-startup', + context: { + home: paneHome, + shell: '/bin/zsh', + codexHome: join(paneHome, 'custom-codex-home') + } + }) + expect( + override?.source === 'shell-startup' && + shellStartupCodexHomeOverrideMatches(override.context) + ).toBe(true) + } + ) }) diff --git a/src/main/codex/codex-real-home-path.ts b/src/main/codex/codex-real-home-path.ts index 6f6db65710a..700359d4f37 100644 --- a/src/main/codex/codex-real-home-path.ts +++ b/src/main/codex/codex-real-home-path.ts @@ -1,5 +1,20 @@ import { resolve } from 'node:path' import { getSystemCodexHomePath } from './codex-home-paths' +import { readShellStartupEnvVar } from '../pty/shell-startup-env' + +export type CodexShellStartupHomeOverride = { + home: string + shell?: string + codexHome: string +} + +export type CodexEnvironmentHomeOverride = { + codexHome: string +} + +export type CustomCodexHomeOverrideForLaunch = + | { source: 'environment'; context: CodexEnvironmentHomeOverride } + | { source: 'shell-startup'; context: CodexShellStartupHomeOverride } /** True when the user points Codex outside its standard native home. */ export function hasCustomCodexHomeOverride(env: NodeJS.ProcessEnv = process.env): boolean { @@ -18,6 +33,85 @@ export function hasCustomCodexHomeOverride(env: NodeJS.ProcessEnv = process.env) ) } +export function hasCustomCodexHomeOverrideForLaunch(launchEnv?: NodeJS.ProcessEnv): boolean { + return getCustomCodexHomeOverrideForLaunch(launchEnv) !== null +} + +export function getCustomCodexHomeOverrideForLaunch( + launchEnv?: NodeJS.ProcessEnv +): CustomCodexHomeOverrideForLaunch | null { + const effectiveEnv = launchEnv + ? { + CODEX_HOME: getLaunchEnvValue(launchEnv, 'CODEX_HOME'), + ORCA_CODEX_HOME: getLaunchEnvValue(launchEnv, 'ORCA_CODEX_HOME') + } + : process.env + if (hasCustomCodexHomeOverride(effectiveEnv)) { + return { + source: 'environment', + context: { codexHome: effectiveEnv.CODEX_HOME!.trim() } + } + } + const home = launchEnv ? getLaunchEnvValue(launchEnv, 'HOME') : process.env.HOME + const shell = launchEnv ? getLaunchEnvValue(launchEnv, 'SHELL') : process.env.SHELL + const shellCodexHome = readShellStartupEnvVar('CODEX_HOME', home, shell) + if (!home || !shellCodexHome || !hasCustomCodexHomeOverride({ CODEX_HOME: shellCodexHome })) { + return null + } + return { + source: 'shell-startup', + context: { + home, + ...(shell ? { shell } : {}), + codexHome: shellCodexHome + } + } +} + +export function environmentCodexHomeOverrideContextsEqual( + left: CodexEnvironmentHomeOverride, + right: CodexEnvironmentHomeOverride +): boolean { + return normalizePathForComparison(left.codexHome) === normalizePathForComparison(right.codexHome) +} + +export function shellStartupCodexHomeOverrideMatches( + context: CodexShellStartupHomeOverride, + currentContext: CodexShellStartupHomeOverride = context +): boolean { + if (!shellStartupCodexHomeOverrideContextsEqual(context, currentContext)) { + return false + } + const currentCodexHome = readShellStartupEnvVar( + 'CODEX_HOME', + currentContext.home, + currentContext.shell + ) + return Boolean( + currentCodexHome && + hasCustomCodexHomeOverride({ CODEX_HOME: currentCodexHome }) && + normalizePathForComparison(currentCodexHome) === normalizePathForComparison(context.codexHome) + ) +} + +export function shellStartupCodexHomeOverrideContextsEqual( + left: CodexShellStartupHomeOverride, + right: CodexShellStartupHomeOverride +): boolean { + return ( + normalizePathForComparison(left.home) === normalizePathForComparison(right.home) && + left.shell === right.shell && + normalizePathForComparison(left.codexHome) === normalizePathForComparison(right.codexHome) + ) +} + +function getLaunchEnvValue( + launchEnv: NodeJS.ProcessEnv, + key: 'CODEX_HOME' | 'ORCA_CODEX_HOME' | 'HOME' | 'SHELL' +): string | undefined { + return Object.prototype.hasOwnProperty.call(launchEnv, key) ? launchEnv[key] : process.env[key] +} + function normalizePathForComparison(value: string): string { const normalized = resolve(value) return process.platform === 'win32' ? normalized.toLowerCase() : normalized diff --git a/src/main/codex/codex-stale-pane-accounts.test.ts b/src/main/codex/codex-stale-pane-accounts.test.ts index c3ea9c7c551..37812e43473 100644 --- a/src/main/codex/codex-stale-pane-accounts.test.ts +++ b/src/main/codex/codex-stale-pane-accounts.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import type { GlobalSettings } from '../../shared/types' @@ -10,6 +10,7 @@ import { recordCodexPaneAccount } from './codex-pane-account-registry' import { forgetStaleCodexPanes, listStaleCodexPanes } from './codex-stale-pane-accounts' +import { __resetShellStartupEnvCache } from '../pty/shell-startup-env' let userDataPath: string let previousUserDataPath: string | undefined @@ -32,6 +33,7 @@ beforeEach(() => { }) afterEach(() => { + __resetShellStartupEnvCache() rmSync(userDataPath, { recursive: true, force: true }) if (previousUserDataPath === undefined) { delete process.env.ORCA_USER_DATA_PATH @@ -46,7 +48,12 @@ describe('codex pane account registry', () => { recordCodexPaneAccount('pty-1', { selectionKey: 'host', accountId: 'account-a', - homeRoute: 'account-home' + homeRoute: 'account-home', + shellStartupHomeOverride: { + home: '/pane-home', + shell: '/bin/zsh', + codexHome: '/pane-home/custom-codex-home' + } }) _internals.resetCache() @@ -54,7 +61,12 @@ describe('codex pane account registry', () => { expect(getCodexPaneAccount('pty-1')).toEqual({ selectionKey: 'host', accountId: 'account-a', - homeRoute: 'account-home' + homeRoute: 'account-home', + shellStartupHomeOverride: { + home: '/pane-home', + shell: '/bin/zsh', + codexHome: '/pane-home/custom-codex-home' + } }) }) @@ -146,7 +158,14 @@ describe('listStaleCodexPanes', () => { ptyIds: ['pty-1'], settings: settingsWithSelection('account-b') }) - ).toEqual([{ ptyId: 'pty-1', launchAccountId: 'account-a', activeAccountId: 'account-b' }]) + ).toEqual([ + { + ptyId: 'pty-1', + launchAccountId: 'account-a', + activeAccountId: 'account-b', + reason: 'account-change' + } + ]) }) it('reports a managed pane after the selection drops to the system default', () => { @@ -154,7 +173,14 @@ describe('listStaleCodexPanes', () => { expect( listStaleCodexPanes({ ptyIds: ['pty-1'], settings: settingsWithSelection(null) }) - ).toEqual([{ ptyId: 'pty-1', launchAccountId: 'account-a', activeAccountId: null }]) + ).toEqual([ + { + ptyId: 'pty-1', + launchAccountId: 'account-a', + activeAccountId: null, + reason: 'account-change' + } + ]) }) it('leaves a pane alone when its launch account is still selected', () => { @@ -178,7 +204,14 @@ describe('listStaleCodexPanes', () => { settings: settingsWithSelection(null), activeHostHomeRoute: 'real-home' }) - ).toEqual([{ ptyId: 'pty-1', launchAccountId: null, activeAccountId: null }]) + ).toEqual([ + { + ptyId: 'pty-1', + launchAccountId: null, + activeAccountId: null, + reason: 'home-route-change' + } + ]) }) it('does not guess a route for panes recorded before route provenance', () => { @@ -193,6 +226,182 @@ describe('listStaleCodexPanes', () => { ).toEqual([]) }) + it('reports a retained pane after its process CODEX_HOME changes', () => { + recordCodexPaneAccount('pty-1', { + selectionKey: 'host', + accountId: null, + homeRoute: 'shared-home', + environmentHomeOverride: { codexHome: '/custom/codex-a' } + }) + + expect( + listStaleCodexPanes({ + ptyIds: ['pty-1'], + settings: settingsWithSelection(null), + activeHostHomeRoute: 'shared-home', + activeHostCustomHomeOverride: { + source: 'environment', + context: { codexHome: '/custom/codex-b' } + } + }) + ).toEqual([ + { + ptyId: 'pty-1', + launchAccountId: null, + activeAccountId: null, + reason: 'home-route-change' + } + ]) + }) + + it('leaves a retained pane alone while its process CODEX_HOME is unchanged', () => { + recordCodexPaneAccount('pty-1', { + selectionKey: 'host', + accountId: null, + homeRoute: 'shared-home', + environmentHomeOverride: { codexHome: '/custom/codex-home' } + }) + + expect( + listStaleCodexPanes({ + ptyIds: ['pty-1'], + settings: settingsWithSelection(null), + activeHostHomeRoute: 'shared-home', + activeHostCustomHomeOverride: { + source: 'environment', + context: { codexHome: '/custom/codex-home' } + } + }) + ).toEqual([]) + }) + + it.each([ + [ + 'environment to shell startup', + { environmentHomeOverride: { codexHome: '/custom/codex-home' } }, + { + source: 'shell-startup' as const, + context: { + home: '/custom', + shell: '/bin/zsh', + codexHome: '/custom/codex-home' + } + } + ], + [ + 'shell startup to environment', + { + shellStartupHomeOverride: { + home: '/custom', + shell: '/bin/zsh', + codexHome: '/custom/codex-home' + } + }, + { + source: 'environment' as const, + context: { codexHome: '/custom/codex-home' } + } + ] + ])('does not report a same-path %s change', (_label, provenance, activeOverride) => { + recordCodexPaneAccount('pty-1', { + selectionKey: 'host', + accountId: null, + homeRoute: 'shared-home', + ...provenance + }) + + expect( + listStaleCodexPanes({ + ptyIds: ['pty-1'], + settings: settingsWithSelection(null), + activeHostHomeRoute: 'shared-home', + activeHostCustomHomeOverride: activeOverride + }) + ).toEqual([]) + }) + + it.skipIf(process.platform === 'win32')( + 'reports a retained pane after its shell startup CODEX_HOME is removed', + () => { + const paneHome = join(userDataPath, 'pane-home') + mkdirSync(paneHome, { recursive: true }) + const startupPath = join(paneHome, '.zshrc') + const customHome = join(paneHome, 'custom-codex-home') + writeFileSync(startupPath, 'export CODEX_HOME="$HOME/custom-codex-home"\n') + recordCodexPaneAccount('pty-1', { + selectionKey: 'host', + accountId: null, + homeRoute: 'shared-home', + shellStartupHomeOverride: { + home: paneHome, + shell: '/bin/zsh', + codexHome: customHome + } + }) + + expect( + listStaleCodexPanes({ + ptyIds: ['pty-1'], + settings: settingsWithSelection(null), + activeHostHomeRoute: 'real-home' + }) + ).toEqual([]) + + writeFileSync(startupPath, '') + __resetShellStartupEnvCache() + expect( + listStaleCodexPanes({ + ptyIds: ['pty-1'], + settings: settingsWithSelection(null), + activeHostHomeRoute: 'real-home' + }) + ).toEqual([ + { + ptyId: 'pty-1', + launchAccountId: null, + activeAccountId: null, + reason: 'home-route-change' + } + ]) + } + ) + + it.skipIf(process.platform === 'win32')( + 'reports a retained pane when the replacement launch context changes', + () => { + const paneHome = join(userDataPath, 'pane-home') + mkdirSync(paneHome, { recursive: true }) + const customHome = join(paneHome, 'custom-codex-home') + writeFileSync(join(paneHome, '.zshrc'), 'export CODEX_HOME="$HOME/custom-codex-home"\n') + recordCodexPaneAccount('pty-1', { + selectionKey: 'host', + accountId: null, + homeRoute: 'shared-home', + shellStartupHomeOverride: { + home: paneHome, + shell: '/bin/zsh', + codexHome: customHome + } + }) + + expect( + listStaleCodexPanes({ + ptyIds: ['pty-1'], + settings: settingsWithSelection(null), + activeHostHomeRoute: 'real-home', + activeHostCustomHomeOverride: null + }) + ).toEqual([ + { + ptyId: 'pty-1', + launchAccountId: null, + activeAccountId: null, + reason: 'home-route-change' + } + ]) + } + ) + it('never reports an unrecorded PTY, so an upgrade cannot invent a prompt', () => { expect( listStaleCodexPanes({ ptyIds: ['pty-unknown'], settings: settingsWithSelection('account-b') }) @@ -212,7 +421,14 @@ describe('listStaleCodexPanes', () => { ptyIds: ['pty-1', 'pty-2'], settings: settingsWithSelection('account-b') }) - ).toEqual([{ ptyId: 'pty-2', launchAccountId: 'account-a', activeAccountId: 'account-b' }]) + ).toEqual([ + { + ptyId: 'pty-2', + launchAccountId: 'account-a', + activeAccountId: 'account-b', + reason: 'account-change' + } + ]) }) it('compares a WSL pane against its own distro selection', () => { @@ -226,6 +442,13 @@ describe('listStaleCodexPanes', () => { // distro's switch must not restart another distro's panes. settings: settingsWithSelection('account-b', { Ubuntu: 'account-a', Debian: 'account-d' }) }) - ).toEqual([{ ptyId: 'pty-2', launchAccountId: 'account-c', activeAccountId: 'account-d' }]) + ).toEqual([ + { + ptyId: 'pty-2', + launchAccountId: 'account-c', + activeAccountId: 'account-d', + reason: 'account-change' + } + ]) }) }) diff --git a/src/main/codex/codex-stale-pane-accounts.ts b/src/main/codex/codex-stale-pane-accounts.ts index 61346d668db..c3c9ea3f998 100644 --- a/src/main/codex/codex-stale-pane-accounts.ts +++ b/src/main/codex/codex-stale-pane-accounts.ts @@ -5,11 +5,20 @@ import { getCodexPaneAccount, type CodexPaneHomeRoute } from './codex-pane-account-registry' +import { + environmentCodexHomeOverrideContextsEqual, + getCustomCodexHomeOverrideForLaunch, + shellStartupCodexHomeOverrideMatches, + type CustomCodexHomeOverrideForLaunch, + type CodexEnvironmentHomeOverride, + type CodexShellStartupHomeOverride +} from './codex-real-home-path' export type StaleCodexPane = { ptyId: string launchAccountId: string | null activeAccountId: string | null + reason: 'account-change' | 'home-route-change' } /** @@ -20,6 +29,7 @@ export function listStaleCodexPanes(args: { ptyIds: readonly string[] settings: GlobalSettings activeHostHomeRoute?: CodexPaneHomeRoute + activeHostCustomHomeOverride?: CustomCodexHomeOverrideForLaunch | null }): StaleCodexPane[] { const stalePanes: StaleCodexPane[] = [] for (const ptyId of args.ptyIds) { @@ -31,18 +41,73 @@ export function listStaleCodexPanes(args: { args.settings, parseSelectionLaneKey(record.selectionKey) ) + const customHomeChanged = getCustomHomeChanged(record, args) + const hasCustomHomeProvenance = + record.shellStartupHomeOverride !== undefined || record.environmentHomeOverride !== undefined 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 }) + (customHomeChanged || + (!hasCustomHomeProvenance && + args.activeHostHomeRoute !== undefined && + record.homeRoute !== args.activeHostHomeRoute)) + const accountChanged = record.accountId !== activeAccountId + if (accountChanged || homeRouteChanged) { + stalePanes.push({ + ptyId, + launchAccountId: record.accountId, + activeAccountId, + reason: homeRouteChanged ? 'home-route-change' : 'account-change' + }) } } return stalePanes } +function getCustomHomeChanged( + record: { + shellStartupHomeOverride?: CodexShellStartupHomeOverride + environmentHomeOverride?: CodexEnvironmentHomeOverride + }, + args: { + activeHostCustomHomeOverride?: CustomCodexHomeOverrideForLaunch | null + } +): boolean { + if (record.shellStartupHomeOverride) { + if (!Object.prototype.hasOwnProperty.call(args, 'activeHostCustomHomeOverride')) { + const currentProcessOverride = getCustomCodexHomeOverrideForLaunch() + return currentProcessOverride + ? customHomeOverrideChanged(record.shellStartupHomeOverride.codexHome, { + activeHostCustomHomeOverride: currentProcessOverride + }) + : !shellStartupCodexHomeOverrideMatches(record.shellStartupHomeOverride) + } + return customHomeOverrideChanged(record.shellStartupHomeOverride.codexHome, args) + } + if (!record.environmentHomeOverride) { + return false + } + return customHomeOverrideChanged(record.environmentHomeOverride.codexHome, args) +} + +function customHomeOverrideChanged( + recordedCodexHome: string, + args: { + activeHostCustomHomeOverride?: CustomCodexHomeOverrideForLaunch | null + } +): boolean { + const currentOverride = Object.prototype.hasOwnProperty.call(args, 'activeHostCustomHomeOverride') + ? args.activeHostCustomHomeOverride + : getCustomCodexHomeOverrideForLaunch() + return ( + !currentOverride || + !environmentCodexHomeOverrideContextsEqual( + { codexHome: recordedCodexHome }, + { codexHome: currentOverride.context.codexHome } + ) + ) +} + /** * Drops the launch record for panes the user chose to keep on the old account. * diff --git a/src/main/ipc/codex-accounts.ts b/src/main/ipc/codex-accounts.ts index 0c111a5f6d8..e7268d8a777 100644 --- a/src/main/ipc/codex-accounts.ts +++ b/src/main/ipc/codex-accounts.ts @@ -3,6 +3,7 @@ import type { CodexAccountAddTarget, CodexAccountService } from '../codex-accoun import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' import { listRecordedCodexPaneLanes } from '../codex/codex-pane-account-registry' import { forgetStaleCodexPanes, listStaleCodexPanes } from '../codex/codex-stale-pane-accounts' +import { getCustomCodexHomeOverrideForLaunch } from '../codex/codex-real-home-path' import type { GlobalSettings } from '../../shared/types' export function registerCodexAccountHandlers( @@ -17,7 +18,8 @@ export function registerCodexAccountHandlers( return listStaleCodexPanes({ ptyIds: args.ptyIds.filter((ptyId): ptyId is string => typeof ptyId === 'string'), settings, - activeHostHomeRoute: codexAccounts.runtimeHomeService.getSelectedHostCodexHomeRoute() + activeHostHomeRoute: codexAccounts.runtimeHomeService.getSelectedHostCodexHomeRoute(), + activeHostCustomHomeOverride: getCustomCodexHomeOverrideForLaunch() }) }) 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 27ea55b8c08..b554a8e87a8 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -249,6 +249,7 @@ import { resolveWindowsShellLaunchArgs } from '../providers/windows-shell-args' import { _resetWslCachesForTests, _setWslCachesForTests } from '../wsl' import { wslHookRelayManager } from '../agent-hooks/wsl-hook-relay-manager' import { acquireWatcherRemovalGate } from './watcher-removal-gate' +import { __resetShellStartupEnvCache } from '../pty/shell-startup-env' import { acceptSshPtyOutputData, acceptSshPtyOutputExit, @@ -386,6 +387,7 @@ describe('registerPtyHandlers', () => { mainWindow.webContents.removeListener.mockReset() // Why: hidden-delivery gate state is module-level (PTY-keyed), so tests must not leak hidden bits across cases. _resetHiddenRendererPtyDeliveryGateForTest() + __resetShellStartupEnvCache() // Why: mirror real Electron — ipcMain.handle throws on a duplicate channel, catching re-registration that forgot removeHandler. handleMock.mockImplementation((channel: string, handler: (...a: unknown[]) => unknown) => { @@ -15019,6 +15021,120 @@ describe('registerPtyHandlers', () => { ]) }) + posixOnlyIt( + 'does not guess route provenance for a pane-local shell startup CODEX_HOME', + async () => { + setLocalPtyProvider({ + spawn: vi.fn(async () => ({ id: 'pty-custom-home' })), + 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((path: string) => + path === '/pane-home/.zshrc' ? 'export CODEX_HOME="$HOME/custom-codex-home"\n' : '' + ) + const getSettings = vi.fn().mockReturnValue({ activeCodexManagedAccountId: null }) + registerPtyHandlers( + mainWindow as never, + undefined, + () => TEST_CODEX_HOME, + getSettings as never + ) + + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + env: { + CODEX_HOME: '', + ORCA_CODEX_HOME: '', + HOME: '/pane-home', + SHELL: '/bin/zsh' + } + }) + + expect(recordCodexPaneAccountMock).toHaveBeenCalledWith('pty-custom-home', { + selectionKey: 'host', + accountId: null + }) + } + ) + + it('records route provenance for a process-wide CODEX_HOME', async () => { + const previousCodexHome = process.env.CODEX_HOME + process.env.CODEX_HOME = '/process/custom-codex-home' + try { + setLocalPtyProvider({ + spawn: vi.fn(async () => ({ id: 'pty-process-home' })), + 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) + const getSettings = vi.fn().mockReturnValue({ activeCodexManagedAccountId: null }) + registerPtyHandlers( + mainWindow as never, + undefined, + () => TEST_CODEX_HOME, + getSettings as never + ) + + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + env: { CODEX_HOME: '/process/custom-codex-home' } + }) + + expect(recordCodexPaneAccountMock).toHaveBeenCalledWith('pty-process-home', { + selectionKey: 'host', + accountId: null, + homeRoute: 'shared-home', + environmentHomeOverride: { codexHome: '/process/custom-codex-home' } + }) + } finally { + if (previousCodexHome === undefined) { + delete process.env.CODEX_HOME + } else { + process.env.CODEX_HOME = previousCodexHome + } + } + }) + + it('does not guess route provenance for a pane-local environment CODEX_HOME', async () => { + setLocalPtyProvider({ + spawn: vi.fn(async () => ({ id: 'pty-pane-env-home' })), + 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) + const getSettings = vi.fn().mockReturnValue({ activeCodexManagedAccountId: null }) + registerPtyHandlers(mainWindow as never, undefined, () => TEST_CODEX_HOME, getSettings as never) + + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + env: { CODEX_HOME: '/pane/custom-codex-home' } + }) + + expect(recordCodexPaneAccountMock).toHaveBeenCalledWith('pty-pane-env-home', { + selectionKey: 'host', + accountId: null + }) + }) + 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' })) diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index e2de0e9c1a8..12333592f71 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -198,7 +198,11 @@ 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 { + environmentCodexHomeOverrideContextsEqual, + getCustomCodexHomeOverrideForLaunch, + shellStartupCodexHomeOverrideContextsEqual +} 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' @@ -1003,14 +1007,40 @@ function recordCodexPaneAccountForSpawn(args: { if (!args.ptyId || !args.isDaemonHostSpawn || args.isReattach) { return } + const customHomeOverride = getCustomCodexHomeOverrideForLaunch(args.launchEnv) + const processHomeOverride = customHomeOverride ? getCustomCodexHomeOverrideForLaunch() : null + const recheckableEnvironmentOverride = + customHomeOverride?.source === 'environment' && + processHomeOverride?.source === 'environment' && + environmentCodexHomeOverrideContextsEqual( + customHomeOverride.context, + processHomeOverride.context + ) + ? customHomeOverride.context + : undefined + const recheckableShellStartupOverride = + customHomeOverride?.source === 'shell-startup' && + processHomeOverride?.source === 'shell-startup' && + shellStartupCodexHomeOverrideContextsEqual( + customHomeOverride.context, + processHomeOverride.context + ) + ? customHomeOverride.context + : undefined const record = args.settings ? 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. + // Why: pane-local overrides cannot be re-derived when a restart builds + // a fresh launch env, so route prompts would guess and block valid input. recordHomeRoute: - args.pinnedByResume || !hasCustomCodexHomeOverride({ ...process.env, ...args.launchEnv }), + args.pinnedByResume || + ((customHomeOverride?.source !== 'environment' || + recheckableEnvironmentOverride !== undefined) && + (customHomeOverride?.source !== 'shell-startup' || + recheckableShellStartupOverride !== undefined)), + shellStartupHomeOverride: args.pinnedByResume ? undefined : recheckableShellStartupOverride, + environmentHomeOverride: args.pinnedByResume ? undefined : recheckableEnvironmentOverride, systemCodexHomePath: getSystemCodexHomePath(), settings: args.settings, target: args.target diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index cdb5000d6cc..35c58ab5a71 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -2379,10 +2379,14 @@ export type PreloadApi = { wslDistro?: string | null }) => Promise /** Live PTYs whose baked CODEX_HOME still points at a deselected account. */ - listStalePanes: (args: { - ptyIds: string[] - }) => Promise< - { ptyId: string; launchAccountId: string | null; activeAccountId: string | null }[] + listStalePanes: (args: { ptyIds: string[] }) => Promise< + { + ptyId: string + launchAccountId: string | null + activeAccountId: string | null + /** Optional for compatibility with a pre-reason main process. */ + reason?: 'account-change' | 'home-route-change' + }[] > /** The selection lane each PTY launched from, keyed by pty id; unrecorded panes are absent. */ listRecordedPaneLanes: (args: { ptyIds: string[] }) => Promise> diff --git a/src/preload/index.ts b/src/preload/index.ts index 9da862f485b..3862d973ff5 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2046,7 +2046,12 @@ const api = { listStalePanes: (args: { ptyIds: string[] }): Promise< - { ptyId: string; launchAccountId: string | null; activeAccountId: string | null }[] + { + ptyId: string + launchAccountId: string | null + activeAccountId: string | null + reason?: 'account-change' | 'home-route-change' + }[] > => ipcRenderer.invoke('codexAccounts:listStalePanes', args), listRecordedPaneLanes: (args: { ptyIds: string[] }): Promise> => ipcRenderer.invoke('codexAccounts:listRecordedPaneLanes', args), diff --git a/src/renderer/src/components/CodexRestartChip.tsx b/src/renderer/src/components/CodexRestartChip.tsx index 92f19f41144..4df9c54a9ed 100644 --- a/src/renderer/src/components/CodexRestartChip.tsx +++ b/src/renderer/src/components/CodexRestartChip.tsx @@ -73,6 +73,7 @@ function isInsideHiddenTree(element: HTMLElement): boolean { type RestartNotice = { previousAccountLabel: string nextAccountLabel: string + homeRouteChanged?: true } export default function CodexRestartChip({ @@ -195,27 +196,41 @@ function LoudRestartOverlay({
- {translate('auto.components.CodexRestartChip.d3e8a1f4b2', 'Account switched')} + {restartNotice.homeRouteChanged + ? translate('auto.components.CodexRestartChip.8f0d5c92a1', 'Codex setup changed') + : translate('auto.components.CodexRestartChip.d3e8a1f4b2', 'Account switched')}
- {translate( - 'auto.components.CodexRestartChip.a4c8e1b2f7', - 'Codex is still signed in as {{value0}}', - { value0: restartNotice.previousAccountLabel } - )} + {restartNotice.homeRouteChanged + ? translate( + 'auto.components.CodexRestartChip.3ea91b5c07', + 'This Codex session is using an outdated configuration' + ) + : translate( + 'auto.components.CodexRestartChip.a4c8e1b2f7', + 'Codex is still signed in as {{value0}}', + { value0: restartNotice.previousAccountLabel } + )}
- {translate( - 'auto.components.CodexRestartChip.9375620cc3', - 'Restart this session to use {{value0}}. It stays on the previous account until you do.', - { value0: restartNotice.nextAccountLabel } - )} + {restartNotice.homeRouteChanged + ? translate( + 'auto.components.CodexRestartChip.e6b7139d2a', + 'Restart this session to load your current Codex configuration.' + ) + : translate( + 'auto.components.CodexRestartChip.9375620cc3', + 'Restart this session to use {{value0}}. It stays on the previous account until you do.', + { value0: restartNotice.nextAccountLabel } + )}