mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 00:03:45 +00:00
fix(codex): harden retained home compatibility
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,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 }) => {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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>>
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
Reference in New Issue
Block a user