diff --git a/src/shared/agent-cli-install-dir-fallback.test.ts b/src/shared/agent-cli-install-dir-fallback.test.ts new file mode 100644 index 00000000000..c44b5b8f379 --- /dev/null +++ b/src/shared/agent-cli-install-dir-fallback.test.ts @@ -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() })) + +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 { + return Object.fromEntries( + resolveCliCommands(commands, { ...options, pathEnv: GUI_LAUNCH_PATH }) + ) as Record +} + +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') + }) +}) diff --git a/src/shared/node-cli-command-resolution.ts b/src/shared/node-cli-command-resolution.ts index 6931cc8fc7e..0e8753f04ca 100644 --- a/src/shared/node-cli-command-resolution.ts +++ b/src/shared/node-cli-command-resolution.ts @@ -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() diff --git a/src/shared/posix-version-manager-bin-dirs.ts b/src/shared/posix-version-manager-bin-dirs.ts index 698b0f7f5b1..7062d240263 100644 --- a/src/shared/posix-version-manager-bin-dirs.ts +++ b/src/shared/posix-version-manager-bin-dirs.ts @@ -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(' ') diff --git a/src/shared/system-cli-install-dirs.ts b/src/shared/system-cli-install-dirs.ts new file mode 100644 index 00000000000..91970fa4339 --- /dev/null +++ b/src/shared/system-cli-install-dirs.ts @@ -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 +}