mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 16:02:56 +00:00
fix(agents): detect agent CLIs installed outside a version manager
The install-dir fallback that answers "is this agent installed?" when the login-shell PATH probe fails listed only version-manager bin dirs, so codex/opencode/cursor-agent installed by Homebrew, npm's default global prefix, snap, nix, or the CLI's own installer read as not installed. Where this is decisive, corrected from the previous message: the `orca` CLI, whose detectSkillsCliAgentKeys (src/cli/handlers/skills.ts) calls detectCommandsInInstallDirs directly and whose entry point seeds no PATH, plus unpackaged/dev runs. NOT the packaged desktop app: patchPackagedProcessPath (configure-process.ts:114) appends /opt/homebrew/bin, /usr/local/bin, ~/.opencode/bin and the Linux/nix prefixes onto process.env.PATH at main-process-preflight.ts:147, before any detection, and mergePathSegments preserves them, so the PATH scan reaches those dirs first and this fallback never fires for them. That means this does NOT explain the packaged macOS v1.4.194 report of codex/cursor-agent/opencode all undetected -- that report stays open and uninvestigated. Second correction: the fallback now carries the prefixes Homebrew actually uses on Linux (/home/linuxbrew/.linuxbrew/bin), plus /snap/bin and the two nix profile dirs, matching what patchPackagedProcessPath already seeds. The WSL guest prelude gains the same entries. Leaving them out closed the native/WSL asymmetry on darwin only, on the platforms where the fallback is decisive. Appended last so a version-manager install still wins, and kept out of getVersionManagerBinPaths, whose result is PREPENDED to PATH (#18234). Lives in its own module so node-cli-command-resolution.ts stays under max-lines. The test stages every path through `join` and asserts via detectCommandsInInstallDirs as well as resolveCliCommands, so it holds on a Windows dev machine and pins the "absolute path means installed" contract.
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
import { delimiter, join } from 'node:path'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { detectCommandsInInstallDirs } from './local-agent-install-dir-detection'
|
||||
import { getVersionManagerBinPaths, resolveCliCommands } from './node-cli-command-resolution'
|
||||
import { buildPosixFallbackPathPrelude } from './posix-version-manager-bin-dirs'
|
||||
|
||||
/**
|
||||
* The install-dir fallback answers "is this agent CLI installed?" whenever the
|
||||
* login-shell PATH probe does not land. Homebrew, npm's default global prefix
|
||||
* and opencode's own installer are absolute paths, so they cannot be staged
|
||||
* under a temp home -- hence a synthetic fs rather than a fixture tree.
|
||||
*
|
||||
* Every staged path goes through `join`, because the lookup builds candidates
|
||||
* with the host's `join`: a literal `/opt/homebrew/bin/codex` would never match
|
||||
* on a Windows dev machine.
|
||||
*/
|
||||
const fsFixture = vi.hoisted(() => ({ executables: new Set<string>() }))
|
||||
|
||||
const MOCK_HOME = '/home/tester'
|
||||
|
||||
vi.mock('node:os', () => ({ homedir: () => MOCK_HOME }))
|
||||
|
||||
vi.mock('node:fs', () => ({
|
||||
constants: { X_OK: 1 },
|
||||
statSync: (target: string) => {
|
||||
if (!fsFixture.executables.has(target)) {
|
||||
throw new Error(`ENOENT: ${target}`)
|
||||
}
|
||||
return { isFile: () => true }
|
||||
},
|
||||
accessSync: (target: string) => {
|
||||
if (!fsFixture.executables.has(target)) {
|
||||
throw new Error(`EACCES: ${target}`)
|
||||
}
|
||||
},
|
||||
// No nvm install in any of these cases; the nvm walk is covered by nvm-default-alias.test.ts.
|
||||
existsSync: () => false,
|
||||
readdirSync: () => {
|
||||
throw new Error('ENOENT')
|
||||
},
|
||||
readFileSync: () => {
|
||||
throw new Error('ENOENT')
|
||||
}
|
||||
}))
|
||||
|
||||
// The PATH a Finder/Dock-launched macOS app inherits with no login shell.
|
||||
const GUI_LAUNCH_PATH = ['/usr/bin', '/bin', '/usr/sbin', '/sbin'].join(delimiter)
|
||||
|
||||
function stage(...paths: string[]): void {
|
||||
for (const path of paths) {
|
||||
fsFixture.executables.add(path)
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAll(
|
||||
commands: string[],
|
||||
options: { platform: NodeJS.Platform; homePath: string }
|
||||
): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
resolveCliCommands(commands, { ...options, pathEnv: GUI_LAUNCH_PATH })
|
||||
) as Record<string, string>
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fsFixture.executables.clear()
|
||||
})
|
||||
|
||||
describe('agent CLI install-dir fallback', () => {
|
||||
it('finds macOS CLIs installed outside a version manager', () => {
|
||||
const home = '/Users/tester'
|
||||
stage(
|
||||
join(home, '.local', 'bin', 'claude'),
|
||||
join('/opt/homebrew/bin', 'codex'),
|
||||
join('/usr/local/bin', 'cursor-agent'),
|
||||
join(home, '.opencode', 'bin', 'opencode')
|
||||
)
|
||||
expect(
|
||||
resolveAll(['claude', 'codex', 'cursor-agent', 'opencode'], {
|
||||
platform: 'darwin',
|
||||
homePath: home
|
||||
})
|
||||
).toEqual({
|
||||
claude: join(home, '.local', 'bin', 'claude'),
|
||||
codex: join('/opt/homebrew/bin', 'codex'),
|
||||
'cursor-agent': join('/usr/local/bin', 'cursor-agent'),
|
||||
opencode: join(home, '.opencode', 'bin', 'opencode')
|
||||
})
|
||||
})
|
||||
|
||||
it('finds Linux CLIs in Linuxbrew, snap and nix prefixes, not the macOS brew prefix', () => {
|
||||
const home = '/home/tester'
|
||||
stage(
|
||||
join('/home/linuxbrew/.linuxbrew/bin', 'codex'),
|
||||
join('/snap/bin', 'cursor-agent'),
|
||||
join(home, '.nix-profile', 'bin', 'opencode'),
|
||||
join('/opt/homebrew/bin', 'claude')
|
||||
)
|
||||
expect(
|
||||
resolveAll(['codex', 'cursor-agent', 'opencode', 'claude'], {
|
||||
platform: 'linux',
|
||||
homePath: home
|
||||
})
|
||||
).toEqual({
|
||||
codex: join('/home/linuxbrew/.linuxbrew/bin', 'codex'),
|
||||
'cursor-agent': join('/snap/bin', 'cursor-agent'),
|
||||
opencode: join(home, '.nix-profile', 'bin', 'opencode'),
|
||||
// Why unresolved: /opt/homebrew is an Apple Silicon prefix; Linuxbrew uses another.
|
||||
claude: 'claude'
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves the win32 branch on its own install dirs', () => {
|
||||
const home = 'C:/Users/tester'
|
||||
stage(join(home, 'AppData', 'Roaming', 'npm', 'codex.cmd'), join('/usr/local/bin', 'claude'))
|
||||
expect(resolveAll(['codex', 'claude'], { platform: 'win32', homePath: home })).toEqual({
|
||||
codex: join(home, 'AppData', 'Roaming', 'npm', 'codex.cmd'),
|
||||
claude: 'claude'
|
||||
})
|
||||
})
|
||||
|
||||
it('still lets a version-manager install outrank a system one', () => {
|
||||
const home = '/Users/tester'
|
||||
stage(
|
||||
join(home, '.volta', 'bin', 'codex'),
|
||||
join('/opt/homebrew/bin', 'codex'),
|
||||
join('/usr/local/bin', 'codex')
|
||||
)
|
||||
expect(resolveAll(['codex'], { platform: 'darwin', homePath: home })).toEqual({
|
||||
codex: join(home, '.volta', 'bin', 'codex')
|
||||
})
|
||||
})
|
||||
|
||||
// Why this guard: getVersionManagerBinPaths is PREPENDED onto PATH by
|
||||
// patchPackagedProcessPath and the CLI's addAgentNodePaths, so a system dir
|
||||
// leaking into it would re-rank binaries the user already has (#18234).
|
||||
it('keeps system install dirs out of the PATH seed list', () => {
|
||||
const seeded = getVersionManagerBinPaths({ platform: 'darwin', homePath: '/Users/tester' })
|
||||
expect(seeded).not.toContain('/opt/homebrew/bin')
|
||||
expect(seeded).not.toContain('/usr/local/bin')
|
||||
})
|
||||
|
||||
// Why through this entry point: it is what the `orca` CLI's agent detection
|
||||
// calls, and the "absolute path means installed" contract lives here.
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'reports a system-installed CLI as detected, not just resolved',
|
||||
() => {
|
||||
stage(join('/usr/local/bin', 'codex'), join(MOCK_HOME, '.opencode', 'bin', 'opencode'))
|
||||
expect(detectCommandsInInstallDirs(['codex', 'opencode', 'cursor-agent'])).toEqual(
|
||||
new Set(['codex', 'opencode'])
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('carries the system install dirs into the POSIX guest fallback prelude', () => {
|
||||
const prelude = buildPosixFallbackPathPrelude()
|
||||
for (const dir of [
|
||||
'"$HOME/.opencode/bin"',
|
||||
'"$HOME/.nix-profile/bin"',
|
||||
'"/home/linuxbrew/.linuxbrew/bin"',
|
||||
'"/snap/bin"',
|
||||
'"/nix/var/nix/profiles/default/bin"'
|
||||
]) {
|
||||
expect(prelude).toContain(dir)
|
||||
}
|
||||
// Why absent: a WSL guest is Linux, so /opt/homebrew is never its brew prefix.
|
||||
expect(prelude).not.toContain('/opt/homebrew')
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import { accessSync, constants, existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import { delimiter, dirname, isAbsolute, join } from 'node:path'
|
||||
import { getSystemCliInstallDirectories } from './system-cli-install-dirs'
|
||||
|
||||
type ResolveCommandOptions = {
|
||||
pathEnv?: string | null
|
||||
@@ -267,7 +268,10 @@ export function resolveCliCommand(
|
||||
nvmCandidate ??
|
||||
findFirstExecutable(
|
||||
platform,
|
||||
getBaseVersionManagerDirectories(platform, homePath),
|
||||
[
|
||||
...getBaseVersionManagerDirectories(platform, homePath),
|
||||
...getSystemCliInstallDirectories(platform, homePath)
|
||||
],
|
||||
executableNames
|
||||
)
|
||||
return versionManagerCandidate ?? commandName
|
||||
@@ -283,7 +287,8 @@ export function resolveCliCommands(
|
||||
const homePath = options.homePath ?? homedir()
|
||||
const installDirectories = [
|
||||
...getNvmVersionDirectories(homePath),
|
||||
...getBaseVersionManagerDirectories(platform, homePath)
|
||||
...getBaseVersionManagerDirectories(platform, homePath),
|
||||
...getSystemCliInstallDirectories(platform, homePath)
|
||||
]
|
||||
const resolved = new Map<string, string>()
|
||||
|
||||
|
||||
@@ -7,9 +7,12 @@
|
||||
* establish the login PATH reports an nvm-installed claude/codex as not
|
||||
* installed, which is #9725.
|
||||
*
|
||||
* Kept in step with `getBaseVersionManagerDirectories` in
|
||||
* node-cli-command-resolution.ts: a WSL user on asdf, mise, volta or fnm would
|
||||
* otherwise still hit #9725 while the same user on native does not.
|
||||
* Kept in step with `getBaseVersionManagerDirectories` and
|
||||
* `getSystemCliInstallDirectories` in node-cli-command-resolution.ts: a WSL user
|
||||
* on asdf, mise, volta or fnm -- or on Linuxbrew, snap or nix -- would otherwise
|
||||
* still hit #9725 while the same user on native does not. `/opt/homebrew` stays
|
||||
* out because a WSL guest is Linux, where Homebrew installs to the Linuxbrew
|
||||
* prefix below.
|
||||
*
|
||||
* Each entry is quoted so a `$HOME` containing a space cannot word-split into
|
||||
* a relative path -- except the nvm glob, where only the prefix is quoted so
|
||||
@@ -24,7 +27,13 @@ const POSIX_VERSION_MANAGER_BIN_DIRS = [
|
||||
'"$HOME/.asdf/shims"',
|
||||
'"$HOME/.fnm/aliases/default/bin"',
|
||||
'"$HOME/.local/share/mise/shims"',
|
||||
// Why: opencode's own installer default, which no version manager owns.
|
||||
'"$HOME/.opencode/bin"',
|
||||
'"$HOME/.nix-profile/bin"',
|
||||
'"/usr/local/bin"',
|
||||
'"/home/linuxbrew/.linuxbrew/bin"',
|
||||
'"/snap/bin"',
|
||||
'"/nix/var/nix/profiles/default/bin"',
|
||||
'"$HOME"/.nvm/versions/node/*/bin'
|
||||
].join(' ')
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { join } from 'node:path'
|
||||
|
||||
/**
|
||||
* Where an agent CLI lands when no version manager installed it: Homebrew (both
|
||||
* prefixes), npm's default global prefix, snap, nix, or the CLI's own installer
|
||||
* (#829 named `~/.opencode/bin` as the motivating case, but only for the
|
||||
* login-shell probe; the fallback used when that probe fails never gained it).
|
||||
*
|
||||
* The same set `patchPackagedProcessPath` appends to PATH, minus the sbin dirs
|
||||
* and the generic `~/bin` — no agent CLI installer targets those. Kept in step
|
||||
* with it and with `POSIX_VERSION_MANAGER_BIN_DIRS`, or the answer to "is this
|
||||
* agent installed?" depends on which of the three lists ran.
|
||||
*
|
||||
* Lookup-only, deliberately outside `getBaseVersionManagerDirectories`: that
|
||||
* list is PREPENDED to PATH by `getVersionManagerBinPaths` callers, and hoisting
|
||||
* a system dir over the inherited PATH re-ranks binaries the user already has
|
||||
* (#18234).
|
||||
*/
|
||||
export function getSystemCliInstallDirectories(
|
||||
platform: NodeJS.Platform,
|
||||
homePath: string
|
||||
): string[] {
|
||||
if (platform === 'win32') {
|
||||
return []
|
||||
}
|
||||
const directories = [join(homePath, '.opencode', 'bin')]
|
||||
if (platform === 'darwin') {
|
||||
// Apple Silicon Homebrew; Intel Homebrew shares /usr/local with npm's prefix.
|
||||
directories.push('/opt/homebrew/bin')
|
||||
} else {
|
||||
// Linuxbrew uses its own prefix, not /opt/homebrew; snap is Linux-only.
|
||||
directories.push('/home/linuxbrew/.linuxbrew/bin', '/snap/bin')
|
||||
}
|
||||
directories.push(
|
||||
'/usr/local/bin',
|
||||
'/nix/var/nix/profiles/default/bin',
|
||||
join(homePath, '.nix-profile', 'bin')
|
||||
)
|
||||
return directories
|
||||
}
|
||||
Reference in New Issue
Block a user