From 8703969ca94dcf5cf0cab066f4e7cd653e6330b2 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:41:41 -0700 Subject: [PATCH] fix(startup): stop the PATH seed pinning nvm to its newest install patchPackagedProcessPath prepends the newest nvm version dir to process.env.PATH, then hydrateShellPath probes the login shell with that same env. nvm's startup `use` honors whatever node is already on PATH instead of the user's `default` alias, so the probe returns a PATH pinned to the newest install and every terminal pane inherits it. A user whose newest nvm node is a bare install then loses every global CLI (codex, claude, gemini, vercel...) inside Orca while they still resolve in Ghostty/Terminal, which start from the bare GUI PATH and fall through to `default`. Probe with the PATH the process launched with. Windows already gets this through WindowsShellPathOwnership. --- src/main/startup/configure-process.test.ts | 23 +++++++++++++++++ src/main/startup/configure-process.ts | 6 +++++ src/main/startup/hydrate-shell-path.test.ts | 28 +++++++++++++++++++++ src/main/startup/hydrate-shell-path.ts | 22 +++++++++++++++- 4 files changed, 78 insertions(+), 1 deletion(-) 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