diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index c3fbdea2bc1..8fb5203ac3f 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -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() diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index e46bcee0307..845ac0d7f0b 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -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 { diff --git a/src/main/codex/codex-real-home-hook-install.test.ts b/src/main/codex/codex-real-home-hook-install.test.ts index db62745f246..06795b7d482 100644 --- a/src/main/codex/codex-real-home-hook-install.test.ts +++ b/src/main/codex/codex-real-home-hook-install.test.ts @@ -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)', () => { diff --git a/src/main/codex/codex-real-home-hook-install.ts b/src/main/codex/codex-real-home-hook-install.ts index 42a24fd1838..83fc162951a 100644 --- a/src/main/codex/codex-real-home-hook-install.ts +++ b/src/main/codex/codex-real-home-hook-install.ts @@ -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 diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index 472c59a619f..0edb09b696a 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -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) diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index b0e69655a08..ce911777c0b 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -122,6 +122,25 @@ export type PtySubprocessOptions = { terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe' } +function deleteRequestedDaemonEnvKeys( + env: Record, + 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 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 } diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index e9e7fb3c7a6..6f48e1ccdbf 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -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 ────────────────────────────────────────── diff --git a/src/main/index.ts b/src/main/index.ts index 2e4317ce8ed..0a3bcab2848 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -737,8 +737,14 @@ function startTerminalRuntimeStartupServices(): Promise { 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, diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index a1c2120a104..048895045bb 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -749,7 +749,10 @@ describe('registerPtyHandlers', () => { async function spawnAndGetEnv( argsEnv?: Record, processEnvOverrides?: Record, - 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 () => { diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index c938ec53001..99cddb0aa78 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -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): 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[] { +// 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[] { 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): return keysToDelete } -type GetSelectedCodexHomePath = (target?: CodexAccountSelectionTarget) => string | null +type GetSelectedCodexHomePath = ( + target?: CodexAccountSelectionTarget, + launchEnv?: NodeJS.ProcessEnv +) => string | null type PrepareClaudeAuth = ( target?: ClaudeAccountSelectionTarget ) => Promise @@ -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) diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 0dd54c2c598..4d1891f373a 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -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,