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