mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 01:27:35 +00:00
* fix(codex): pair structured launches with the CLI runtime * test(codex): align launch snapshots with runtime pairing * fix(agent-session): keep this app's CLI ahead of the paired runtime dir Pairing ran after the Orca CLI prepend, so a runtime dir that also holds an `orca` (e.g. /usr/local/bin with node+codex+orca) shadowed this app's launcher for the child. Pair first; the CLI dir stays first on PATH. Drops a PATH-shape test the parity snapshots already cover. Co-Authored-By: Claude <noreply@anthropic.com> * fix(cli-runtime): skip compiled CLIs and keep inherited PATH when pairing a sibling node A native codex/claude beside a node (Homebrew, /usr/bin) never runs on it, so pairing only reordered PATH. An env naming no PATH now pairs over the PATH the child inherits instead of replacing it with the CLI dir alone. Co-Authored-By: Claude <noreply@anthropic.com> * test(cli-runtime): type the PATH-less env fixture Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
393 lines
14 KiB
TypeScript
393 lines
14 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'
|
|
import { firstWindowsPathEnvKey, isNativeExecutable } from './cli-runtime-pairing-inputs'
|
|
import { getSystemCliInstallDirectories } from './system-cli-install-dirs'
|
|
|
|
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
|
|
}
|
|
|
|
// Why one list for both resolvers: the system block must stay LAST so a
|
|
// version-manager install always outranks a Homebrew/npm/snap one, and two
|
|
// hand-spelled spreads is how the native and WSL lists drifted apart before.
|
|
function getCliInstallDirectories(platform: NodeJS.Platform, homePath: string): string[] {
|
|
return [
|
|
...getNvmVersionDirectories(homePath),
|
|
...getBaseVersionManagerDirectories(platform, homePath),
|
|
...getSystemCliInstallDirectories(platform, homePath)
|
|
]
|
|
}
|
|
|
|
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 installCandidate = findFirstExecutable(
|
|
platform,
|
|
getCliInstallDirectories(platform, homePath),
|
|
executableNames
|
|
)
|
|
return installCandidate ?? commandName
|
|
}
|
|
|
|
/** Resolve a literal override without falling back to an unverified command name. */
|
|
export function resolveExecutableCommand(
|
|
command: string,
|
|
options: ResolveCommandOptions = {}
|
|
): string | null {
|
|
const platform = options.platform ?? process.platform
|
|
const expanded =
|
|
command.startsWith('~/') || command.startsWith('~\\')
|
|
? join(options.homePath ?? homedir(), command.slice(2))
|
|
: command
|
|
if (isAbsolute(expanded)) {
|
|
return isRunnableCommand(platform, expanded) ? expanded : null
|
|
}
|
|
// Relative paths depend on the workspace, which a session-less catalog cannot name.
|
|
if (expanded.includes('/') || expanded.includes('\\')) {
|
|
return null
|
|
}
|
|
const resolved = resolveCliCommand(expanded, options)
|
|
return isAbsolute(resolved) && isRunnableCommand(platform, resolved) ? resolved : null
|
|
}
|
|
|
|
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 = getCliInstallDirectories(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)
|
|
}
|
|
|
|
/**
|
|
* 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 and the CLI is not a
|
|
* compiled binary, so a native CLI or one beside 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')) ||
|
|
isNativeExecutable(commandPath)
|
|
) {
|
|
return env
|
|
}
|
|
const pathKey = platform === 'win32' ? firstWindowsPathEnvKey(env) : 'PATH'
|
|
const pathDelimiter = platform === 'win32' ? ';' : delimiter
|
|
// Why: the child inherits Orca's PATH when `env` names none, so pair over that, not over nothing.
|
|
const segments = splitPath(env[pathKey] ?? process.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)
|
|
}
|