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.
This commit is contained in:
Neil
2026-08-24 15:41:41 -07:00
parent b76a47e468
commit 8703969ca9
4 changed files with 78 additions and 1 deletions
@@ -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<string, string>([['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')
+6
View File
@@ -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))
@@ -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', () => {
+21 -1
View File
@@ -32,6 +32,13 @@ export type HydrationResult =
let cached: Promise<HydrationResult> | 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<HydrationResult> {
// 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