Files
orca/src/main/startup/hydrate-shell-path.ts
T
OrcaWinandOrca Worker 6f32701985 perf: reuse Windows PATH normalization within merges (#20309)
Co-authored-by: Orca Worker <orca-worker@localhost>
2026-09-12 18:09:33 -07:00

400 lines
15 KiB
TypeScript

import { spawn } from 'node:child_process'
import { delimiter, win32 as pathWin32 } from 'node:path'
import type { ShellHydrationFailureReason } from '../../shared/shell-path-hydration-types'
import { resolveWindowsShellStartupFamily } from '../../shared/windows-terminal-shell'
import { WindowsShellPathOwnership, createWindowsPathKey } from './windows-shell-path-ownership'
// Why: GUI-launched Electron can miss PATH entries added by shell profiles.
// Tools installed into ~/.opencode/bin, ~/.cargo/bin, pyenv/volta/fnm
// shims, and countless other user-local locations end up invisible to our
// `which` probe even though they work fine from Terminal (see stablyai/orca#829).
//
// Probe the profile-loading shell once instead of hard-coding every tool's install path.
const DELIMITER = '__ORCA_SHELL_PATH__'
// Why 10s: 5s was chosen without measurement and a real profile overruns it —
// a bash -ilc loading nvm, rvm, conda and gcloud measures ~1s idle but 6-7s on a
// loaded machine, so a cold start under load silently fell back to the seeded
// PATH. Mirrors the budget mature GUI editors settled on for the same probe.
//
// The tail this lengthens is not uniform. On posix the probe is fire-and-forget
// (index.ts) and only agent detection awaits it. On packaged Windows it also
// gates terminal runtime services and every non-WSL git command, so the extra
// 5s binds there — but only for a profile that already blew the old budget, and
// those users were otherwise stuck with a wrong PATH for the whole session.
const SPAWN_TIMEOUT_MS = 10_000
// ANSI escape sequences can leak into the captured output when the user's rc
// files print banners or set colored prompts. Strip them before parsing.
const ANSI_RE = /\x1b\[[0-9;?]*[A-Za-z]/g // eslint-disable-line no-control-regex
// Why: the discriminator lets telemetry classify *why* hydration failed, not
// just whether it did. Five resolve sites in this file each tag the result
// with the right reason. The shared alias keeps the enum in lockstep with the
// telemetry schema (compile-time guard in telemetry-events.ts).
export type HydrationResult =
| { ok: true; segments: string[]; failureReason: 'none' }
| {
ok: false
segments: []
failureReason: Exclude<ShellHydrationFailureReason, 'none'>
}
let cached: Promise<HydrationResult> | null = null
let probeQueue = Promise.resolve()
// Why: patchPackagedProcessPath seeds a version-manager install dir ahead of
// PATH. nvm's startup `use` honors whatever node is already on PATH over the
// user's `default` alias, so a probe inheriting that seed comes back pinned to
// the seeded version rather than reporting what the user's shell really
// resolves. The seed now prefers the `default` alias, but still falls back to
// newest-first when default resolution yields nothing (no alias, `system`,
// uninstalled target, cycle), so the probe must stay insulated from it.
// Snapshot PATH here instead: module init runs while the import graph is
// evaluated, strictly before any statement in main's body, so it cannot observe the
// seeds — and unlike an explicit hand-off from the seeding site, there is no call
// ordering left for a later refactor to break. Windows keys it `Path`, and a Git Bash
// user whose nvm uses the POSIX ~/.nvm/versions/node layout is seeded there too, so
// capture the key alongside the value.
const LAUNCH_PATH_KEY =
process.platform === 'win32' && process.env.Path !== undefined ? 'Path' : 'PATH'
const LAUNCH_PATH = process.env[LAUNCH_PATH_KEY] ?? null
// Why: rc files that exec into a multiplexer or start a heavy prompt can outrun the
// probe budget. This lets them detect the probe and take a fast path.
const PROBE_MARKER_ENV_VAR = 'ORCA_SHELL_PATH_PROBE'
let launchPathOverride: { key: string; value: string } | null = null
let configuredWindowsShell = 'powershell.exe'
let configuredWindowsGitBashPath: string | null = null
let configuredWindowsFallbackShell: string | null = null
let windowsShellConfigurationVersion = 0
const windowsPathOwnership = new WindowsShellPathOwnership()
/** @internal - tests need a clean hydration cache between cases. */
export function _resetHydrateShellPathCache(): void {
cached = null
probeQueue = Promise.resolve()
launchPathOverride = null
configuredWindowsShell = 'powershell.exe'
configuredWindowsGitBashPath = null
configuredWindowsFallbackShell = null
windowsShellConfigurationVersion = 0
windowsPathOwnership.reset()
}
export function resolveProfileLoadingShell(): string | null {
if (process.platform === 'win32') {
const family = resolveWindowsShellStartupFamily(configuredWindowsShell)
if (family === 'cmd') {
return null
}
if (family === 'posix') {
return configuredWindowsGitBashPath
}
const basename = pathWin32.basename(configuredWindowsShell).toLowerCase()
return basename === 'powershell.exe' || basename === 'pwsh.exe' ? configuredWindowsShell : null
}
const shell = process.env.SHELL
return shell?.length ? shell : process.platform === 'darwin' ? '/bin/zsh' : '/bin/bash'
}
export const resolveProfileLoadingFallbackShell = (): string | null =>
configuredWindowsFallbackShell
function parseCapturedPath(stdout: string, pathDelimiter: string = delimiter): string[] {
const cleaned = stdout.replace(ANSI_RE, '')
const first = cleaned.indexOf(DELIMITER)
if (first === -1) {
return []
}
const second = cleaned.indexOf(DELIMITER, first + DELIMITER.length)
if (second === -1) {
return []
}
const value = cleaned.slice(first + DELIMITER.length, second).trim()
if (!value) {
return []
}
// Why: Set preserves insertion order, and PATH resolution is first-match-wins,
// so de-duping this way keeps the user's rc-file ordering intact.
return [
...new Set(
value
.split(pathDelimiter)
.map((s) => s.trim())
.filter(Boolean)
)
]
}
/** @internal - tests need a launch PATH the runner's own environment cannot supply. */
export function _setLaunchPathForTests(value: string, key = 'PATH'): void {
launchPathOverride = { key, value }
}
function isLaunchPathKey(name: string, key: string): boolean {
return process.platform === 'win32' ? name.toLowerCase() === key.toLowerCase() : name === key
}
function applyLaunchPath(env: NodeJS.ProcessEnv, key: string, value: string | null): void {
if (value === null) {
return
}
// Why: Windows resolves env names case-insensitively even though a spread is
// a plain object, so never leave both the captured key and another casing.
for (const name of Object.keys(env)) {
if (name !== key && isLaunchPathKey(name, key)) {
delete env[name]
}
}
env[key] = value
}
function shellProbeEnv(): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = { ...process.env, [PROBE_MARKER_ENV_VAR]: '1' }
const key = launchPathOverride?.key ?? LAUNCH_PATH_KEY
const value = launchPathOverride?.value ?? LAUNCH_PATH
applyLaunchPath(env, key, value)
return env
}
/** Run a synchronous launcher without passing Orca's seeded PATH to its child. */
export function runWithLaunchPath<T>(action: () => T): T {
const key = launchPathOverride?.key ?? LAUNCH_PATH_KEY
const value = launchPathOverride?.value ?? LAUNCH_PATH
if (value === null) {
return action()
}
const previous = Object.entries(process.env).filter(([name]) => isLaunchPathKey(name, key))
applyLaunchPath(process.env, key, value)
try {
return action()
} finally {
for (const name of Object.keys(process.env)) {
if (isLaunchPathKey(name, key)) {
delete process.env[name]
}
}
for (const [name, previousValue] of previous) {
process.env[name] = previousValue
}
}
}
function shellPathProbe(shell: string): { args: string[]; pathDelimiter: string } {
if (process.platform !== 'win32') {
const command = `printf '%s' '${DELIMITER}'; printf '%s' "$PATH"; printf '%s' '${DELIMITER}'`
return { args: ['-ilc', command], pathDelimiter: delimiter }
}
if (resolveWindowsShellStartupFamily(shell) === 'posix') {
// Why: native child processes cannot resolve Git Bash's /c/... PATH entries.
const command = `printf '%s' '${DELIMITER}'; cygpath -wp "$PATH"; printf '%s' '${DELIMITER}'`
return { args: ['-ilc', command], pathDelimiter: ';' }
}
const command =
`[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); ` +
`[Console]::Write('${DELIMITER}'); [Console]::Write($env:Path); ` +
`[Console]::Write('${DELIMITER}')`
// Why: omitting -NoProfile is the behavior this probe exists to capture.
return { args: ['-NoLogo', '-Command', command], pathDelimiter: ';' }
}
function spawnShellAndReadPath(shell: string): Promise<HydrationResult> {
return new Promise((resolve) => {
// Why: delimiters isolate PATH from profile banners and MOTDs.
const probe = shellPathProbe(shell)
let finished = false
let stdout = ''
let timer: ReturnType<typeof setTimeout> | null = null
const child = spawn(shell, probe.args, {
// Why: inherit current env so the shell sees the same baseline, then let
// 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: shellProbeEnv(),
stdio: ['ignore', 'pipe', 'ignore'],
detached: false,
windowsHide: true
})
const cleanup = (): void => {
if (timer) {
clearTimeout(timer)
timer = null
}
child.stdout.off('data', onStdoutData)
child.off('error', onError)
child.off('close', onClose)
}
const finish = (result: HydrationResult): void => {
if (finished) {
return
}
finished = true
cleanup()
resolve(result)
}
timer = setTimeout(() => {
// Why: slow rc files (corporate env setup, nvm eager init) can exceed
// our budget. Kill the shell and fall back to process.env rather than
// blocking the Agents pane indefinitely.
try {
child.kill('SIGKILL')
} catch {
// ignore
}
finish({ segments: [], ok: false, failureReason: 'timeout' })
}, SPAWN_TIMEOUT_MS)
const onStdoutData = (chunk: Buffer): void => {
stdout += chunk.toString('utf8')
}
const onError = (): void => {
finish({ segments: [], ok: false, failureReason: 'spawn_error' })
}
const onClose = (): void => {
const segments = parseCapturedPath(stdout, probe.pathDelimiter)
if (segments.length === 0) {
finish({ segments: [], ok: false, failureReason: 'empty_path' })
return
}
finish({ segments, ok: true, failureReason: 'none' })
}
child.stdout.on('data', onStdoutData)
child.on('error', onError)
child.on('close', onClose)
})
}
type HydrateOptions = {
force?: boolean
/** Override for tests — defaults to running `spawn` against the real shell. */
spawner?: (shell: string) => Promise<HydrationResult>
/** Override for tests — defaults to `pickShell()`. */
shellOverride?: string | null
}
/**
* Spawn the user's profile-loading shell once and return the PATH it would export.
* Caches the promise for the lifetime of the process — call
* `_resetHydrateShellPathCache()` in tests or `hydrateShellPath({ force: true })`
* when the user asks to re-probe (e.g. after installing a new CLI).
*/
export function hydrateShellPath(options: HydrateOptions = {}): Promise<HydrationResult> {
if (cached && !options.force) {
return cached
}
const platform = process.platform
const configurationVersion = windowsShellConfigurationVersion
const shell =
options.shellOverride !== undefined ? options.shellOverride : resolveProfileLoadingShell()
if (!shell) {
cached = Promise.resolve({ segments: [], ok: false, failureReason: 'no_shell' })
return cached
}
const spawner = options.spawner ?? spawnShellAndReadPath
const fallbackShell = options.shellOverride === undefined ? configuredWindowsFallbackShell : null
const probe = probeQueue.then(async () => {
if (platform === 'win32') {
windowsPathOwnership.restore(process.env)
}
const result = await spawner(shell)
if (!result.ok && result.failureReason === 'spawn_error' && fallbackShell) {
return spawner(fallbackShell)
}
return result
})
// Why: one rejected profile must not block later refreshes or shell changes.
probeQueue = probe.then(
() => undefined,
() => undefined
)
cached = probe.then((result) => {
if (
platform === 'win32' &&
options.shellOverride === undefined &&
configurationVersion !== windowsShellConfigurationVersion
) {
return hydrateShellPath()
}
return result
})
return cached
}
export function configureWindowsShellPathHydration(
shell: string | null | undefined,
gitBashPath: string | null = null,
fallbackShell: string | null = null
): void {
const next = shell?.trim() || 'powershell.exe'
if (
next === configuredWindowsShell &&
gitBashPath === configuredWindowsGitBashPath &&
fallbackShell === configuredWindowsFallbackShell
) {
return
}
windowsPathOwnership.restore(process.env)
configuredWindowsShell = next
configuredWindowsGitBashPath = gitBashPath
configuredWindowsFallbackShell = fallbackShell
windowsShellConfigurationVersion += 1
cached = null
}
function uniquePathSegments(segments: string[], pathKey: (segment: string) => string): string[] {
const seen = new Set<string>()
return segments.filter((segment) => {
const key = pathKey(segment)
if (seen.has(key)) {
return false
}
seen.add(key)
return true
})
}
/**
* Promote shell-discovered PATH segments to the front of process.env.PATH,
* preserving shell ordering and avoiding duplicates. Returns the segments that
* were newly added so callers can log/telemetry on nontrivial hydrations.
*/
export function mergePathSegments(segments: string[]): string[] {
if (segments.length === 0) {
return []
}
if (process.platform === 'win32') {
windowsPathOwnership.restore(process.env)
}
const current = process.env.PATH ?? process.env.Path ?? ''
const pathDelimiter = process.platform === 'win32' ? pathWin32.delimiter : delimiter
const currentSegments = current.split(pathDelimiter).filter(Boolean)
const pathKey =
process.platform === 'win32' ? createWindowsPathKey() : (segment: string): string => segment
const shellSegments = uniquePathSegments(segments, pathKey)
const shellSegmentSet = new Set(shellSegments.map(pathKey))
const existing = new Set(currentSegments.map(pathKey))
const added = shellSegments.filter((segment) => !existing.has(pathKey(segment)))
const merged = [
...shellSegments,
...currentSegments.filter((segment) => !shellSegmentSet.has(pathKey(segment)))
]
const next = merged.join(pathDelimiter)
if (next === current) {
return []
}
// Why: shell-provided entries must win over hardcoded packaged-app fallbacks.
// A seeded fallback can point at a stale CLI while the user's shell resolves
// a healthy one from the same directory list in a different order.
if (process.platform === 'win32') {
windowsPathOwnership.apply(process.env, next)
} else {
process.env.PATH = next
}
return added
}