fix(codex): preserve real-home lane invariants

This commit is contained in:
Brennan Benson
2026-07-15 15:54:51 -07:00
parent b552e719be
commit 7b7fa4eabf
11 changed files with 317 additions and 60 deletions
@@ -988,6 +988,7 @@ describe('CodexRuntimeHomeService', () => {
expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath())
expect(service.prepareForRateLimitFetch()).toBe(getRuntimeCodexHomePath())
expect(service.getHostCodexHomePathsForSessionDiscovery()).toEqual([getRuntimeCodexHomePath()])
expect(existsSync(getRuntimeCodexHomePath())).toBe(true)
})
@@ -998,6 +999,23 @@ describe('CodexRuntimeHomeService', () => {
expect(service.isHostSystemDefaultRealHome()).toBe(true)
expect(service.prepareForCodexLaunch()).toBeNull()
expect(service.getHostCodexHomePathsForSessionDiscovery()).toEqual([
getRuntimeCodexHomePath(),
getSystemCodexHomePath()
])
const perSpawnCustomHome = join(testState.fakeHomeDir, 'per-spawn-custom-codex-home')
expect(service.isHostSystemDefaultRealHome({ CODEX_HOME: perSpawnCustomHome })).toBe(false)
expect(service.prepareForCodexLaunch(undefined, { CODEX_HOME: perSpawnCustomHome })).toBe(
getRuntimeCodexHomePath()
)
writeFileSync(
join(testState.fakeHomeDir, '.zshrc'),
'export CODEX_HOME="$HOME/shell-custom-codex-home"\n',
'utf-8'
)
const shellLaunchEnv = { HOME: testState.fakeHomeDir, SHELL: '/bin/zsh' }
expect(service.isHostSystemDefaultRealHome(shellLaunchEnv)).toBe(false)
expect(service.prepareForCodexLaunch(undefined, shellLaunchEnv)).toBe(getRuntimeCodexHomePath())
const previousCodexHome = process.env.CODEX_HOME
const previousOrcaCodexHome = process.env.ORCA_CODEX_HOME
process.env.CODEX_HOME = getRuntimeCodexHomePath()
+50 -11
View File
@@ -62,6 +62,7 @@ import {
import { getDefaultWslDistro, getWslHome } from '../wsl'
import { isCodexSystemDefaultRealHomeEnabled } from '../codex/codex-real-home-flag'
import { hasCustomCodexHomeOverride } from '../codex/codex-real-home-path'
import { readShellStartupEnvVar } from '../pty/shell-startup-env'
type CodexAuthIdentity = {
email: string | null
@@ -93,6 +94,20 @@ 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 {
// Why: tracks whether the runtime auth.json currently mirrors a managed
// account. When null, runtime auth follows the user's system-default
@@ -140,7 +155,10 @@ export class CodexRuntimeHomeService {
* Historical session bridging is requested in the background so launch setup
* returns as soon as the active runtime home is ready.
*/
prepareForCodexLaunch(target?: CodexAccountSelectionTarget): string | null {
prepareForCodexLaunch(
target?: CodexAccountSelectionTarget,
launchEnv?: NodeJS.ProcessEnv
): string | null {
if (target?.runtime === 'wsl') {
const wslTarget = this.resolveWslDefaultTarget(target)
const syncedRuntimeHomePath = this.syncWslRuntimeForCurrentSelection(wslTarget)
@@ -149,7 +167,7 @@ export class CodexRuntimeHomeService {
this.startWslSessionBridgeForLaunch(wslTarget, runtimeHomePath)
return runtimeHomePath
}
if (this.isHostSystemDefaultRealHome()) {
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->
@@ -197,8 +215,14 @@ export class CodexRuntimeHomeService {
})
}
getHostRuntimeHomePath(): string {
return this.getRuntimeHomePath()
getHostCodexHomePathsForSessionDiscovery(): string[] {
const homes = [this.getRuntimeHomePath()]
if (this.isHostSystemDefaultRealHomeSelected()) {
// Why: nested Orca processes can retain an ambient managed CODEX_HOME;
// explicitly include the real lane so its sessions remain discoverable.
homes.push(getSystemCodexHomePath())
}
return homes.filter((home, index) => homes.indexOf(home) === index)
}
// Why: the real-home hook installer flips this gate off when the trust-grant
@@ -213,17 +237,32 @@ export class CodexRuntimeHomeService {
// Why: real-home routing applies only to the host system-default selection
// with the staged flag ON. Managed accounts keep hot-swap isolation; custom
// CODEX_HOMEs stay managed until phase 1 can track cleanup across old homes.
isHostSystemDefaultRealHomeSelected(): boolean {
isHostSystemDefaultRealHomeSelected(launchEnv?: NodeJS.ProcessEnv): boolean {
const settings = this.store.getSettings()
return (
isCodexSystemDefaultRealHomeEnabled(settings) &&
normalizeCodexRuntimeSelection(settings).host === null &&
!hasCustomCodexHomeOverride()
if (
!isCodexSystemDefaultRealHomeEnabled(settings) ||
normalizeCodexRuntimeSelection(settings).host !== null
) {
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
}
// 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 })
}
isHostSystemDefaultRealHome(): boolean {
return this.isHostSystemDefaultRealHomeSelected() && this.realHomeLaneGate()
isHostSystemDefaultRealHome(launchEnv?: NodeJS.ProcessEnv): boolean {
return this.isHostSystemDefaultRealHomeSelected(launchEnv) && this.realHomeLaneGate()
}
syncActiveWslSelectionsBeforeRestart(): void {
@@ -194,6 +194,49 @@ describe('ensureRealHomeCodexHookState (install)', () => {
expect(lane).toBe('installed')
expect(readFileSync(getRealHooksJsonPath(), 'utf-8')).toBe(firstRaw)
})
it('keeps later user hook trust positions stable when reconciling an existing install', () => {
grantSucceeds()
const userBefore = { hooks: [{ type: 'command', command: 'before.sh' }] }
writeFileSync(
getRealHooksJsonPath(),
`${JSON.stringify({ hooks: { Stop: [userBefore] } }, null, 2)}\n`,
'utf-8'
)
ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })
const installed = readRealHooksJson()
const userAfter = { hooks: [{ type: 'command', command: 'after.sh' }] }
installed.hooks!.Stop!.push(userAfter)
writeFileSync(getRealHooksJsonPath(), `${JSON.stringify(installed, null, 2)}\n`, 'utf-8')
expect(ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })).toBe(
'installed'
)
const reconciled = readRealHooksJson().hooks?.Stop
expect(reconciled?.[0]).toEqual(userBefore)
expect(reconciled?.[2]).toEqual(userAfter)
const plan = grantMock.mock.calls.at(-1)![0] as CodexManagedTrustGrantPlan
expect(plan.managedEntries.find((entry) => entry.eventLabel === 'stop')?.groupIndex).toBe(1)
})
it("keeps later user handler trust positions stable inside Orca's hook group", () => {
grantSucceeds()
ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })
const installed = readRealHooksJson()
const userAfter = { type: 'command', command: 'after.sh' }
installed.hooks!.Stop![0]!.hooks!.push(userAfter)
writeFileSync(getRealHooksJsonPath(), `${JSON.stringify(installed, null, 2)}\n`, 'utf-8')
expect(ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })).toBe(
'installed'
)
expect(readRealHooksJson().hooks?.Stop?.[0]?.hooks?.[1]).toEqual(userAfter)
const plan = grantMock.mock.calls.at(-1)![0] as CodexManagedTrustGrantPlan
const stopEntry = plan.managedEntries.find((entry) => entry.eventLabel === 'stop')
expect(stopEntry).toMatchObject({ groupIndex: 0, handlerIndex: 0 })
})
})
describe('ensureRealHomeCodexHookState (opt-out sweep)', () => {
+44 -7
View File
@@ -120,16 +120,13 @@ function installRealHomeCodexHook(userDataPath: string): RealHomeCodexHookLane {
const managedEntries: CodexTrustEntry[] = []
for (const eventName of material.events) {
const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : []
const cleaned = removeManagedCommands(current, isManagedCommand)
// Why: append LAST. Codex trust keys are positional
// (source:event:group:handler); prepending would shift every user entry
// and invalidate the user's own hook trust records.
nextHooks[eventName] = [...cleaned, { hooks: [buildManagedCommandHook(material.command)] }]
const reconciled = reconcileManagedHookDefinition(current, isManagedCommand, material.command)
nextHooks[eventName] = reconciled.definitions
managedEntries.push({
sourcePath: hooksJsonPath,
eventLabel: material.eventLabel[eventName],
groupIndex: cleaned.length,
handlerIndex: 0,
groupIndex: reconciled.groupIndex,
handlerIndex: reconciled.handlerIndex,
command: material.command,
timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS
})
@@ -177,6 +174,46 @@ function installRealHomeCodexHook(userDataPath: string): RealHomeCodexHookLane {
return 'unavailable'
}
function reconcileManagedHookDefinition(
current: HookDefinition[],
isManagedCommand: (command: string | undefined) => boolean,
command: string
): { definitions: HookDefinition[]; groupIndex: number; handlerIndex: number } {
const directCommandKeys = ['command', 'bash', 'powershell'] as const
const hasManagedDirectCommand = current.some((definition) =>
directCommandKeys.some((key) => isManagedCommand(definition[key]))
)
const nestedLocations = current.flatMap((definition, groupIndex) =>
Array.isArray(definition.hooks)
? definition.hooks.flatMap((hook, handlerIndex) =>
isManagedCommand(hook.command) ? [{ groupIndex, handlerIndex }] : []
)
: []
)
if (!hasManagedDirectCommand && nestedLocations.length === 1) {
const { groupIndex, handlerIndex } = nestedLocations[0]!
const definition = current[groupIndex]!
const hasDirectCommand = directCommandKeys.some((key) => typeof definition[key] === 'string')
if (definition.matcher === undefined && !hasDirectCommand) {
const definitions = [...current]
// Why: users can append groups or handlers after Orca's first install.
// Reusing the exact slot preserves all later positional trust keys.
const hooks = [...definition.hooks!]
hooks[handlerIndex] = buildManagedCommandHook(command)
definitions[groupIndex] = { ...definition, hooks }
return { definitions, groupIndex, handlerIndex }
}
}
const cleaned = removeManagedCommands(current, isManagedCommand)
// Why: first install appends LAST so no existing user trust position shifts.
return {
definitions: [...cleaned, { hooks: [buildManagedCommandHook(command)] }],
groupIndex: cleaned.length,
handlerIndex: 0
}
}
function getInstallRetryAfterMs(reason: CodexTrustGrantFallbackReason): number {
return reason === 'unsupported' || reason === 'unsupported-cached' || reason === 'disabled'
? Number.POSITIVE_INFINITY
+73
View File
@@ -329,6 +329,11 @@ describe('createPtySubprocess', () => {
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(21)
})
it('uses a new daemon protocol for daemon-local Codex env ownership', () => {
expect(PROTOCOL_VERSION).toBeGreaterThan(22)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(22)
})
it('resolves a missing Unix default before spawning node-pty', () => {
const proc = mockPtyProcess()
spawnMock.mockReturnValue(proc)
@@ -1871,6 +1876,74 @@ describe('createPtySubprocess', () => {
expect(lastCall[2].env.CODEX_HOME).toBeUndefined()
})
it('deletes daemon-owned Codex overlay pairs when the private marker is requested', () => {
const proc = mockPtyProcess()
spawnMock.mockReturnValue(proc)
const previousCodexHome = process.env.CODEX_HOME
const previousOrcaCodexHome = process.env.ORCA_CODEX_HOME
process.env.CODEX_HOME = '/daemon/managed/codex-home'
process.env.ORCA_CODEX_HOME = '/daemon/managed/codex-home'
try {
createPtySubprocess({
sessionId: 'test',
cols: 80,
rows: 24,
env: { SHELL: '/bin/bash' },
envToDelete: ['ORCA_CODEX_HOME']
})
} finally {
if (previousCodexHome === undefined) {
delete process.env.CODEX_HOME
} else {
process.env.CODEX_HOME = previousCodexHome
}
if (previousOrcaCodexHome === undefined) {
delete process.env.ORCA_CODEX_HOME
} else {
process.env.ORCA_CODEX_HOME = previousOrcaCodexHome
}
}
const env = spawnMock.mock.calls.at(-1)![2].env
expect(env.CODEX_HOME).toBeUndefined()
expect(env.ORCA_CODEX_HOME).toBeUndefined()
})
it('preserves a daemon-owned custom Codex home while deleting a stale private marker', () => {
const proc = mockPtyProcess()
spawnMock.mockReturnValue(proc)
const previousCodexHome = process.env.CODEX_HOME
const previousOrcaCodexHome = process.env.ORCA_CODEX_HOME
process.env.CODEX_HOME = '/daemon/user/codex-home'
process.env.ORCA_CODEX_HOME = '/daemon/stale/managed-home'
try {
createPtySubprocess({
sessionId: 'test',
cols: 80,
rows: 24,
env: { SHELL: '/bin/bash' },
envToDelete: ['ORCA_CODEX_HOME']
})
} finally {
if (previousCodexHome === undefined) {
delete process.env.CODEX_HOME
} else {
process.env.CODEX_HOME = previousCodexHome
}
if (previousOrcaCodexHome === undefined) {
delete process.env.ORCA_CODEX_HOME
} else {
process.env.ORCA_CODEX_HOME = previousOrcaCodexHome
}
}
const env = spawnMock.mock.calls.at(-1)![2].env
expect(env.CODEX_HOME).toBe('/daemon/user/codex-home')
expect(env.ORCA_CODEX_HOME).toBeUndefined()
})
it('honors explicit terminal env overrides after deleting requested defaults', () => {
const proc = mockPtyProcess()
spawnMock.mockReturnValue(proc)
+21 -6
View File
@@ -122,6 +122,25 @@ export type PtySubprocessOptions = {
terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe'
}
function deleteRequestedDaemonEnvKeys(
env: Record<string, string>,
keys: readonly string[] | undefined
): void {
// Why: the persistent daemon's inherited env can differ from Electron's.
// Compare ownership here so real-home routing neither leaks an Orca overlay
// nor deletes a user-owned CODEX_HOME chosen by the daemon's host context.
const deleteOrcaOwnedCodexHome =
keys?.includes('ORCA_CODEX_HOME') === true &&
env.ORCA_CODEX_HOME !== undefined &&
env.CODEX_HOME === env.ORCA_CODEX_HOME
for (const key of keys ?? []) {
delete env[key]
}
if (deleteOrcaOwnedCodexHome) {
delete env.CODEX_HOME
}
}
/**
* Returns a stable default working directory for daemon-spawned PTYs.
*/
@@ -584,9 +603,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
FORCE_HYPERLINK: '1'
} as Record<string, string>
composeGuardedDaemonGitConfigEnv(env, opts.env, opts.launchAgent)
for (const key of opts.envToDelete ?? []) {
delete env[key]
}
deleteRequestedDaemonEnvKeys(env, opts.envToDelete)
if (opts.env?.TERM) {
env.TERM = opts.env.TERM
}
@@ -760,9 +777,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
} else {
// Why: relay-side launch modes can ask for host defaults to stay scrubbed
// even after environment normalization above.
for (const key of opts.envToDelete ?? []) {
delete env[key]
}
deleteRequestedDaemonEnvKeys(env, opts.envToDelete)
if (opts.env?.TERM) {
env.TERM = opts.env.TERM
}
+2 -2
View File
@@ -16,10 +16,10 @@ import type { TuiAgent } from '../../shared/types'
// when daemon-baked behavior cannot be delivered by on-disk wrapper refresh.
// Why: bump when adding daemon wire behavior so same-version old daemons do
// not silently accept the handshake and then reject new RPCs.
export const PROTOCOL_VERSION = 22
export const PROTOCOL_VERSION = 23
export const GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION = 22
export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22
] as const
// ─── Session State Machine ──────────────────────────────────────────
+14 -10
View File
@@ -737,8 +737,14 @@ function startTerminalRuntimeStartupServices(): Promise<void> {
return firstWindowStartupServicesReady
}
function prepareCodexRuntimeHomeForLaunch(target?: CodexAccountSelectionTarget): string | null {
if (target?.runtime !== 'wsl' && codexRuntimeHome!.isHostSystemDefaultRealHomeSelected()) {
function prepareCodexRuntimeHomeForLaunch(
target?: CodexAccountSelectionTarget,
launchEnv?: NodeJS.ProcessEnv
): string | null {
if (
target?.runtime !== 'wsl' &&
codexRuntimeHome!.isHostSystemDefaultRealHomeSelected(launchEnv)
) {
// Why (flag ON, system default): the hook entry must exist — appended last
// and trusted by codex's own app-server grant — in the real ~/.codex before
// the pane spawns. An incapable grant flips the lane gate so the launch
@@ -748,8 +754,8 @@ function prepareCodexRuntimeHomeForLaunch(target?: CodexAccountSelectionTarget):
userDataPath: app.getPath('userData')
})
}
const runtimeHomePath = codexRuntimeHome!.prepareForCodexLaunch(target)
if (runtimeHomePath === null && codexRuntimeHome!.isHostSystemDefaultRealHome()) {
const runtimeHomePath = codexRuntimeHome!.prepareForCodexLaunch(target, launchEnv)
if (runtimeHomePath === null && target?.runtime !== 'wsl') {
// Why: Codex runs on the user's real ~/.codex; the managed-home hook
// install below would target a home Codex never reads on this lane.
return null
@@ -1000,7 +1006,7 @@ function openMainWindow(): BrowserWindow {
keybindings,
{
getAdditionalAiVaultCodexHomePaths: () =>
codexRuntimeHome ? [codexRuntimeHome.getHostRuntimeHomePath()] : [],
codexRuntimeHome ? codexRuntimeHome.getHostCodexHomePathsForSessionDiscovery() : [],
onBeforeRelaunch: async () => {
isQuitting = true
desktopRelayService?.fenceAndCloseNow()
@@ -1882,7 +1888,7 @@ app.whenReady().then(async () => {
// aiVault.listSessions RPC includes managed-Codex sessions on remote/SSH
// hosts; the window-only registerCoreHandlers path never runs under serve.
getAdditionalAiVaultCodexHomePaths: () =>
codexRuntimeHome ? [codexRuntimeHome.getHostRuntimeHomePath()] : [],
codexRuntimeHome ? codexRuntimeHome.getHostCodexHomePathsForSessionDiscovery() : [],
buildAgentHookPtyEnv: () =>
isAgentStatusHooksEnabled(store?.getSettings()) ? agentHookServer.buildPtyEnv() : {}
})
@@ -2020,15 +2026,13 @@ app.whenReady().then(async () => {
}
}
if (codexRuntimeHome.isHostSystemDefaultRealHomeSelected()) {
// Why: host-connect seam — install (or sweep, when opted out) the trusted
// real-home status hook before the first pane can spawn, so the very first
// launch already knows whether this host's grant lane is usable.
// Why: establish the lane before background rate-limit polling starts, so
// an incapable grant host never polls a home its PTYs will not use.
ensureRealHomeCodexHookState({
hooksEnabled: isAgentStatusHooksEnabled(store.getSettings()),
userDataPath: app.getPath('userData')
})
}
app.on('child-process-gone', (_event, details) => {
recordProcessGoneCrash('child', details.type, details.reason, details.exitCode ?? null, {
name: details.name,
+29 -4
View File
@@ -749,7 +749,10 @@ describe('registerPtyHandlers', () => {
async function spawnAndGetEnv(
argsEnv?: Record<string, string>,
processEnvOverrides?: Record<string, string | undefined>,
getSelectedCodexHomePath?: () => string | null,
getSelectedCodexHomePath?: (
target?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null },
launchEnv?: NodeJS.ProcessEnv
) => string | null,
getSettings?: () => {
enableGitHubAttribution?: boolean
agentStatusHooksEnabled?: boolean
@@ -1398,6 +1401,28 @@ describe('registerPtyHandlers', () => {
expect(env.ORCA_CODEX_HOME).toBeUndefined()
})
it('lets the resolver keep a per-spawn custom CODEX_HOME on the managed lane', async () => {
const customHome = '/home/me/.config/codex'
let resolvedCodexHome: string | undefined
const resolveHome = vi.fn((_target: unknown, launchEnv?: NodeJS.ProcessEnv) => {
resolvedCodexHome = launchEnv?.CODEX_HOME
return launchEnv?.CODEX_HOME === customHome ? TEST_CODEX_HOME : null
})
const env = await spawnAndGetEnv(
{ CODEX_HOME: customHome },
{ CODEX_HOME: undefined, ORCA_CODEX_HOME: undefined },
resolveHome,
() => ({ codexSystemDefaultRealHomeEnabled: true }) as never
)
expect(resolveHome).toHaveBeenCalledTimes(1)
expect(resolveHome.mock.calls[0]?.[0]).toEqual({ runtime: 'host' })
expect(resolvedCodexHome).toBe(customHome)
expect(env.CODEX_HOME).toBe(TEST_CODEX_HOME)
expect(env.ORCA_CODEX_HOME).toBe(TEST_CODEX_HOME)
})
it('injects explicit proxy settings into local PTY env', async () => {
const env = await spawnAndGetEnv(undefined, undefined, undefined, () => ({
httpProxyUrl: 'http://proxy.example:8080',
@@ -1759,9 +1784,9 @@ describe('registerPtyHandlers', () => {
)
expect(spawnOptions.env.CODEX_HOME).toBeUndefined()
expect(spawnOptions.env.ORCA_CODEX_HOME).toBeUndefined()
expect(spawnOptions.envToDelete).toEqual(
expect.arrayContaining(['CODEX_HOME', 'ORCA_CODEX_HOME'])
)
expect(spawnOptions.envToDelete).toEqual(expect.arrayContaining(['ORCA_CODEX_HOME']))
// The daemon compares its own merged values before deleting CODEX_HOME.
expect(spawnOptions.envToDelete).not.toContain('CODEX_HOME')
})
it('preserves a daemon-inherited user CODEX_HOME for real-home routing', async () => {
+19 -19
View File
@@ -647,16 +647,14 @@ const CODEX_HOME_ENV_KEYS = ['CODEX_HOME', 'ORCA_CODEX_HOME'] as const
// the marker so a shell-ready wrapper cannot restore the managed home. A
// user-set CODEX_HOME with no Orca marker is preserved untouched (see #8606).
function stripInheritedOrcaCodexHomeOverride(baseEnv: Record<string, string>): void {
for (const key of getInheritedOrcaCodexHomeEnvKeysToDelete(baseEnv)) {
for (const key of getLocalOrcaCodexHomeEnvKeysToDelete(baseEnv)) {
delete baseEnv[key]
}
}
// Why: the daemon spawns the PTY from its own inherited environment and honors
// only spawnOptions.envToDelete, so mutating the sparse env object is not enough
// to strip an Orca-owned CODEX_HOME the daemon already carries. Return the exact
// keys to delete, preserving a user-owned CODEX_HOME.
function getInheritedOrcaCodexHomeEnvKeysToDelete(env: Record<string, string>): string[] {
// Why: in-process spawns share main's inherited environment, so equality with
// the private marker is authoritative here. Persistent daemons compare locally.
function getLocalOrcaCodexHomeEnvKeysToDelete(env: Record<string, string>): string[] {
const inheritedOrcaOverride = env.ORCA_CODEX_HOME ?? process.env.ORCA_CODEX_HOME
const inheritedCodexHome = env.CODEX_HOME ?? process.env.CODEX_HOME
const keysToDelete = ['ORCA_CODEX_HOME']
@@ -666,7 +664,10 @@ function getInheritedOrcaCodexHomeEnvKeysToDelete(env: Record<string, string>):
return keysToDelete
}
type GetSelectedCodexHomePath = (target?: CodexAccountSelectionTarget) => string | null
type GetSelectedCodexHomePath = (
target?: CodexAccountSelectionTarget,
launchEnv?: NodeJS.ProcessEnv
) => string | null
type PrepareClaudeAuth = (
target?: ClaudeAccountSelectionTarget
) => Promise<ClaudeRuntimeAuthPreparation>
@@ -1604,7 +1605,7 @@ export function registerPtyHandlers(
: { runtime: 'host' }
const selectedCodexHomePath = getCompatibleSelectedCodexHomePath(
codexSelectionTarget,
getSelectedCodexHomePath?.(codexSelectionTarget) ?? null
getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv) ?? null
)
const skipCodexHomeEnv = ctx?.isWsl === true && !selectedCodexHomePath
const env = buildPtyHostEnv(id, baseEnv, {
@@ -3086,7 +3087,7 @@ export function registerPtyHandlers(
const selectedCodexHomePath = isDaemonHostSpawn
? getCompatibleSelectedCodexHomePath(
codexSelectionTarget,
getSelectedCodexHomePath?.(codexSelectionTarget) ?? null
getSelectedCodexHomePath?.(codexSelectionTarget, env) ?? null
)
: null
const skipCodexHomeEnv =
@@ -3144,12 +3145,11 @@ export function registerPtyHandlers(
CODEX_HOME_ENV_KEYS
)
} else if (stripInheritedOrcaCodexHome) {
// Why: the daemon inherits its own CODEX_HOME; strip the Orca-owned
// override there too, preserving a user-set CODEX_HOME.
spawnOptions.envToDelete = mergePtyEnvDeletions(
spawnOptions.envToDelete,
getInheritedOrcaCodexHomeEnvKeysToDelete(env ?? {})
)
// Why: the daemon owns a persistent inherited environment that may
// differ from main. ORCA_CODEX_HOME asks it to compare/delete the pair.
spawnOptions.envToDelete = mergePtyEnvDeletions(spawnOptions.envToDelete, [
'ORCA_CODEX_HOME'
])
}
deleteRequestedEnvKeys(env, spawnOptions.envToDelete)
promoteAgentTeamsShimPath(env, requestedAgentTeamsPath)
@@ -3969,7 +3969,7 @@ export function registerPtyHandlers(
const selectedCodexHomePath = isDaemonHostSpawn
? getCompatibleSelectedCodexHomePath(
codexSelectionTarget,
getSelectedCodexHomePath?.(codexSelectionTarget) ?? null
getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv) ?? null
)
: null
const skipCodexHomeEnv =
@@ -4055,9 +4055,9 @@ export function registerPtyHandlers(
),
skipCodexHomeEnv ? CODEX_HOME_ENV_KEYS : []
),
// Why: real-home routing strips the Orca-owned override the daemon
// inherits, while preserving a user-set CODEX_HOME.
stripInheritedOrcaCodexHome ? getInheritedOrcaCodexHomeEnvKeysToDelete(spawnEnv ?? {}) : []
// Why: the persistent daemon compares its own merged CODEX_HOME pair;
// main cannot safely decide ownership for a process it may not parent.
stripInheritedOrcaCodexHome ? ['ORCA_CODEX_HOME'] : []
)
deleteRequestedEnvKeys(spawnEnv, combinedEnvToDelete)
promoteAgentTeamsShimPath(spawnEnv, requestedAgentTeamsPath)
@@ -68,7 +68,10 @@ export function attachMainWindowServices(
mainWindow: BrowserWindow,
store: Store,
runtime: OrcaRuntimeService,
getSelectedCodexHomePath?: (target?: CodexAccountSelectionTarget) => string | null,
getSelectedCodexHomePath?: (
target?: CodexAccountSelectionTarget,
launchEnv?: NodeJS.ProcessEnv
) => string | null,
prepareClaudeAuth?: (
target?: ClaudeAccountSelectionTarget
) => Promise<ClaudeRuntimeAuthPreparation>,