Files
orca/src/main/codex-cli/command.test.ts
T
84411b943f fix: detect agents via install-dir resolver when which misses on GUI launch (#4880)
* fix: detect agents via install-dir resolver when which misses on GUI launch

The Agents settings pane reports every agent as "Not installed" on a cold
GUI launch even when the CLIs are installed and runnable from Terminal.

detectInstalledAgents resolves each agent with `which`, which only sees
process.env.PATH. GUI-launched Electron inherits a stripped PATH from launchd
that omits the user's shell-rc dirs (~/.local/bin, ~/Library/pnpm,
~/.asdf/shims, nvm/volta/fnm/mise shims, ...). The synchronous seed in
patchPackagedProcessPath plus the async shell-PATH hydration both race the
first detection, and the first result is cached for the session, so the pane
can latch onto an empty "nothing installed" snapshot until the user relaunches
from a terminal or clicks Refresh.

Fall back to resolveCliCommand (the same resolver the spawn path uses in
src/main/codex-cli/command.ts) when the PATH lookup misses. Detection now has
the same reach as launch: if Orca can run it, Orca detects it, independent of
PATH-hydration timing. No false positives — an uninstalled agent still resolves
to a bare command name rather than an absolute path.

Verified on macOS that resolveCliCommand resolves claude (~/.local/bin), codex
(~/.asdf/shims), and opencode (~/Library/pnpm) under a stripped PATH.

* fix: bulk resolve agent install-dir detection

Co-authored-by: Orca <help@stably.ai>

* fix: ignore node navigator for linux webgl policy

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
Co-authored-by: Orca <help@stably.ai>
2026-06-08 19:23:32 -04:00

316 lines
11 KiB
TypeScript

import { chmodSync, mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { delimiter, dirname, join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
getVersionManagerBinPaths,
resolveClaudeCommand,
resolveCliCommands,
resolveCodexCommand
} from './command'
function makeExecutable(path: string): void {
mkdirSync(dirname(path), { recursive: true })
writeFileSync(path, '')
if (process.platform !== 'win32') {
chmodSync(path, 0o755)
}
}
function makeNonExecutableFile(path: string): void {
mkdirSync(dirname(path), { recursive: true })
writeFileSync(path, '')
if (process.platform !== 'win32') {
chmodSync(path, 0o644)
}
}
describe('resolveCodexCommand', () => {
afterEach(() => {
delete process.env.PATH
delete process.env.Path
})
it('prefers Codex already present on PATH', () => {
const root = mkdtempSync(join(tmpdir(), 'orca-codex-command-'))
const pathDir = join(root, 'bin')
const commandPath = join(pathDir, 'codex')
makeExecutable(commandPath)
expect(resolveCodexCommand({ platform: 'darwin', pathEnv: pathDir, homePath: root })).toBe(
commandPath
)
})
it.skipIf(process.platform === 'win32')(
'skips non-runnable PATH entries and keeps scanning',
() => {
const root = mkdtempSync(join(tmpdir(), 'orca-codex-command-'))
const badDir = join(root, 'bad-bin')
const goodDir = join(root, 'good-bin')
const badCommandPath = join(badDir, 'codex')
const goodCommandPath = join(goodDir, 'codex')
makeNonExecutableFile(badCommandPath)
makeExecutable(goodCommandPath)
expect(
resolveCodexCommand({
platform: 'linux',
pathEnv: [badDir, goodDir].join(delimiter),
homePath: root
})
).toBe(goodCommandPath)
}
)
it('skips PATH directories named like the command', () => {
const root = mkdtempSync(join(tmpdir(), 'orca-codex-command-'))
const badDir = join(root, 'bad-bin')
const goodDir = join(root, 'good-bin')
mkdirSync(join(badDir, 'codex'), { recursive: true })
const goodCommandPath = join(goodDir, 'codex')
makeExecutable(goodCommandPath)
expect(
resolveCodexCommand({
platform: 'linux',
pathEnv: [badDir, goodDir].join(delimiter),
homePath: root
})
).toBe(goodCommandPath)
})
it('falls back to the newest nvm-installed Codex when PATH misses it', () => {
const root = mkdtempSync(join(tmpdir(), 'orca-codex-command-'))
const v22Path = join(root, '.nvm', 'versions', 'node', 'v22.14.0', 'bin', 'codex')
const v24Path = join(root, '.nvm', 'versions', 'node', 'v24.13.0', 'bin', 'codex')
makeExecutable(v22Path)
makeExecutable(v24Path)
expect(resolveCodexCommand({ platform: 'darwin', pathEnv: '', homePath: root })).toBe(v24Path)
})
it('finds Codex in pnpm global bin on macOS', () => {
const root = mkdtempSync(join(tmpdir(), 'orca-codex-command-'))
const pnpmPath = join(root, 'Library', 'pnpm', 'codex')
makeExecutable(pnpmPath)
expect(resolveCodexCommand({ platform: 'darwin', pathEnv: '', homePath: root })).toBe(pnpmPath)
})
it('finds Codex in pnpm global bin on Linux', () => {
const root = mkdtempSync(join(tmpdir(), 'orca-codex-command-'))
const pnpmPath = join(root, '.local', 'share', 'pnpm', 'codex')
makeExecutable(pnpmPath)
expect(resolveCodexCommand({ platform: 'linux', pathEnv: '', homePath: root })).toBe(pnpmPath)
})
it('finds Codex in pnpm global bin on Windows', () => {
const root = mkdtempSync(join(tmpdir(), 'orca-codex-command-'))
const pnpmPath = join(root, 'AppData', 'Local', 'pnpm', 'codex.cmd')
makeExecutable(pnpmPath)
expect(resolveCodexCommand({ platform: 'win32', pathEnv: '', homePath: root })).toBe(pnpmPath)
})
it('finds Codex in yarn global bin on macOS', () => {
const root = mkdtempSync(join(tmpdir(), 'orca-codex-command-'))
const yarnPath = join(root, '.yarn', 'bin', 'codex')
makeExecutable(yarnPath)
expect(resolveCodexCommand({ platform: 'darwin', pathEnv: '', homePath: root })).toBe(yarnPath)
})
it('finds Codex in yarn global bin on Windows', () => {
const root = mkdtempSync(join(tmpdir(), 'orca-codex-command-'))
const yarnPath = join(root, 'AppData', 'Local', 'Yarn', 'bin', 'codex.cmd')
makeExecutable(yarnPath)
expect(resolveCodexCommand({ platform: 'win32', pathEnv: '', homePath: root })).toBe(yarnPath)
})
it('finds Codex in bun global bin', () => {
const root = mkdtempSync(join(tmpdir(), 'orca-codex-command-'))
const bunPath = join(root, '.bun', 'bin', 'codex')
makeExecutable(bunPath)
expect(resolveCodexCommand({ platform: 'linux', pathEnv: '', homePath: root })).toBe(bunPath)
})
it('finds Codex in bun global bin on Windows', () => {
const root = mkdtempSync(join(tmpdir(), 'orca-codex-command-'))
const bunPath = join(root, '.bun', 'bin', 'codex.exe')
makeExecutable(bunPath)
expect(resolveCodexCommand({ platform: 'win32', pathEnv: '', homePath: root })).toBe(bunPath)
})
it('finds Codex in mise shims directory', () => {
const root = mkdtempSync(join(tmpdir(), 'orca-codex-command-'))
const misePath = join(root, '.local', 'share', 'mise', 'shims', 'codex')
makeExecutable(misePath)
expect(resolveCodexCommand({ platform: 'linux', pathEnv: '', homePath: root })).toBe(misePath)
})
it('returns the bare command when no filesystem candidate exists', () => {
const root = mkdtempSync(join(tmpdir(), 'orca-codex-command-'))
expect(resolveCodexCommand({ platform: 'linux', pathEnv: '', homePath: root })).toBe('codex')
})
})
describe('resolveClaudeCommand', () => {
afterEach(() => {
delete process.env.PATH
delete process.env.Path
})
it('prefers claude already present on PATH', () => {
const root = mkdtempSync(join(tmpdir(), 'orca-claude-command-'))
const pathDir = join(root, 'bin')
const commandPath = join(pathDir, 'claude')
makeExecutable(commandPath)
expect(resolveClaudeCommand({ platform: 'darwin', pathEnv: pathDir, homePath: root })).toBe(
commandPath
)
})
it('falls back to the newest nvm-installed claude when PATH misses it', () => {
const root = mkdtempSync(join(tmpdir(), 'orca-claude-command-'))
const v22Path = join(root, '.nvm', 'versions', 'node', 'v22.14.0', 'bin', 'claude')
const v24Path = join(root, '.nvm', 'versions', 'node', 'v24.13.0', 'bin', 'claude')
makeExecutable(v22Path)
makeExecutable(v24Path)
expect(resolveClaudeCommand({ platform: 'darwin', pathEnv: '', homePath: root })).toBe(v24Path)
})
it('finds claude in pnpm global bin on macOS', () => {
const root = mkdtempSync(join(tmpdir(), 'orca-claude-command-'))
const pnpmPath = join(root, 'Library', 'pnpm', 'claude')
makeExecutable(pnpmPath)
expect(resolveClaudeCommand({ platform: 'darwin', pathEnv: '', homePath: root })).toBe(pnpmPath)
})
it('finds claude in yarn global bin', () => {
const root = mkdtempSync(join(tmpdir(), 'orca-claude-command-'))
const yarnPath = join(root, '.yarn', 'bin', 'claude')
makeExecutable(yarnPath)
expect(resolveClaudeCommand({ platform: 'linux', pathEnv: '', homePath: root })).toBe(yarnPath)
})
it('finds claude in bun global bin', () => {
const root = mkdtempSync(join(tmpdir(), 'orca-claude-command-'))
const bunPath = join(root, '.bun', 'bin', 'claude')
makeExecutable(bunPath)
expect(resolveClaudeCommand({ platform: 'darwin', pathEnv: '', homePath: root })).toBe(bunPath)
})
it('finds native Windows claude.exe in user-local bin', () => {
const root = mkdtempSync(join(tmpdir(), 'orca-claude-command-'))
const nativePath = join(root, '.local', 'bin', 'claude.exe')
makeExecutable(nativePath)
expect(resolveClaudeCommand({ platform: 'win32', pathEnv: '', homePath: root })).toBe(
nativePath
)
})
it('returns the bare command when no filesystem candidate exists', () => {
const root = mkdtempSync(join(tmpdir(), 'orca-claude-command-'))
expect(resolveClaudeCommand({ platform: 'linux', pathEnv: '', homePath: root })).toBe('claude')
})
})
describe('resolveCliCommands', () => {
afterEach(() => {
delete process.env.PATH
delete process.env.Path
})
it('resolves a batch from PATH and install directories', () => {
const root = mkdtempSync(join(tmpdir(), 'orca-cli-commands-'))
const pathDir = join(root, 'bin')
const pathClaude = join(pathDir, 'claude')
const nvmCodex = join(root, '.nvm', 'versions', 'node', 'v24.13.0', 'bin', 'codex')
const pnpmOpencode = join(root, 'Library', 'pnpm', 'opencode')
makeExecutable(pathClaude)
makeExecutable(nvmCodex)
makeExecutable(pnpmOpencode)
const resolved = resolveCliCommands(['claude', 'codex', 'opencode', 'missing'], {
platform: 'darwin',
pathEnv: pathDir,
homePath: root
})
expect(resolved.get('claude')).toBe(pathClaude)
expect(resolved.get('codex')).toBe(nvmCodex)
expect(resolved.get('opencode')).toBe(pnpmOpencode)
expect(resolved.get('missing')).toBe('missing')
})
it('deduplicates command names in the returned map', () => {
const root = mkdtempSync(join(tmpdir(), 'orca-cli-commands-'))
const pathDir = join(root, 'bin')
const pathClaude = join(pathDir, 'claude')
makeExecutable(pathClaude)
const resolved = resolveCliCommands(['claude', 'claude'], {
platform: 'linux',
pathEnv: pathDir,
homePath: root
})
expect([...resolved.keys()]).toEqual(['claude'])
expect(resolved.get('claude')).toBe(pathClaude)
})
})
describe('getVersionManagerBinPaths', () => {
it('includes volta, asdf, fnm, mise, pnpm, yarn, and bun directories', () => {
const root = mkdtempSync(join(tmpdir(), 'orca-vm-paths-'))
const paths = getVersionManagerBinPaths({ platform: 'darwin', pathEnv: '', homePath: root })
expect(paths).toContain(join(root, '.volta', 'bin'))
expect(paths).toContain(join(root, '.asdf', 'shims'))
expect(paths).toContain(join(root, '.fnm', 'aliases', 'default', 'bin'))
expect(paths).toContain(join(root, '.local', 'share', 'mise', 'shims'))
expect(paths).toContain(join(root, 'Library', 'pnpm'))
expect(paths).toContain(join(root, '.yarn', 'bin'))
expect(paths).toContain(join(root, '.bun', 'bin'))
})
it('includes nvm bin dir when node versions exist', () => {
const root = mkdtempSync(join(tmpdir(), 'orca-vm-paths-'))
const nodeBin = join(root, '.nvm', 'versions', 'node', 'v22.14.0', 'bin', 'node')
makeExecutable(nodeBin)
const paths = getVersionManagerBinPaths({ platform: 'darwin', pathEnv: '', homePath: root })
expect(paths).toContain(join(root, '.nvm', 'versions', 'node', 'v22.14.0', 'bin'))
})
it('uses platform-specific pnpm path on Linux', () => {
const root = mkdtempSync(join(tmpdir(), 'orca-vm-paths-'))
const paths = getVersionManagerBinPaths({ platform: 'linux', pathEnv: '', homePath: root })
expect(paths).toContain(join(root, '.local', 'share', 'pnpm'))
expect(paths).not.toContain(join(root, 'Library', 'pnpm'))
})
it('includes Windows user-local bin for native CLI installers', () => {
const root = mkdtempSync(join(tmpdir(), 'orca-vm-paths-'))
const paths = getVersionManagerBinPaths({ platform: 'win32', pathEnv: '', homePath: root })
expect(paths).toContain(join(root, '.local', 'bin'))
expect(paths).toContain(join(root, 'AppData', 'Roaming', 'npm'))
})
})