fix(codex): harden retained home compatibility

This commit is contained in:
Brennan Benson
2026-07-31 17:59:31 -07:00
parent b50ea8243a
commit c79bd8091f
28 changed files with 2151 additions and 306 deletions
+143 -2
View File
@@ -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<typeof NodeFs>()
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', () => {
+147 -1
View File
@@ -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
@@ -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<typeof FsUtils>()
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()
)
})
})
@@ -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)
}
}
@@ -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()
)
})
})
@@ -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)
}
}
@@ -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 () => {
+407 -68
View File
@@ -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<CodexSharedRuntimeAuthProvenance, { owner: 'managed' }>
): Extract<CodexSharedRuntimeAuthProvenance, { owner: 'system-default' }> | 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<string, unknown>
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<string, unknown>
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<string, unknown>
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 {
+16 -12
View File
@@ -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 =
+61 -2
View File
@@ -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<CodexEnvironmentHomeOverride>
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<CodexShellStartupHomeOverride>
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.
*/
+24 -2
View File
@@ -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: {
+56 -3
View File
@@ -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)
}
)
})
+94
View File
@@ -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
@@ -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'
}
])
})
})
+69 -4
View File
@@ -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.
*
+3 -1
View File
@@ -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 }) => {
+116
View File
@@ -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' }))
+34 -4
View File
@@ -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
+8 -4
View File
@@ -2379,10 +2379,14 @@ export type PreloadApi = {
wslDistro?: string | null
}) => Promise<CodexRateLimitAccountsState>
/** 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<Record<string, string>>
+6 -1
View File
@@ -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<Record<string, string>> =>
ipcRenderer.invoke('codexAccounts:listRecordedPaneLanes', args),
@@ -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({
</div>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="text-xs font-medium uppercase tracking-wide text-foreground">
{translate('auto.components.CodexRestartChip.d3e8a1f4b2', 'Account switched')}
{restartNotice.homeRouteChanged
? translate('auto.components.CodexRestartChip.8f0d5c92a1', 'Codex setup changed')
: translate('auto.components.CodexRestartChip.d3e8a1f4b2', 'Account switched')}
</div>
<div id={titleId} className="text-base font-semibold leading-tight">
{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 }
)}
</div>
</div>
</div>
<div id={bodyId} className="text-sm leading-relaxed text-muted-foreground">
{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 }
)}
</div>
<div className="mt-1 flex flex-wrap justify-end gap-2">
<Button type="button" variant="outline" size="sm" onClick={onDismiss}>
{translate('auto.components.CodexRestartChip.6133594b12', 'Keep old account')}
{restartNotice.homeRouteChanged
? translate('auto.components.CodexRestartChip.7b1d20f4c8', 'Keep current session')
: translate('auto.components.CodexRestartChip.6133594b12', 'Keep old account')}
</Button>
<Button type="button" variant="default" size="sm" onClick={onRestart}>
<RefreshCw />
@@ -159,6 +159,48 @@ describe('CodexRestartChip helpers', () => {
).toEqual(['Keep old account', 'Restart'])
})
it('uses configuration wording for a home-route restart', async () => {
useAppStore.setState({
tabsByWorktree: {
'worktree-1': [
{
id: 'tab-1',
worktreeId: 'worktree-1',
title: 'Terminal',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1,
ptyId: null
}
]
},
ptyIdsByTabId: { 'tab-1': ['pty-1'] },
codexRestartNoticeByPtyId: {
'pty-1': {
previousAccountLabel: 'System default',
nextAccountLabel: 'System default',
previousAccountId: null,
nextAccountId: null,
homeRouteChanged: true
}
}
})
await act(async () => {
root.render(React.createElement(CodexRestartChip, { worktreeId: 'worktree-1' }))
})
expect(container.textContent).toContain('Codex setup changed')
expect(container.textContent).toContain('This Codex session is using an outdated configuration')
expect(container.textContent).toContain(
'Restart this session to load your current Codex configuration.'
)
expect(
Array.from(container.querySelectorAll('button'), (button) => button.textContent?.trim())
).toEqual(['Keep current session', 'Restart'])
})
it('forgets the launch record when the user keeps the old account', async () => {
const forgetStalePanes = vi.fn(() => Promise.resolve())
Object.defineProperty(window, 'api', {
@@ -8,6 +8,16 @@ describe('codex restart notice key', () => {
previousAccountLabel: 'Account A',
nextAccountLabel: 'Account B'
})
).toBe('Account A\u0000Account B')
).toBe('account\u0000Account A\u0000Account B')
})
it('distinguishes a home-route notice from equal account labels', () => {
expect(
buildCodexRestartNoticeKey({
previousAccountLabel: 'System default',
nextAccountLabel: 'System default',
homeRouteChanged: true
})
).toBe('route\u0000System default\u0000System default')
})
})
@@ -1,6 +1,7 @@
export function buildCodexRestartNoticeKey(args: {
previousAccountLabel: string
nextAccountLabel: string
homeRouteChanged?: true
}): string {
return `${args.previousAccountLabel}\u0000${args.nextAccountLabel}`
return `${args.homeRouteChanged === true ? 'route' : 'account'}\u0000${args.previousAccountLabel}\u0000${args.nextAccountLabel}`
}
@@ -806,6 +806,27 @@ describe('markRestoredStaleCodexSessionsForRestart', () => {
})
})
it('keeps a system-default home-route change as a restart notice', async () => {
vi.mocked(window.api.codexAccounts.listStalePanes).mockResolvedValue([
{
ptyId: 'pty-1',
launchAccountId: null,
activeAccountId: null,
reason: 'home-route-change'
}
])
await markRestoredStaleCodexSessionsForRestart()
expect(useAppStore.getState().codexRestartNoticeByPtyId['pty-1']).toEqual({
previousAccountLabel: 'System default',
nextAccountLabel: 'System default',
previousAccountId: null,
nextAccountId: null,
homeRouteChanged: true
})
})
it('labels the system default when a pane launched without a managed account', async () => {
vi.mocked(window.api.codexAccounts.listStalePanes).mockResolvedValue([
{ ptyId: 'pty-1', launchAccountId: null, activeAccountId: 'account-b' }
@@ -266,7 +266,8 @@ export async function markRestoredStaleCodexSessionsForRestart(args?: {
// Why the ids: main decided staleness by id, and the labels can collide.
// Passing only labels hands the store a question it cannot answer.
previousAccountId: pane.launchAccountId,
nextAccountId: pane.activeAccountId
nextAccountId: pane.activeAccountId,
...(pane.reason === 'home-route-change' ? { homeRouteChanged: true as const } : {})
}))
)
// Why not every stale pane: the bind sweep suppresses a "notified" pane for the
@@ -186,6 +186,27 @@ describe('codex restart notices with colliding account labels', () => {
expect(useAppStore.getState().codexRestartNoticeByPtyId['pty-1']).toBeUndefined()
})
it('does not collapse an equal-account home-route change', () => {
useAppStore.getState().markCodexRestartNotices([
{
ptyId: 'pty-1',
previousAccountLabel: 'System default',
nextAccountLabel: 'System default',
previousAccountId: null,
nextAccountId: null,
homeRouteChanged: true
}
])
expect(useAppStore.getState().codexRestartNoticeByPtyId['pty-1']).toEqual({
previousAccountLabel: 'System default',
nextAccountLabel: 'System default',
previousAccountId: null,
nextAccountId: null,
homeRouteChanged: true
})
})
it('re-asks a dismissed pane when a same-labelled third account is selected', () => {
switchAccountById('pty-1', 'account-a', 'account-b')
useAppStore.getState().dismissCodexRestartNotices(['pty-1'])
+9 -1
View File
@@ -525,6 +525,8 @@ export type CodexRestartNotice = {
* a re-mark actually points back at the account the pane launched under. */
previousAccountId?: string | null
nextAccountId?: string | null
/** The launch account still matches, but its baked CODEX_HOME route is retired. */
homeRouteChanged?: true
/** Set once the user asks for the restart. The record outlives the prompt
* because the previous-account fields are the only memory of the account this
* pane actually launched under, which drives the A -> B -> A collapse. */
@@ -735,7 +737,9 @@ export type TerminalSlice = {
* prompt from one the A -> B -> A collapse dropped. */
markCodexRestartNotices: (
notices: (Pick<CodexRestartNotice, 'previousAccountLabel' | 'nextAccountLabel'> &
Partial<Pick<CodexRestartNotice, 'previousAccountId' | 'nextAccountId'>> & {
Partial<
Pick<CodexRestartNotice, 'previousAccountId' | 'nextAccountId' | 'homeRouteChanged'>
> & {
ptyId: string
})[]
) => string[]
@@ -3463,9 +3467,12 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
// describing two different accounts.
const launch = existing ?? notice
const target = { id: notice.nextAccountId, label: notice.nextAccountLabel }
const homeRouteChanged =
existing?.homeRouteChanged === true || notice.homeRouteChanged === true
// Why: a live Codex pane keeps its original launch account until it actually restarts, so A -> B -> A must not leave a stale restart notice.
if (
!homeRouteChanged &&
isSameCodexRestartNoticeAccount(
{ id: launch.previousAccountId, label: launch.previousAccountLabel },
target
@@ -3483,6 +3490,7 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
? {}
: { previousAccountId: launch.previousAccountId }),
...(notice.nextAccountId === undefined ? {} : { nextAccountId: notice.nextAccountId }),
...(homeRouteChanged ? { homeRouteChanged: true as const } : {}),
// Why: a queued restart relaunches under whatever account is selected
// when it runs, so a later switch does not reopen an answered prompt.
...(existing?.restartRequested ? { restartRequested: true as const } : {}),