Files
orca/src/main/codex-cli/command.test.ts
T
Neil a7505fd911 fix(cli): spawn a version-manager CLI with its own node runtime (#16365)
* fix(cli): spawn a version-manager CLI with its own node runtime

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. Nothing paired the binary with the runtime it was
installed against, so its `#!/usr/bin/env node` shebang loaded a v20-built
native module under a v22 ABI and the agent died on first require (#10932).

Reproduced with a real addon rather than asserted: a CLI requiring a
cpu-features build for NODE_MODULE_VERSION 115, spawned with v24 leading
PATH, fails with ERR_DLOPEN_FAILED and exit 1. With the CLI's own bin
directory prepended it runs clean.

withCliRuntimeOnPath prepends the resolved command's directory when that
directory ships a sibling node, and is a no-op otherwise — so a Homebrew or
/usr/local CLI is untouched, and the WSL paths pass a bare `codex`/`claude`
that is not absolute and so never matches.

Host CLI resolution in the Claude login path is now lazy, keeping the WSL
branch from resolving a host binary it never spawns.

* fix(cli): split PATH on the delimiter we join with, pair app-server too

Readiness review findings, all four addressed.

withCliRuntimeOnPath chose its join delimiter from the platform option but
split with the host's. Passing platform:'win32' from a posix host turned
`C:\Windows;C:\Windows\System32` into `C;\Windows;C;\Windows\System32` —
every drive letter torn off at its colon. Latent, since no shipped caller
passes platform, but the sole win32 test was written against the corrupted
value and asserted one split segment, so it green-lit the shredding.

That test's other assertion was vacuous: it seeded only `Path`, so the
`PATH` key it asserted absent could never exist. Deleting the whole
case-dedupe block left the suite green. It now seeds both keys and asserts
the full joined string; removing the block fails it.

Nothing covered the wiring, and the argument choice is the easy thing to get
silently wrong. Note it only diverges on win32 — on posix
getSpawnArgsForWindows returns the CLI itself, so pairing the spawn command
is indistinguishable there. The new test drives the win32 branch with a .cmd
fixture; pairing spawnCmd or dropping the wrapper both fail it now.

codex-trust-grant-host and codex-session-index-heal spawn the same
`codex app-server` subcommand through runCodexAppServerSession and were left
unpaired. Pair centrally there via a new optional cliPath, since
invocation.command may be a cmd.exe wrapper.

Pairing tests live in their own file: adding them inline pushed
codex-fetcher.test.ts past the 800-line ratchet.

* fix(cli): read the Windows path key the child will actually use

Round-2 review finding. The read was narrower than the delete: the key was
picked from exactly two spellings (`Path`, else `PATH`), while the twin
dedupe removed every key whose lowercase form is `path`. A block spelling it
`path` or `pATh` therefore had its value deleted without ever being read,
handing the child a PATH containing only the CLI's own directory — a strictly
worse outcome than not pairing at all.

Win32 resolves env names case-insensitively and object order preserves block
order, so the entry the child reads is the first case-insensitive match. The
repo already encodes that rule in resolvePathEnvKey
(src/main/pty/windows-path-segment-merge.ts); src/shared cannot import from
src/main, so mirror it locally.

Verified by execution across six env shapes: lowercase, mixed-case, Path-only,
PATH-only, both twins, and a PATHEXT control that must not be touched. All
preserve the original PATH; before the fix the first two lost it entirely.
Reverting the selector fails the new test and nothing else.
2026-08-24 22:30:04 -07:00

393 lines
15 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,
withCliRuntimeOnPath,
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'))
})
})
describe('withCliRuntimeOnPath', () => {
it('pairs a version-manager CLI with its sibling node (stablyai/orca#10932)', () => {
const root = mkdtempSync(join(tmpdir(), 'orca-pair-'))
const v20 = join(root, '.nvm', 'versions', 'node', 'v20.11.0', 'bin')
const v22 = join(root, '.nvm', 'versions', 'node', 'v22.9.0', 'bin')
makeExecutable(join(v20, 'node'))
makeExecutable(join(v20, 'codex'))
makeExecutable(join(v22, 'node'))
// default is v22, but codex only exists under v20
const env = { PATH: [v22, '/usr/bin'].join(delimiter) }
const codex = resolveCodexCommand({ platform: 'darwin', pathEnv: env.PATH, homePath: root })
expect(codex).toBe(join(v20, 'codex'))
const paired = withCliRuntimeOnPath(codex, env, { platform: 'darwin' })
// the shebang's `node` must now come from v20, not v22
expect(paired.PATH.split(delimiter)[0]).toBe(v20)
})
it('leaves PATH untouched for a CLI whose directory ships no node', () => {
const root = mkdtempSync(join(tmpdir(), 'orca-pair-'))
const brew = join(root, 'opt', 'homebrew', 'bin')
makeExecutable(join(brew, 'codex'))
const env = { PATH: '/usr/bin' }
expect(withCliRuntimeOnPath(join(brew, 'codex'), env, { platform: 'darwin' })).toBe(env)
})
it('leaves PATH untouched for a bare command name', () => {
const env = { PATH: '/usr/bin' }
expect(withCliRuntimeOnPath('codex', env, { platform: 'darwin' })).toBe(env)
})
it('is a no-op when the runtime directory already leads PATH', () => {
const root = mkdtempSync(join(tmpdir(), 'orca-pair-'))
const v20 = join(root, '.nvm', 'versions', 'node', 'v20.11.0', 'bin')
makeExecutable(join(v20, 'node'))
makeExecutable(join(v20, 'codex'))
const env = { PATH: [v20, '/usr/bin'].join(delimiter) }
expect(withCliRuntimeOnPath(join(v20, 'codex'), env, { platform: 'darwin' })).toBe(env)
})
it('reads the Windows path key the child will actually use, whatever its casing', () => {
const root = mkdtempSync(join(tmpdir(), 'orca-pair-'))
const v20 = join(root, '.nvm', 'versions', 'node', 'v20.11.0', 'bin')
makeExecutable(join(v20, 'node.exe'))
makeExecutable(join(v20, 'codex.cmd'))
// Why: win32 resolves env names case-insensitively, so a block may spell it
// any way. Reading a narrower set than the twin-dedupe deletes would drop
// this entry unread and hand the child a PATH containing only our directory.
const env = { path: 'C:\\Windows;C:\\Windows\\System32', HOME: 'x' }
const paired = withCliRuntimeOnPath(join(v20, 'codex.cmd'), env, { platform: 'win32' })
expect(paired.path).toBe([v20, 'C:\\Windows', 'C:\\Windows\\System32'].join(';'))
expect(Object.keys(paired).filter((key) => key.toLowerCase() === 'path')).toEqual(['path'])
})
it('writes the Windows Path key without leaving a differently-cased twin', () => {
const root = mkdtempSync(join(tmpdir(), 'orca-pair-'))
const v20 = join(root, '.nvm', 'versions', 'node', 'v20.11.0', 'bin')
makeExecutable(join(v20, 'node.exe'))
makeExecutable(join(v20, 'codex.cmd'))
// Why both keys: with only `Path` seeded the assertion is vacuous — the
// helper cannot invent a `PATH` key, so the dedupe loop could be deleted
// wholesale and this test would still pass.
const env = { Path: 'C:\\Windows;C:\\Windows\\System32', PATH: 'C:\\Stale' }
const paired = withCliRuntimeOnPath(join(v20, 'codex.cmd'), env, { platform: 'win32' })
expect(Object.keys(paired)).toEqual(['Path'])
// Why the whole string: splitting on the host delimiter while joining on ';'
// shredded every drive letter into `C;\\Windows`.
expect(paired.Path).toBe([v20, 'C:\\Windows', 'C:\\Windows\\System32'].join(';'))
})
})