mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 16:03:49 +00:00
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:
@@ -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')
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user