Files
orca/src/shared/agent-cli-install-dir-fallback.test.ts
T
Neil 901c6771ff fix(agents): detect agent CLIs installed outside a version manager (#18336)
* 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.

* fix(agents): align system install-dir order across the three PATH lists

Round-2 review remediation. The blocking finding was about the handoff
artifact, not the code: the summary handed to review described a 2-file/+38
change with 3 new macOS dirs, while HEAD is 4 files/+227 with 6 lookup dirs
plus 5 new WSL-guest prelude entries, and the quoted failing test names never
existed. Restated against HEAD in the handoff; no rebuttal, the reviewer was
right.

Justification for the entries the summary never described: the fallback exists
to close the native/WSL asymmetry for a CLI no version manager installed, and
patchPackagedProcessPath already seeds Linuxbrew, /snap/bin and both nix
profile dirs (configure-process.ts:141-158). Shipping only the darwin subset
would have left a Linux or WSL user with a snap/nix install still reading as
not installed while the packaged macOS user did not -- the asymmetry the change
is for.

Code changes, all from the non-blocking list:

- The three lists disagreed on order while claiming to be kept in step, so a
  CLI in both /usr/local/bin and /snap/bin could resolve to a different binary
  than the seeded PATH scan or the WSL guest probe found. All three now use the
  seed's relative order, pinned by a new duplicate-install test and an offset
  assertion on the prelude. The prelude's system block also moved after the nvm
  glob so a version manager still wins in the guest, as it does natively.
- The parity docstring asserted "the same set patchPackagedProcessPath appends,
  minus the sbin dirs and the generic ~/bin", which was false: it also omits
  ~/.vite-plus/bin (seeded by configure-process.ts:156, but no probed agent
  command maps to it) and /opt/homebrew off darwin. All three gaps are now
  named as deliberate.
- win32 returns [] and stays that way, but the branch now says why:
  %USERPROFILE%\.opencode\bin has never had install-dir coverage in either
  list, and the seed's system block is POSIX-only too. Pre-existing, unchanged.
- The detectCommandsInInstallDirs case read the ambient process.env.PATH, so on
  a box with /usr/local/bin on PATH only the opencode assertion exercised the
  fallback. It now stubs the GUI-launch PATH, so both do.

Unchanged and restated: this does NOT explain the packaged macOS v1.4.194
report of codex/cursor-agent/opencode all undetected, and must not close G6
report #3. patchPackagedProcessPath returns early unless app.isPackaged and
seeds all six dirs before any detection, and mergePathSegments never deletes
them, so the packaged PATH scan reaches them first and this fallback never
fires there. Decisive only for the `orca` CLI's detectSkillsCliAgentKeys and
unpackaged/dev runs.

Verification: the suite is red without the production hunks (5 failed/3 passed)
and green with them (8 passed); 11 related suites pass (197 tests); tc:node,
tc:cli, oxlint, oxfmt and the max-lines ratchet are clean.

* fix(agents): correct the ordering-parity claims and widen the seed-leak guard

Round-3 review found two docstring claims that are false as written and one
guard that only asserted a third of its list.

- system-cli-install-dirs.ts claimed a CLI in two of these dirs resolves the
  same here as in the packaged PATH seed. True inside the system block, false
  across it: `claude` in both ~/.local/bin and /opt/homebrew/bin resolves to
  ~/.local/bin via the fallback (getBaseVersionManagerDirectories leads) and to
  /opt/homebrew/bin via the seed, which appends ~/.local/bin last. Scope the
  claim to the block and name the gap instead of asserting it away. No behavior
  change: closing it would hoist a system dir over a version-manager one
  (#18234).
- posix-version-manager-bin-dirs.ts justified moving "/usr/local/bin" after the
  nvm glob with "a version manager still wins in the guest, as it does
  natively". The glob expands lexicographically; native orders nvm dirs
  default-alias-first (#10932), so that is not parity. Record the move as the
  one behavior change in the file and bound it: entries are appended behind a
  resolved login PATH, and both consumers only test presence.
- The #18234 seed-leak guard asserted only /opt/homebrew/bin and /usr/local/bin
  were absent from getVersionManagerBinPaths, leaving the four other new dirs
  unpinned. It now spells out all seven across darwin and linux -- spelled out
  rather than derived from getSystemCliInstallDirectories, which would pass
  vacuously against exactly the refactor it guards.

Tests 8 passed (8); 5 failed / 3 passed with the production hunks reverted to
origin/main. tsc node + cli clean, oxlint clean.

* fix(agents): find Pi's own installer dir in the CLI install-dir fallback

The fallback added `~/.opencode/bin` but skipped `~/.vite-plus/bin` on the
claim that no probed agent command maps to it. False: `pi` is a probed detect
command on every runtime (`tui-agent-config.ts`, no `detectUnsupportedRuntimes`)
and `~/.vite-plus/bin` is the Pi installer's default — the two dirs #829 named
and `patchPackagedProcessPath` seeds together. Added to both the native
fallback and the WSL guest prelude, so a Pi installed by its own script is
found by the `orca` CLI and in WSL, not just on a seeded packaged PATH.

Also, all narrower:
- `/snap/bin` + Linuxbrew now gate on `linux` like the seed does, instead of
  every non-darwin posix.
- Docstring: `/opt/homebrew` off darwin is the one remaining seed gap and says
  why; the "lookup-only" paragraph names the `withCliRuntimeOnPath` exception.
- New seed-order test derives the expected order from
  `getSystemCliInstallDirectories`, so reordering either list fails.

The PR body's claim that SSH hosts benefit is dropped: they answer
`preflight.detectAgents` from `src/relay/preflight-handler.ts` via
`isCommandOnPathForRelay`, a separate bundle that never reaches this module.

* fix(agents): build the CLI install-dir fallback order once and pin it on both resolvers

resolveCliCommand (every spawn site) and resolveCliCommands (detection) each
spelled the nvm -> version-manager -> system-dir order by hand, which is how
the native and WSL lists drifted apart before. One getCliInstallDirectories
now feeds both, and the test pins system dirs LAST on both resolvers, on
darwin and linux, plus a derived check that the WSL guest prelude keeps every
native version-manager dir ahead of the native system block.
2026-09-04 15:18:07 -07:00

277 lines
11 KiB
TypeScript

import { delimiter, join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { detectCommandsInInstallDirs } from './local-agent-install-dir-detection'
import {
getVersionManagerBinPaths,
resolveCliCommand,
resolveCliCommands
} from './node-cli-command-resolution'
import { buildPosixFallbackPathPrelude } from './posix-version-manager-bin-dirs'
import { getSystemCliInstallDirectories } from './system-cli-install-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()
// Why: the no-options entry point reads the ambient PATH, where a dev box's
// real /usr/local/bin would answer before the fallback ever runs.
vi.stubEnv('PATH', GUI_LAUNCH_PATH)
})
afterEach(() => {
vi.unstubAllEnvs()
})
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'
})
})
// Why pinned: patchPackagedProcessPath seeds these onto PATH in this order and
// the POSIX guest prelude appends them in it, so a divergence here would spawn
// a different binary than the packaged PATH scan for the same install.
it('ranks system install dirs in the same order as the PATH seed', () => {
const home = '/home/tester'
const dirs = [
'/usr/local/bin',
'/snap/bin',
'/home/linuxbrew/.linuxbrew/bin',
'/nix/var/nix/profiles/default/bin',
join(home, '.nix-profile', 'bin'),
join(home, '.opencode', 'bin'),
join(home, '.vite-plus', 'bin')
]
stage(...dirs.map((dir) => join(dir, 'opencode')))
for (const expected of dirs) {
expect(resolveAll(['opencode'], { platform: 'linux', homePath: home })).toEqual({
opencode: join(expected, 'opencode')
})
fsFixture.executables.delete(join(expected, 'opencode'))
}
})
// Why both resolvers and both platforms: resolveCliCommand is what every
// spawn site (codex login, app-server, session-index heal) calls, and its
// list was once spelled separately from resolveCliCommands'. A same-named
// binary in /usr/local/bin must never shadow the one a version manager owns.
describe.each([
{ platform: 'darwin' as const, home: '/Users/tester', systemDir: '/opt/homebrew/bin' },
{
platform: 'linux' as const,
home: '/home/tester',
systemDir: '/home/linuxbrew/.linuxbrew/bin'
}
])('$platform: system dirs stay last', ({ platform, home, systemDir }) => {
it('lets a version-manager install outrank a system one', () => {
const managed = join(home, '.volta', 'bin', 'codex')
stage(managed, join(systemDir, 'codex'), join('/usr/local/bin', 'codex'))
expect(resolveCliCommand('codex', { platform, homePath: home })).toBe(managed)
expect(resolveAll(['codex'], { platform, homePath: home })).toEqual({ codex: managed })
})
it('lets an npm --user (~/.local/bin) install outrank a system one', () => {
const managed = join(home, '.local', 'bin', 'codex')
stage(managed, join(systemDir, 'codex'))
expect(resolveCliCommand('codex', { platform, homePath: home })).toBe(managed)
expect(resolveAll(['codex'], { platform, homePath: home })).toEqual({ codex: managed })
})
it('lets a copy already on PATH outrank every install dir', () => {
const onPath = join('/custom/bin', 'codex')
const pathEnv = [GUI_LAUNCH_PATH, '/custom/bin'].join(delimiter)
stage(onPath, join(home, '.volta', 'bin', 'codex'), join(systemDir, 'codex'))
expect(resolveCliCommand('codex', { platform, homePath: home, pathEnv })).toBe(onPath)
expect(resolveCliCommands(['codex'], { platform, homePath: home, pathEnv })).toEqual(
new Map([['codex', onPath]])
)
})
})
// 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', () => {
for (const platform of ['darwin', 'linux'] as const) {
const home = platform === 'darwin' ? '/Users/tester' : '/home/tester'
const seeded = getVersionManagerBinPaths({ platform, homePath: home })
// Spelled out, not derived from the list under test: a guard that iterates
// getSystemCliInstallDirectories passes vacuously if that list is emptied
// into getBaseVersionManagerDirectories, which is the leak it guards.
for (const directory of [
'/opt/homebrew/bin',
'/usr/local/bin',
'/snap/bin',
'/home/linuxbrew/.linuxbrew/bin',
'/nix/var/nix/profiles/default/bin',
join(home, '.nix-profile', 'bin'),
join(home, '.opencode', 'bin'),
join(home, '.vite-plus', 'bin')
]) {
expect(seeded).not.toContain(directory)
}
}
})
// 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'),
// Why pi: it is a probed detect command on every runtime (tui-agent-config.ts,
// no detectUnsupportedRuntimes) and its installer defaults to ~/.vite-plus/bin,
// the second dir #829 named and seeded alongside ~/.opencode/bin.
join(MOCK_HOME, '.vite-plus', 'bin', 'pi')
)
// All three come from the fallback: the stubbed PATH holds no system dir.
expect(detectCommandsInInstallDirs(['codex', 'opencode', 'pi', 'cursor-agent'])).toEqual(
new Set(['codex', 'opencode', 'pi'])
)
}
)
it('carries the system install dirs into the POSIX guest fallback prelude', () => {
const prelude = buildPosixFallbackPathPrelude()
const systemDirs = [
'"/usr/local/bin"',
'"/snap/bin"',
'"/home/linuxbrew/.linuxbrew/bin"',
'"/nix/var/nix/profiles/default/bin"',
'"$HOME/.nix-profile/bin"',
'"$HOME/.opencode/bin"',
'"$HOME/.vite-plus/bin"'
]
const offsets = systemDirs.map((dir) => prelude.indexOf(dir))
expect(offsets.every((offset) => offset >= 0)).toBe(true)
expect([...offsets].sort((a, b) => a - b)).toEqual(offsets)
// Why after: the guest prelude appends, so a version manager must still win.
expect(prelude.indexOf('.nvm/versions/node/*/bin')).toBeLessThan(offsets[0])
// Why absent: a WSL guest is Linux, so /opt/homebrew is never its brew prefix.
expect(prelude).not.toContain('/opt/homebrew')
})
// Why derived: the native and guest lists drifted apart once by hand. Every
// version-manager dir the native resolver knows must precede the guest's
// first system dir, and the guest's system block must be the native one.
it('keeps the WSL guest prelude in step with the native Linux lists', () => {
const prelude = buildPosixFallbackPathPrelude()
const asGuest = (dir: string): string => `"${dir.split('\\').join('/')}"`
const systemDirs = getSystemCliInstallDirectories('linux', '$HOME').map(asGuest)
const firstSystemOffset = prelude.indexOf(systemDirs[0])
expect(firstSystemOffset).toBeGreaterThan(0)
for (const dir of getVersionManagerBinPaths({ platform: 'linux', homePath: '$HOME' })) {
const offset = prelude.indexOf(asGuest(dir))
expect(offset, dir).toBeGreaterThanOrEqual(0)
expect(offset, dir).toBeLessThan(firstSystemOffset)
}
const systemOffsets = systemDirs.map((dir) => prelude.indexOf(dir))
expect(systemOffsets.every((offset) => offset >= firstSystemOffset)).toBe(true)
expect([...systemOffsets].sort((a, b) => a - b)).toEqual(systemOffsets)
})
})