diff --git a/src/main/startup/configure-process.test.ts b/src/main/startup/configure-process.test.ts index 54ecdc61424..6dfa78733ec 100644 --- a/src/main/startup/configure-process.test.ts +++ b/src/main/startup/configure-process.test.ts @@ -3,6 +3,10 @@ import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' +vi.mock('./hydrate-shell-path', () => ({ + recordLaunchPath: vi.fn() +})) + vi.mock('electron', () => { const paths = new Map([['appData', '/tmp/app-data']]) return { @@ -56,6 +60,25 @@ describe('patchPackagedProcessPath', () => { } }) + it('records the pre-seed PATH so the login-shell probe cannot inherit a seeded nvm version dir', async () => { + const { app } = await import('electron') + const { patchPackagedProcessPath } = await import('./configure-process') + const { recordLaunchPath } = await import('./hydrate-shell-path') + const recorded = vi.mocked(recordLaunchPath) + + setPlatform('darwin') + Object.defineProperty(app, 'isPackaged', { configurable: true, value: true }) + process.env.HOME = '/Users/tester' + process.env.PATH = '/usr/bin:/bin' + recorded.mockClear() + + patchPackagedProcessPath() + + expect(recorded).toHaveBeenCalledWith('/usr/bin:/bin') + // Why: recording must happen before the seeds land, or the probe sees them anyway. + expect(process.env.PATH).not.toBe('/usr/bin:/bin') + }) + it('prepends agent-CLI install dirs (~/.opencode/bin, ~/.vite-plus/bin) for packaged darwin runs', async () => { const { app } = await import('electron') const { patchPackagedProcessPath } = await import('./configure-process') diff --git a/src/main/startup/configure-process.ts b/src/main/startup/configure-process.ts index e39963bc9f2..cad51caec75 100644 --- a/src/main/startup/configure-process.ts +++ b/src/main/startup/configure-process.ts @@ -4,6 +4,7 @@ import { homedir } from 'node:os' import { join, resolve } from 'node:path' import { getVersionManagerBinPaths } from '../codex-cli/command' import { getMainE2EConfig } from '../e2e-config' +import { recordLaunchPath } from './hydrate-shell-path' const DEV_PARENT_SHUTDOWN_GRACE_MS = 3000 const HTTP1_COMPATIBILITY_ENV_VAR = 'ORCA_DISABLE_HTTP2' @@ -139,6 +140,11 @@ export function patchPackagedProcessPath(): void { const pathKey = process.platform === 'win32' && process.env.Path !== undefined ? 'Path' : 'PATH' const currentPath = process.env[pathKey] ?? '' + // Why: the login-shell probe must not see the seeds below. nvm's startup + // `use` keeps whatever node is already on PATH instead of the user's + // `default` alias, so a seeded version dir would pin every terminal to the + // newest install and hide global CLIs installed under `default`. + recordLaunchPath(currentPath) const pathDelimiter = getProcessPathDelimiter() const existing = new Set(currentPath.split(pathDelimiter)) const missing = extraPaths.filter((path) => !existing.has(path)) diff --git a/src/main/startup/hydrate-shell-path.test.ts b/src/main/startup/hydrate-shell-path.test.ts index f096dd03ed3..9fc3c96581a 100644 --- a/src/main/startup/hydrate-shell-path.test.ts +++ b/src/main/startup/hydrate-shell-path.test.ts @@ -6,6 +6,7 @@ import { _resetHydrateShellPathCache, hydrateShellPath, mergePathSegments, + recordLaunchPath, type HydrationResult } from './hydrate-shell-path' @@ -177,6 +178,33 @@ describe('hydrateShellPath', () => { vi.useRealTimers() } }) + + it('probes with the recorded launch PATH so seeded fallbacks cannot pin nvm', async () => { + const proc = createMockShellProcess() + spawnMock.mockReturnValue(proc) + process.env.PATH = `/home/u/.nvm/versions/node/v26.7.0/bin${delimiter}/usr/bin` + recordLaunchPath('/usr/bin') + + const resultPromise = hydrateShellPath({ shellOverride: '/bin/bash', force: true }) + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled()) + proc.emit('close', 0) + await resultPromise + + expect(spawnMock.mock.calls[0][2].env.PATH).toBe('/usr/bin') + }) + + it('falls back to process.env when no launch PATH was recorded', async () => { + const proc = createMockShellProcess() + spawnMock.mockReturnValue(proc) + process.env.PATH = '/seeded/bin' + + const resultPromise = hydrateShellPath({ shellOverride: '/bin/bash', force: true }) + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled()) + proc.emit('close', 0) + await resultPromise + + expect(spawnMock.mock.calls[0][2].env.PATH).toBe('/seeded/bin') + }) }) describe('mergePathSegments', () => { diff --git a/src/main/startup/hydrate-shell-path.ts b/src/main/startup/hydrate-shell-path.ts index db41fe3f462..f674d3a49ef 100644 --- a/src/main/startup/hydrate-shell-path.ts +++ b/src/main/startup/hydrate-shell-path.ts @@ -32,6 +32,13 @@ export type HydrationResult = let cached: Promise | null = null let probeQueue = Promise.resolve() +// Why: patchPackagedProcessPath seeds a version manager's newest install dir +// ahead of PATH before we probe. nvm's startup `use` honors whatever node is +// already on PATH over the user's `default` alias, so a probe that inherits the +// seed comes back pinned to that version and every terminal loses the global +// CLIs installed under `default`. Probe with the launch PATH; Windows gets the +// same protection from WindowsShellPathOwnership. +let launchPath: string | null = null let configuredWindowsShell = 'powershell.exe' let configuredWindowsGitBashPath: string | null = null let configuredWindowsFallbackShell: string | null = null @@ -42,6 +49,7 @@ const windowsPathOwnership = new WindowsShellPathOwnership() export function _resetHydrateShellPathCache(): void { cached = null probeQueue = Promise.resolve() + launchPath = null configuredWindowsShell = 'powershell.exe' configuredWindowsGitBashPath = null configuredWindowsFallbackShell = null @@ -94,6 +102,18 @@ function parseCapturedPath(stdout: string, pathDelimiter: string = delimiter): s ] } +/** Records PATH as the process was launched with, before Orca seeds fallbacks onto it. */ +export function recordLaunchPath(pathValue: string): void { + launchPath = pathValue +} + +function shellProbeEnv(): NodeJS.ProcessEnv { + if (process.platform === 'win32' || launchPath === null) { + return process.env + } + return { ...process.env, PATH: launchPath } +} + function shellPathProbe(shell: string): { args: string[]; pathDelimiter: string } { if (process.platform !== 'win32') { const command = `printf '%s' '${DELIMITER}'; printf '%s' "$PATH"; printf '%s' '${DELIMITER}'` @@ -125,7 +145,7 @@ function spawnShellAndReadPath(shell: string): Promise { // it layer its own rc files on top. Do NOT forward stdio — some shells // (oh-my-zsh setups, powerlevel10k) print a lot to stderr on startup, // and we don't want that in Orca's console. - env: process.env, + env: shellProbeEnv(), stdio: ['ignore', 'pipe', 'ignore'], detached: false, windowsHide: true