Files
orca/src/shared/node-cli-command-resolution.ts
T
Neil 4218d5068e fix(cli): seed nvm's default version, not the newest install (#16420)
* fix(cli): seed nvm's default version, not the newest install

#16314 stopped the login-shell probe inheriting the seeded PATH, but left the
seed itself picking the newest installed nvm version. That ordering decides
which node a CLI runs under whenever the probe does not land — a timeout, or a
login shell whose rc never initializes nvm — and newest is precisely the wrong
guess: it is usually the version the user just added and has installed nothing
into. That is the root cause reported in #10932.

Resolve `alias/default` instead, mirroring nvm: follow the alias chain
(`default` -> `lts/*` -> `lts/krypton` -> a version), resolve a partial version
like `24` to the highest matching install, and treat `system`/`node`/`stable`
as no preference. The chain is bounded and cycle-guarded because nvm's own
resolver tracks seen aliases and hand-edited files can point at each other.

Ordering is a preference, not a restriction: the remaining versions stay behind
the default, so a CLI installed outside it is still reachable.

Measured on a real machine with nvm default=24 and a bare v26.7.0 installed:
the old resolver seeds v26.7.0/bin (no CLIs), the new one seeds v24.18.0/bin
(every CLI). Tests were written first and verified to fail on the three bug
cases against main before the fix existed.

Also raise the probe budget from 5s to 10s. The old value was never measured
against a real profile: a bash -ilc loading nvm, rvm, conda and gcloud takes
~1s idle but 6-7s on a loaded machine, so a cold start under load silently
fell back to the seed. Startup does not block on the probe, and the one
awaited consumer is agent detection, which is better served by a probe that
finishes late than one that gives up early.

* fix(cli): reject non-version alias tokens instead of matching v0.x

Review finding, and a real bug I introduced. parseVersionSegment coerces
every unparseable segment to 0, so an unresolvable default alias — `garbage`,
`iojs`, `lts/nonexistent`, any hand-named alias — became [0] and prefix-matched
a `v0.12.x` install, or any stray non-version directory. Orca would then seed a
decade-old node as the preferred runtime. Real nvm answers N/A for all of them.

The `wanted.length === 0` bail could never have caught this: ''.split('.') is
[''], never empty. Replaced with a shape check that still admits legitimate
numeric prefixes — verified against nvm itself, which resolves `24` to
v24.18.0 and `0` to an installed v0.x while answering N/A for the rest.

Also corrects two comments that no longer described the code: the seed is no
longer "newest install", and the probe budget note claimed startup never blocks
on hydration, which is false on packaged Windows where it gates terminal
services and git. The traversal-guard comment claimed a containment join()
already normalizes away; the real guarantee is that matchNvmVersion can only
return an entry of the versions directory.

* fix(cli): match nvm's version-token grammar, not just its first character

Round-2 review finding, and the same bug one layer down. The previous guard
anchored only the first character, but parseInt stops at the first non-digit,
so `0x18`, `00` and `0abc` still parsed to [0] and prefix-matched a v0.12.x
install — the decade-old-node seed the earlier fix was supposed to close.

Reachable: `nvm alias default 0x18` warns that the version does not exist and
writes the alias anyway, then resolves it to N/A.

Use nvm's actual grammar, leading zeros included — nvm calls `00` and `024`
N/A while parseInt reads them as 0 and 24. Verified by executing 17 tokens
against a five-version fixture: every one now agrees with nvm, including the
legitimate prefixes `0`, `0.12`, `24` and `v24.18.0`.

Also drops a dead disjunct (the hop bound already caps the loop, so seen.size
can never exceed it) and corrects the log comment in index.ts, which still
told the reader a failed probe leaves the newest install in front. It leaves
the default version in front now, which is usually survivable but still not
what the shell would have resolved.

* test(cli): skip the lts/* chain fixture on Windows

Round-3 review finding. makeNvmHome materializes each alias as a real file,
and the chain case uses nvm's actual `lts/*` alias — `*` is a reserved Win32
filename character, so writeFileSync fails with EINVAL. PR CI runs a Windows
allowlist that excludes this file, so the breakage only reaches a Windows
developer running the suite locally.

Skipped rather than renamed: `lts/*` is the alias nvm really ships, and the
assertion pins platform: 'darwin' anyway, so the real name costs no coverage.
Matches the skipIf convention already used across src/shared.

Also reflows a comment line that a previous edit ran to 143 characters;
oxfmt does not reflow comments, so nothing would have caught it.
2026-08-25 03:05:24 -07:00

380 lines
13 KiB
TypeScript

import { accessSync, constants, existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
import { homedir } from 'node:os'
import { delimiter, dirname, isAbsolute, join } from 'node:path'
type ResolveCommandOptions = {
pathEnv?: string | null
platform?: NodeJS.Platform
homePath?: string
}
function getExecutableNames(platform: NodeJS.Platform, commandName: string): string[] {
if (platform === 'win32') {
return [`${commandName}.cmd`, `${commandName}.exe`, `${commandName}.bat`, commandName]
}
return [commandName]
}
function splitPath(
pathEnv: string | null | undefined,
pathDelimiter: string = delimiter
): string[] {
if (!pathEnv) {
return []
}
return pathEnv
.split(pathDelimiter)
.map((entry) => entry.trim())
.filter(Boolean)
}
function parseVersionSegment(raw: string): number[] {
return raw
.replace(/^v/i, '')
.split('.')
.map((segment) => Number.parseInt(segment, 10))
.map((segment) => (Number.isFinite(segment) ? segment : 0))
}
function compareVersionDesc(left: string, right: string): number {
const leftParts = parseVersionSegment(left)
const rightParts = parseVersionSegment(right)
const length = Math.max(leftParts.length, rightParts.length)
for (let index = 0; index < length; index += 1) {
const delta = (rightParts[index] ?? 0) - (leftParts[index] ?? 0)
if (delta !== 0) {
return delta
}
}
return right.localeCompare(left)
}
function findFirstExecutable(
platform: NodeJS.Platform,
directories: string[],
executableNames: string[]
): string | null {
for (const directory of directories) {
for (const executableName of executableNames) {
const candidate = join(directory, executableName)
if (isRunnableCommand(platform, candidate)) {
return candidate
}
}
}
return null
}
function isRunnableCommand(platform: NodeJS.Platform, candidate: string): boolean {
try {
const stats = statSync(candidate)
if (!stats.isFile()) {
return false
}
if (platform === 'win32') {
return true
}
// Why: GUI fallback probing should skip placeholders/directories so spawn
// can continue to a runnable CLI instead of failing later with EACCES/EISDIR.
accessSync(candidate, constants.X_OK)
return true
} catch {
return false
}
}
function getBaseVersionManagerDirectories(platform: NodeJS.Platform, homePath: string): string[] {
const directories = [
join(homePath, '.volta', 'bin'),
join(homePath, '.asdf', 'shims'),
join(homePath, '.fnm', 'aliases', 'default', 'bin'),
// Why: mise (formerly rtx) exposes managed tool binaries via a shims
// directory, similar to asdf.
join(homePath, '.local', 'share', 'mise', 'shims')
]
if (platform === 'win32') {
// Why: Anthropic's native Windows installer places claude.exe here, and
// GUI-launched Orca may not inherit the user's PATH entry for it.
directories.push(join(homePath, '.local', 'bin'))
directories.push(join(homePath, 'AppData', 'Roaming', 'npm'))
directories.push(join(homePath, 'AppData', 'Local', 'pnpm'))
directories.push(join(homePath, 'AppData', 'Local', 'Yarn', 'bin'))
} else {
directories.push(join(homePath, '.local', 'bin'))
// Why: pnpm uses platform-specific global bin directories that differ from
// npm's ~/.local/bin.
if (platform === 'darwin') {
directories.push(join(homePath, 'Library', 'pnpm'))
} else {
directories.push(join(homePath, '.local', 'share', 'pnpm'))
}
directories.push(join(homePath, '.yarn', 'bin'))
}
directories.push(join(homePath, '.bun', 'bin'))
return directories
}
// Why bounded and cycle-guarded: an nvm alias may point at another alias
// (`default` -> `lts/*` -> `lts/krypton` -> a version), and a hand-edited pair
// can point at each other. nvm's own resolver tracks seen aliases; mirror that
// rather than trusting the files to be acyclic. Termination comes from the hop
// bound; the seen-set is what turns a cycle into "no preference" instead of
// silently resolving whichever alias the walk happened to stop on.
const NVM_ALIAS_CHAIN_LIMIT = 10
/** Resolves `alias/default` to an installed version directory name, or null. */
function resolveNvmDefaultVersion(nvmVersionsDir: string, installed: string[]): string | null {
const aliasDir = join(nvmVersionsDir, '..', '..', 'alias')
let token = readNvmAlias(join(aliasDir, 'default'))
const seen = new Set<string>()
for (let hop = 0; token && hop < NVM_ALIAS_CHAIN_LIMIT; hop += 1) {
if (seen.has(token)) {
return null
}
seen.add(token)
const next = readNvmAlias(join(aliasDir, token))
if (!next) {
break
}
token = next
}
if (!token) {
return null
}
// Why: `system` selects the OS node, so nvm owns nothing to prefer here.
// `node`/`stable` mean newest, which is the ordering we already produce.
if (token === 'system' || token === 'node' || token === 'stable') {
return null
}
return matchNvmVersion(token, installed)
}
function readNvmAlias(aliasPath: string): string | null {
// Why this is a cheap check and not the actual containment: join() normalizes
// `..` away before we ever see it, so this only rejects the literal spelling.
// The real guarantee is downstream — matchNvmVersion can only ever return an
// entry of readdirSync(versions/node), so no token can put a foreign path on
// PATH regardless of what the alias file says.
if (aliasPath.includes('..')) {
return null
}
try {
if (!statSync(aliasPath).isFile()) {
return null
}
const value = readFileSync(aliasPath, 'utf8').trim()
return value.length > 0 ? value : null
} catch {
return null
}
}
/** `24` matches the highest installed `v24.x.y`; `v24.18.0` matches exactly. */
function matchNvmVersion(token: string, installed: string[]): string | null {
// Why a full shape check: parseVersionSegment coerces every unparseable
// segment to 0 (parseInt stops at the first non-digit), so an unresolvable
// token prefix-matched `v0.12.x` — or any stray non-version directory —
// instead of matching nothing. Anchoring only the first character was not
// enough: `0x18`, `00` and `0abc` all still parsed to [0]. nvm writes such a
// token to the alias file even while warning it does not exist, then answers
// N/A for it, and so must we — which leaves newest-first ordering untouched.
// Leading zeros are rejected for the same reason: nvm calls `00` and `024`
// N/A, while parseInt happily reads them as 0 and 24.
// (A length check cannot catch any of this: ''.split('.') is [''].)
if (!/^v?(0|[1-9]\d*)(\.(0|[1-9]\d*))*$/.test(token)) {
return null
}
const wanted = parseVersionSegment(token)
const matches = installed.filter((entry) => {
const parts = parseVersionSegment(entry)
return wanted.every((segment, index) => parts[index] === segment)
})
return matches.sort(compareVersionDesc)[0] ?? null
}
function getNvmVersionDirectories(homePath: string): string[] {
const nvmVersionsDir = join(homePath, '.nvm', 'versions', 'node')
if (!existsSync(nvmVersionsDir)) {
return []
}
let installed: string[]
try {
installed = readdirSync(nvmVersionsDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort(compareVersionDesc)
} catch {
return []
}
// Why default-first rather than newest-first: this ordering decides which node
// a CLI runs under whenever the login-shell probe does not land. Newest is
// usually the version the user just installed and has put nothing into, so it
// hid every globally installed CLI and mismatched native module ABIs
// (stablyai/orca#10932). The rest stay behind it as fallbacks, so a CLI
// installed outside the default version is still reachable.
const preferred = resolveNvmDefaultVersion(nvmVersionsDir, installed)
const ordered = preferred
? [preferred, ...installed.filter((entry) => entry !== preferred)]
: installed
return ordered.map((entry) => join(nvmVersionsDir, entry, 'bin'))
}
function getVersionManagerDirectories(
platform: NodeJS.Platform,
homePath: string,
executableNames: string[]
): string[] {
const directories = getBaseVersionManagerDirectories(platform, homePath)
const firstNvmMatch = findFirstExecutable(
platform,
getNvmVersionDirectories(homePath),
executableNames
)
if (firstNvmMatch) {
directories.unshift(dirname(firstNvmMatch))
}
return directories
}
export function resolveCliCommand(
commandName: string,
options: ResolveCommandOptions = {}
): string {
const platform = options.platform ?? process.platform
const executableNames = getExecutableNames(platform, commandName)
const pathEnv = options.pathEnv ?? process.env.PATH ?? process.env.Path ?? null
const pathCandidate = findFirstExecutable(platform, splitPath(pathEnv), executableNames)
if (pathCandidate) {
return pathCandidate
}
const homePath = options.homePath ?? homedir()
const nvmCandidate = findFirstExecutable(
platform,
getNvmVersionDirectories(homePath),
executableNames
)
const versionManagerCandidate =
nvmCandidate ??
findFirstExecutable(
platform,
getBaseVersionManagerDirectories(platform, homePath),
executableNames
)
return versionManagerCandidate ?? commandName
}
export function resolveCliCommands(
commandNames: readonly string[],
options: ResolveCommandOptions = {}
): Map<string, string> {
const platform = options.platform ?? process.platform
const pathEnv = options.pathEnv ?? process.env.PATH ?? process.env.Path ?? null
const pathDirectories = splitPath(pathEnv)
const homePath = options.homePath ?? homedir()
const installDirectories = [
...getNvmVersionDirectories(homePath),
...getBaseVersionManagerDirectories(platform, homePath)
]
const resolved = new Map<string, string>()
for (const commandName of new Set(commandNames)) {
const executableNames = getExecutableNames(platform, commandName)
const pathCandidate = findFirstExecutable(platform, pathDirectories, executableNames)
const installCandidate =
pathCandidate ?? findFirstExecutable(platform, installDirectories, executableNames)
resolved.set(commandName, installCandidate ?? commandName)
}
return resolved
}
export function resolveCodexCommand(options: ResolveCommandOptions = {}): string {
return resolveCliCommand('codex', options)
}
export function resolveClaudeCommand(options: ResolveCommandOptions = {}): string {
return resolveCliCommand('claude', options)
}
// Why: Win32 resolves env names case-insensitively and object order preserves
// the block order, so the entry the child will actually read is the FIRST
// case-insensitive match — not necessarily `Path` or `PATH`. Reading a narrower
// set than the dedupe below deletes would destroy a third spelling unread.
// Mirrors resolvePathEnvKey in src/main/pty/windows-path-segment-merge.ts, which
// src/shared must not import.
function firstWindowsPathEnvKey(env: NodeJS.ProcessEnv): string {
for (const key of Object.keys(env)) {
if (key.toLowerCase() === 'path' && env[key] !== undefined) {
return key
}
}
return 'Path'
}
/**
* Put a resolved CLI's own directory ahead of PATH when that directory ships a
* sibling `node`.
*
* Why: `resolveCliCommand` falls back to scanning every version-manager install
* when PATH misses, so it can hand back `~/.nvm/versions/node/v20.x/bin/codex`
* while PATH still leads with v22. The CLI's `#!/usr/bin/env node` shebang then
* loads a v20-built native module under a v22 ABI and the agent dies on first
* require (stablyai/orca#10932). Pair the binary with the runtime it was
* installed against instead.
*
* Only prepends when the sibling `node` really exists, so a CLI resolved from a
* directory that ships no node is left alone.
*/
export function withCliRuntimeOnPath<T extends NodeJS.ProcessEnv>(
commandPath: string,
env: T,
options: Pick<ResolveCommandOptions, 'platform'> = {}
): T {
const platform = options.platform ?? process.platform
if (!isAbsolute(commandPath)) {
return env
}
const commandDirectory = dirname(commandPath)
if (!findFirstExecutable(platform, [commandDirectory], getExecutableNames(platform, 'node'))) {
return env
}
const pathKey = platform === 'win32' ? firstWindowsPathEnvKey(env) : 'PATH'
const pathDelimiter = platform === 'win32' ? ';' : delimiter
const segments = splitPath(env[pathKey], pathDelimiter)
if (segments[0] === commandDirectory) {
return env
}
const next = [commandDirectory, ...segments.filter((entry) => entry !== commandDirectory)].join(
pathDelimiter
)
const paired = { ...env, [pathKey]: next }
if (platform === 'win32') {
// Why: the spread is case-sensitive while Windows env lookup is not, so a
// differently-cased twin would keep shadowing the value we just wrote.
for (const name of Object.keys(paired)) {
if (name !== pathKey && name.toLowerCase() === pathKey.toLowerCase()) {
delete (paired as NodeJS.ProcessEnv)[name]
}
}
}
return paired as T
}
// Why: Node-script CLIs need their version-manager sibling `node` on PATH.
export function getVersionManagerBinPaths(options: ResolveCommandOptions = {}): string[] {
const platform = options.platform ?? process.platform
const homePath = options.homePath ?? homedir()
const nodeNames = getExecutableNames(platform, 'node')
return getVersionManagerDirectories(platform, homePath, nodeNames)
}