fix(codex): keep persistent panes logged in after home routing

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