From fef903df98ef9061d5f902abda8fedb8ae570f0a Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Thu, 20 Aug 2026 12:47:42 -0400 Subject: [PATCH] fix(cli-resolvers): find CLIs installed via nvm/Homebrew when running as a service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A CLI installed by nvm, Homebrew or a user-level npm prefix lives on a PATH that only a login shell sets up. Codeman running under systemd or launchd does not get that PATH — launchd hands a job `/usr/bin:/bin:/usr/sbin:/sbin` — so every resolver reported the CLI as unavailable on installs where it is plainly there and works from a terminal. Each of the six resolvers had its own hand-rolled copy of the same PATH walk, so the fix is factored into one shared `createCliExecutableResolver()` with an explicit lookup order: the server process PATH, then common install directories in order, then an interactive login shell as the last resort. Only the last step spawns anything, and only when the cheap lookups have already missed. Also adds `formatCliNotFoundMessage()`, so a failure explains where it looked instead of just asserting the CLI is missing. Its diagnostics are bounded and control characters are flattened, so a not-found message cannot dump arbitrary environment data. Success is cached and failure is retried, so installing a CLI while the server is running is picked up without a restart. Net -103 lines across the six resolvers. Behaviour is unchanged wherever the CLI was already on the process PATH: that remains the first thing checked. Tests: 20 cases in test/cli-executable-resolver.test.ts covering the precedence order, login-shell-only resolution, the caching rule, unsafe-name rejection, and the bounded diagnostics. --- src/utils/antigravity-cli-resolver.ts | 56 +++-- src/utils/claude-cli-resolver.ts | 40 +--- src/utils/cli-executable-resolver.ts | 208 +++++++++++++++++ src/utils/codex-cli-resolver.ts | 40 +--- src/utils/gemini-cli-resolver.ts | 39 +--- src/utils/opencode-cli-resolver.ts | 41 +--- src/utils/pi-cli-resolver.ts | 95 ++++---- test/antigravity-cli-resolver.test.ts | 111 +++++++++ test/cli-executable-resolver.test.ts | 319 ++++++++++++++++++++++++++ 9 files changed, 742 insertions(+), 207 deletions(-) create mode 100644 src/utils/cli-executable-resolver.ts create mode 100644 test/antigravity-cli-resolver.test.ts create mode 100644 test/cli-executable-resolver.test.ts diff --git a/src/utils/antigravity-cli-resolver.ts b/src/utils/antigravity-cli-resolver.ts index dc3466e1..d85adc0e 100644 --- a/src/utils/antigravity-cli-resolver.ts +++ b/src/utils/antigravity-cli-resolver.ts @@ -7,22 +7,37 @@ * @module utils/antigravity-cli-resolver */ -import { execSync } from 'node:child_process'; -import { existsSync } from 'node:fs'; -import { dirname, join } from 'node:path'; +import { join } from 'node:path'; import { homedir } from 'node:os'; -import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js'; +import { + createCliExecutableResolver, + formatCliNotFoundMessage, + type CliResolverHost, +} from './cli-executable-resolver.js'; /** Common directories where the Antigravity CLI binary may be installed */ const ANTIGRAVITY_SEARCH_DIRS = [ join(homedir(), '.local', 'bin'), join(homedir(), '.antigravity', 'bin'), '/usr/local/bin', + join(homedir(), '.bun', 'bin'), + join(homedir(), '.npm-global', 'bin'), join(homedir(), 'bin'), ]; -/** Cached directory containing the agy binary (empty string = searched but not found) */ -let _antigravityDir: string | null = null; +const ANTIGRAVITY_NOT_FOUND = + 'Antigravity CLI not found. Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash'; + +function createAntigravityResolver(host?: CliResolverHost) { + return createCliExecutableResolver({ binary: 'agy', searchDirs: ANTIGRAVITY_SEARCH_DIRS }, host); +} + +/** Creates an isolated Antigravity wrapper around an injected resolver host. */ +export function createAntigravityResolverForTest(host: CliResolverHost) { + return createAntigravityResolver(host); +} + +const antigravityResolver = createAntigravityResolver(); /** * Finds the directory containing the `agy` binary. @@ -31,30 +46,7 @@ let _antigravityDir: string | null = null; * @returns Directory path, or null if not found */ export function resolveAntigravityDir(): string | null { - if (_antigravityDir !== null) return _antigravityDir || null; - - try { - const result = execSync('which agy', { - encoding: 'utf-8', - timeout: EXEC_TIMEOUT_MS, - }).trim(); - if (result && existsSync(result)) { - _antigravityDir = dirname(result); - return _antigravityDir; - } - } catch { - // agy not in PATH, will check common locations - } - - for (const dir of ANTIGRAVITY_SEARCH_DIRS) { - if (existsSync(join(dir, 'agy'))) { - _antigravityDir = dir; - return _antigravityDir; - } - } - - _antigravityDir = ''; - return null; + return antigravityResolver.resolve()?.directory ?? null; } /** @@ -63,3 +55,7 @@ export function resolveAntigravityDir(): string | null { export function isAntigravityAvailable(): boolean { return resolveAntigravityDir() !== null; } + +export function getAntigravityNotFoundMessage(): string { + return formatCliNotFoundMessage(ANTIGRAVITY_NOT_FOUND, antigravityResolver.diagnostics()); +} diff --git a/src/utils/claude-cli-resolver.ts b/src/utils/claude-cli-resolver.ts index 6e8f1dda..27781c34 100644 --- a/src/utils/claude-cli-resolver.ts +++ b/src/utils/claude-cli-resolver.ts @@ -8,11 +8,11 @@ * @module utils/claude-cli-resolver */ -import { execSync, execFileSync } from 'node:child_process'; -import { existsSync } from 'node:fs'; -import { delimiter, dirname, join } from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { delimiter, join } from 'node:path'; import { homedir } from 'node:os'; import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js'; +import { createCliExecutableResolver, formatCliNotFoundMessage } from './cli-executable-resolver.js'; /** Common directories where the Claude CLI binary may be installed */ const CLAUDE_SEARCH_DIRS = [ @@ -23,8 +23,8 @@ const CLAUDE_SEARCH_DIRS = [ join(homedir(), 'bin'), ]; -/** Cached directory containing the claude binary (empty string = searched but not found) */ -let _claudeDir: string | null = null; +const claudeResolver = createCliExecutableResolver({ binary: 'claude', searchDirs: CLAUDE_SEARCH_DIRS }); +const CLAUDE_NOT_FOUND = 'Claude CLI not found. Install it with: curl -fsSL https://claude.ai/install.sh | bash'; /** * Returns true if the Claude CLI binary can be located (via `which` or one of @@ -43,29 +43,11 @@ export function isClaudeAvailable(): boolean { * @returns Directory path, or null if not found */ export function findClaudeDir(): string | null { - if (_claudeDir !== null) return _claudeDir || null; + return claudeResolver.resolve()?.directory ?? null; +} - // Try `which` first (respects current PATH) - try { - const result = execSync('which claude', { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }).trim(); - if (result && existsSync(result)) { - _claudeDir = dirname(result); - return _claudeDir; - } - } catch { - // Claude not in PATH, will check common locations - } - - // Fallback: check common installation directories - for (const dir of CLAUDE_SEARCH_DIRS) { - if (existsSync(join(dir, 'claude'))) { - _claudeDir = dir; - return _claudeDir; - } - } - - _claudeDir = ''; // mark as searched, not found - return null; +export function getClaudeNotFoundMessage(): string { + return formatCliNotFoundMessage(CLAUDE_NOT_FOUND, claudeResolver.diagnostics()); } /** @@ -99,7 +81,9 @@ export function getAugmentedPath(): string { const currentPath = process.env.PATH || ''; const claudeDir = findClaudeDir(); - if (claudeDir && !currentPath.split(delimiter).includes(claudeDir)) { + if (!claudeDir) return currentPath; + + if (!currentPath.split(delimiter).includes(claudeDir)) { _augmentedPath = `${claudeDir}${delimiter}${currentPath}`; return _augmentedPath; } diff --git a/src/utils/cli-executable-resolver.ts b/src/utils/cli-executable-resolver.ts new file mode 100644 index 00000000..cf1ec7a9 --- /dev/null +++ b/src/utils/cli-executable-resolver.ts @@ -0,0 +1,208 @@ +import { execFileSync } from 'node:child_process'; +import { accessSync, constants, statSync } from 'node:fs'; +import { basename, delimiter, dirname, isAbsolute, join } from 'node:path'; +import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js'; +import { loginShellArgs, resolveLocalShell } from './shell-resolver.js'; + +const SAFE_BINARY_NAME = /^[a-z0-9][a-z0-9._-]*$/i; +const LOGIN_SHELL_BEGIN_MARKER = '__CODEMAN_CLI_RESOLVE_BEGIN__'; +const LOGIN_SHELL_END_MARKER = '__CODEMAN_CLI_RESOLVE_END__'; +/** Maximum rendered length of each bounded diagnostic field, excluding its label. */ +const DIAGNOSTIC_FIELD_MAX_LENGTH = 1024; + +export type CliResolutionSource = 'process-path' | 'common-directory' | 'login-shell'; + +export interface CliResolutionDiagnostics { + binary: string; + processPath: string; + shellPath: string; + shellArgs: string[]; + searchDirs: string[]; +} + +export interface CliResolverHost { + processPath: string; + shellPath: string; + shellArgs: string[]; + findOnProcessPath(binary: string): string | null; + findInLoginShell(binary: string): string | null; + exists(path: string): boolean; +} + +export interface CandidateValidation { + accepted: boolean; + metadata?: T; +} + +export interface CliResolution { + binaryPath: string; + directory: string; + source: CliResolutionSource; + metadata?: T; +} + +export interface CliExecutableResolver { + resolve(): CliResolution | null; + diagnostics(): CliResolutionDiagnostics; +} + +export interface CliResolverCommandOptions { + encoding: 'utf8'; + timeout: number; + stdio: ['ignore', 'pipe', 'ignore']; +} + +export type CliResolverCommandRunner = (file: string, args: string[], options: CliResolverCommandOptions) => string; + +export interface ProductionCliResolverHostOptions { + processPath?: string; + shellPath?: string; + shellArgs?: string[]; + runCommand?: CliResolverCommandRunner; + isExecutableFile?: (path: string) => boolean; +} + +function isExecutableRegularFile(path: string): boolean { + try { + if (!statSync(path).isFile()) return false; + accessSync(path, constants.X_OK); + return true; + } catch { + return false; + } +} + +function parseLoginShellResult(output: string, binary: string): string | null { + const lines = output.split(/\r?\n/).map((line) => line.trim()); + const begin = lines.indexOf(LOGIN_SHELL_BEGIN_MARKER); + if (begin === -1) return null; + const end = lines.indexOf(LOGIN_SHELL_END_MARKER, begin + 1); + if (end === -1) return null; + + for (const candidate of lines.slice(begin + 1, end)) { + if (isAbsolute(candidate) && basename(candidate) === binary) return candidate; + } + return null; +} + +function loginShellCommand(binary: string): string { + return [ + `printf '%s\\n' '${LOGIN_SHELL_BEGIN_MARKER}'`, + `command -v -- ${binary}`, + `printf '%s\\n' '${LOGIN_SHELL_END_MARKER}'`, + ].join('; '); +} + +export function createProductionCliResolverHost(options: ProductionCliResolverHostOptions = {}): CliResolverHost { + const shellPath = options.shellPath ?? resolveLocalShell(); + const shellArgs = options.shellArgs ?? loginShellArgs(shellPath).trim().split(/\s+/).filter(Boolean); + const processPath = options.processPath ?? process.env.PATH ?? ''; + const isExecutableFile = options.isExecutableFile ?? isExecutableRegularFile; + const runCommand: CliResolverCommandRunner = + options.runCommand ?? ((file, args, commandOptions) => execFileSync(file, args, commandOptions)); + const run = (file: string, args: string[]): string => { + try { + return runCommand(file, args, { + encoding: 'utf8', + timeout: EXEC_TIMEOUT_MS, + stdio: ['ignore', 'pipe', 'ignore'], + }); + } catch { + return ''; + } + }; + + return { + processPath, + shellPath, + shellArgs: [...shellArgs], + findOnProcessPath: (binary) => { + if (!SAFE_BINARY_NAME.test(binary)) return null; + for (const directory of processPath.split(delimiter).filter(Boolean)) { + const candidate = join(directory, binary); + if (isAbsolute(candidate) && isExecutableFile(candidate)) return candidate; + } + return null; + }, + findInLoginShell: (binary) => { + if (!SAFE_BINARY_NAME.test(binary)) return null; + const candidate = parseLoginShellResult(run(shellPath, [...shellArgs, '-c', loginShellCommand(binary)]), binary); + return candidate && isExecutableFile(candidate) ? candidate : null; + }, + exists: isExecutableFile, + }; +} + +export function createCliExecutableResolver( + options: { + binary: string; + searchDirs: string[]; + validateCandidate?: (path: string) => CandidateValidation; + }, + host: CliResolverHost = createProductionCliResolverHost() +): CliExecutableResolver { + if (!SAFE_BINARY_NAME.test(options.binary)) { + throw new Error(`Unsafe CLI binary name: ${options.binary}`); + } + + let cached: CliResolution | null = null; + const accept = (path: string | null, source: CliResolutionSource): CliResolution | null => { + if (!path || !isAbsolute(path) || !host.exists(path)) return null; + const validation = options.validateCandidate?.(path) ?? ({ accepted: true } as CandidateValidation); + if (!validation.accepted) return null; + return { + binaryPath: path, + directory: dirname(path), + source, + metadata: validation.metadata, + }; + }; + + return { + resolve() { + if (cached) return cached; + + cached = accept(host.findOnProcessPath(options.binary), 'process-path'); + if (cached) return cached; + + for (const dir of options.searchDirs) { + cached = accept(join(dir, options.binary), 'common-directory'); + if (cached) return cached; + } + + cached = accept(host.findInLoginShell(options.binary), 'login-shell'); + return cached; + }, + diagnostics: () => ({ + binary: options.binary, + processPath: host.processPath, + shellPath: host.shellPath, + shellArgs: [...host.shellArgs], + searchDirs: [...options.searchDirs], + }), + }; +} + +function sanitizeDiagnosticField(value: string, emptyMarker: string): string { + const flattened = Array.from(value, (character) => { + const codePoint = character.codePointAt(0) ?? 0; + const isControl = codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f); + return isControl || codePoint === 0x2028 || codePoint === 0x2029 ? ' ' : character; + }) + .join('') + .replace(/ +/g, ' ') + .trim(); + if (!flattened) return emptyMarker; + if (flattened.length <= DIAGNOSTIC_FIELD_MAX_LENGTH) return flattened; + return `${flattened.slice(0, DIAGNOSTIC_FIELD_MAX_LENGTH - 1)}…`; +} + +export function formatCliNotFoundMessage(base: string, diagnostics: CliResolutionDiagnostics): string { + const processPath = sanitizeDiagnosticField(diagnostics.processPath, '(empty)'); + const shell = sanitizeDiagnosticField( + [diagnostics.shellPath, ...diagnostics.shellArgs].filter(Boolean).join(' '), + '(none)' + ); + const dirs = sanitizeDiagnosticField(diagnostics.searchDirs.join(', '), '(none)'); + return `${base}\nServer PATH: ${processPath}\nLogin shell: ${shell}\nChecked directories: ${dirs}`; +} diff --git a/src/utils/codex-cli-resolver.ts b/src/utils/codex-cli-resolver.ts index e85fb75c..5cb7221a 100644 --- a/src/utils/codex-cli-resolver.ts +++ b/src/utils/codex-cli-resolver.ts @@ -7,11 +7,9 @@ * @module utils/codex-cli-resolver */ -import { execSync } from 'node:child_process'; -import { existsSync } from 'node:fs'; -import { dirname, join } from 'node:path'; +import { join } from 'node:path'; import { homedir } from 'node:os'; -import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js'; +import { createCliExecutableResolver, formatCliNotFoundMessage } from './cli-executable-resolver.js'; /** Common directories where the Codex CLI binary may be installed */ const CODEX_SEARCH_DIRS = [ @@ -23,8 +21,8 @@ const CODEX_SEARCH_DIRS = [ join(homedir(), 'bin'), // User bin ]; -/** Cached directory containing the codex binary (empty string = searched but not found) */ -let _codexDir: string | null = null; +const codexResolver = createCliExecutableResolver({ binary: 'codex', searchDirs: CODEX_SEARCH_DIRS }); +const CODEX_NOT_FOUND = 'Codex CLI not found. Install with: npm install -g @openai/codex'; /** * Finds the directory containing the `codex` binary. @@ -34,31 +32,7 @@ let _codexDir: string | null = null; * @returns Directory path, or null if not found */ export function resolveCodexDir(): string | null { - if (_codexDir !== null) return _codexDir || null; - - // Try `which` first (respects current PATH) - try { - const result = execSync('which codex', { - encoding: 'utf-8', - timeout: EXEC_TIMEOUT_MS, - }).trim(); - if (result && existsSync(result)) { - _codexDir = dirname(result); - return _codexDir; - } - } catch { - // Codex not in PATH, will check common locations - } - - for (const dir of CODEX_SEARCH_DIRS) { - if (existsSync(join(dir, 'codex'))) { - _codexDir = dir; - return _codexDir; - } - } - - _codexDir = ''; // mark as searched, not found - return null; + return codexResolver.resolve()?.directory ?? null; } /** @@ -67,3 +41,7 @@ export function resolveCodexDir(): string | null { export function isCodexAvailable(): boolean { return resolveCodexDir() !== null; } + +export function getCodexNotFoundMessage(): string { + return formatCliNotFoundMessage(CODEX_NOT_FOUND, codexResolver.diagnostics()); +} diff --git a/src/utils/gemini-cli-resolver.ts b/src/utils/gemini-cli-resolver.ts index 6f0b5099..45936d60 100644 --- a/src/utils/gemini-cli-resolver.ts +++ b/src/utils/gemini-cli-resolver.ts @@ -7,11 +7,9 @@ * @module utils/gemini-cli-resolver */ -import { execSync } from 'node:child_process'; -import { existsSync } from 'node:fs'; -import { dirname, join } from 'node:path'; +import { join } from 'node:path'; import { homedir } from 'node:os'; -import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js'; +import { createCliExecutableResolver, formatCliNotFoundMessage } from './cli-executable-resolver.js'; /** Common directories where the Gemini CLI binary may be installed */ const GEMINI_SEARCH_DIRS = [ @@ -23,8 +21,8 @@ const GEMINI_SEARCH_DIRS = [ join(homedir(), 'bin'), ]; -/** Cached directory containing the gemini binary (empty string = searched but not found) */ -let _geminiDir: string | null = null; +const geminiResolver = createCliExecutableResolver({ binary: 'gemini', searchDirs: GEMINI_SEARCH_DIRS }); +const GEMINI_NOT_FOUND = 'Gemini CLI not found. Install with: npm install -g @google/gemini-cli'; /** * Finds the directory containing the `gemini` binary. @@ -33,30 +31,7 @@ let _geminiDir: string | null = null; * @returns Directory path, or null if not found */ export function resolveGeminiDir(): string | null { - if (_geminiDir !== null) return _geminiDir || null; - - try { - const result = execSync('which gemini', { - encoding: 'utf-8', - timeout: EXEC_TIMEOUT_MS, - }).trim(); - if (result && existsSync(result)) { - _geminiDir = dirname(result); - return _geminiDir; - } - } catch { - // Gemini not in PATH, will check common locations - } - - for (const dir of GEMINI_SEARCH_DIRS) { - if (existsSync(join(dir, 'gemini'))) { - _geminiDir = dir; - return _geminiDir; - } - } - - _geminiDir = ''; - return null; + return geminiResolver.resolve()?.directory ?? null; } /** @@ -65,3 +40,7 @@ export function resolveGeminiDir(): string | null { export function isGeminiAvailable(): boolean { return resolveGeminiDir() !== null; } + +export function getGeminiNotFoundMessage(): string { + return formatCliNotFoundMessage(GEMINI_NOT_FOUND, geminiResolver.diagnostics()); +} diff --git a/src/utils/opencode-cli-resolver.ts b/src/utils/opencode-cli-resolver.ts index b144699e..3225dd6d 100644 --- a/src/utils/opencode-cli-resolver.ts +++ b/src/utils/opencode-cli-resolver.ts @@ -7,11 +7,9 @@ * @module utils/opencode-cli-resolver */ -import { execSync } from 'node:child_process'; -import { existsSync } from 'node:fs'; -import { dirname, join } from 'node:path'; +import { join } from 'node:path'; import { homedir } from 'node:os'; -import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js'; +import { createCliExecutableResolver, formatCliNotFoundMessage } from './cli-executable-resolver.js'; /** Common directories where the OpenCode CLI binary may be installed */ const OPENCODE_SEARCH_DIRS = [ @@ -24,8 +22,8 @@ const OPENCODE_SEARCH_DIRS = [ join(homedir(), 'bin'), // User bin ]; -/** Cached directory containing the opencode binary (empty string = searched but not found) */ -let _openCodeDir: string | null = null; +const openCodeResolver = createCliExecutableResolver({ binary: 'opencode', searchDirs: OPENCODE_SEARCH_DIRS }); +const OPENCODE_NOT_FOUND = 'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash'; /** * Finds the directory containing the `opencode` binary. @@ -35,32 +33,7 @@ let _openCodeDir: string | null = null; * @returns Directory path, or null if not found */ export function resolveOpenCodeDir(): string | null { - if (_openCodeDir !== null) return _openCodeDir || null; - - // Try `which` first (respects current PATH) - try { - const result = execSync('which opencode', { - encoding: 'utf-8', - timeout: EXEC_TIMEOUT_MS, - }).trim(); - if (result && existsSync(result)) { - _openCodeDir = dirname(result); - return _openCodeDir; - } - } catch { - // OpenCode not in PATH, will check common locations - } - - // Fallback: check common installation directories - for (const dir of OPENCODE_SEARCH_DIRS) { - if (existsSync(join(dir, 'opencode'))) { - _openCodeDir = dir; - return _openCodeDir; - } - } - - _openCodeDir = ''; // mark as searched, not found - return null; + return openCodeResolver.resolve()?.directory ?? null; } /** @@ -69,3 +42,7 @@ export function resolveOpenCodeDir(): string | null { export function isOpenCodeAvailable(): boolean { return resolveOpenCodeDir() !== null; } + +export function getOpenCodeNotFoundMessage(): string { + return formatCliNotFoundMessage(OPENCODE_NOT_FOUND, openCodeResolver.diagnostics()); +} diff --git a/src/utils/pi-cli-resolver.ts b/src/utils/pi-cli-resolver.ts index 358fe835..845faa89 100644 --- a/src/utils/pi-cli-resolver.ts +++ b/src/utils/pi-cli-resolver.ts @@ -15,11 +15,15 @@ * @module utils/pi-cli-resolver */ -import { execFileSync, execSync } from 'node:child_process'; -import { existsSync } from 'node:fs'; -import { dirname, join } from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { join } from 'node:path'; import { homedir } from 'node:os'; import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js'; +import { + createCliExecutableResolver, + formatCliNotFoundMessage, + type CliResolverHost, +} from './cli-executable-resolver.js'; /** Common directories where the Pi CLI binary may be installed */ const PI_SEARCH_DIRS = [ @@ -45,22 +49,15 @@ const PI_SEARCH_DIRS = [ */ export const PI_VERSION_REGEX = /(?:^|\s)(\d+\.\d+\.\d+)/; -/** Cached directory containing the pi binary (empty string = searched but not found) */ -let _piDir: string | null = null; -/** Cached version string reported by the resolved binary (empty string = probed, unusable) */ -let _piVersion: string | null = null; +const PI_NOT_FOUND = 'Pi CLI not found. Install with: npm install -g --ignore-scripts @earendil-works/pi-coding-agent'; /** * Run `pi --version` on a candidate path and return the trimmed version when it * looks like the coding agent. Returns null for anything else — a missing * binary, a non-zero exit, a hang (timeout), or output that is not semver-shaped * (which is how an unrelated `pi` on PATH gets rejected). - * - * Never runs under vitest: the suites must stay hermetic and must not depend on - * whether the dev box happens to have pi installed. */ function probePiVersion(binPath: string): string | null { - if (process.env.VITEST) return null; try { const out = execFileSync(binPath, ['--version'], { encoding: 'utf-8', @@ -77,6 +74,29 @@ function probePiVersion(binPath: string): string | null { return null; } +type PiVersionProbe = (binPath: string) => string | null; + +function createPiResolver(host?: CliResolverHost, versionProbe: PiVersionProbe = probePiVersion) { + return createCliExecutableResolver( + { + binary: 'pi', + searchDirs: PI_SEARCH_DIRS, + validateCandidate: (binPath) => { + const version = versionProbe(binPath); + return version ? { accepted: true, metadata: version } : { accepted: false }; + }, + }, + host + ); +} + +/** Creates an isolated Pi wrapper around an injected host and version probe. */ +export function createPiResolverForTest(host: CliResolverHost, versionProbe: PiVersionProbe) { + return createPiResolver(host, versionProbe); +} + +const piResolver = createPiResolver(); + /** * Finds the directory containing a verified `pi` binary. * Checks `which pi` first, then falls back to common install locations. Every @@ -86,46 +106,7 @@ function probePiVersion(binPath: string): string | null { * @returns Directory path, or null if not found */ export function resolvePiDir(): string | null { - if (_piDir !== null) return _piDir || null; - - const accept = (binPath: string): string | null => { - // Under vitest the probe never runs, so existence alone decides (keeps the - // suites hermetic and matches how the sibling resolvers behave there). - if (process.env.VITEST) { - _piDir = dirname(binPath); - _piVersion = ''; - return _piDir; - } - const version = probePiVersion(binPath); - if (!version) return null; - _piDir = dirname(binPath); - _piVersion = version; - return _piDir; - }; - - try { - const result = execSync('which pi', { - encoding: 'utf-8', - timeout: EXEC_TIMEOUT_MS, - }).trim(); - if (result && existsSync(result)) { - const dir = accept(result); - if (dir) return dir; - } - } catch { - // pi not in PATH, will check common locations - } - - for (const dir of PI_SEARCH_DIRS) { - const binPath = join(dir, 'pi'); - if (!existsSync(binPath)) continue; - const accepted = accept(binPath); - if (accepted) return accepted; - } - - _piDir = ''; - _piVersion = ''; - return null; + return piResolver.resolve()?.directory ?? null; } /** @@ -135,12 +116,14 @@ export function isPiAvailable(): boolean { return resolvePiDir() !== null; } +export function getPiNotFoundMessage(): string { + return formatCliNotFoundMessage(PI_NOT_FOUND, piResolver.diagnostics()); +} + /** - * Version reported by the resolved `pi` binary, or null when pi is unavailable - * (or when the probe was skipped, i.e. under vitest). Surfaced through - * `GET /api/pi/status` so a misresolution is diagnosable from the UI. + * Version reported by the resolved `pi` binary, or null when pi is unavailable. + * Surfaced through `GET /api/pi/status` so a misresolution is diagnosable from the UI. */ export function getPiCliVersion(): string | null { - resolvePiDir(); - return _piVersion || null; + return piResolver.resolve()?.metadata ?? null; } diff --git a/test/antigravity-cli-resolver.test.ts b/test/antigravity-cli-resolver.test.ts new file mode 100644 index 00000000..ee9e97dd --- /dev/null +++ b/test/antigravity-cli-resolver.test.ts @@ -0,0 +1,111 @@ +/** + * @fileoverview Tests for the Antigravity CLI resolver wrapper. + */ +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createAntigravityResolverForTest, isAntigravityAvailable } from '../src/utils/antigravity-cli-resolver.js'; +import type { CliResolution, CliResolverHost } from '../src/utils/cli-executable-resolver.js'; + +const availabilityResolution = vi.hoisted(() => ({ current: null as CliResolution | null })); + +vi.mock('../src/utils/cli-executable-resolver.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createCliExecutableResolver: (options: { binary: string; searchDirs: string[] }, host?: CliResolverHost) => + host + ? actual.createCliExecutableResolver(options, host) + : { + resolve: () => availabilityResolution.current, + diagnostics: () => ({ + binary: options.binary, + processPath: '/service/bin', + shellPath: '/bin/zsh', + shellArgs: ['-l'], + searchDirs: [...options.searchDirs], + }), + }, + }; +}); + +function createHost( + options: { + processPathResult?: string | null; + loginShellResults?: Array; + existingPaths?: string[]; + } = {} +): CliResolverHost { + const loginShellResults = [...(options.loginShellResults ?? [])]; + const existingPaths = new Set(options.existingPaths ?? []); + return { + processPath: '/service/bin', + shellPath: '/bin/zsh', + shellArgs: ['-l'], + findOnProcessPath: () => options.processPathResult ?? null, + findInLoginShell: () => loginShellResults.shift() ?? null, + exists: (path) => existingPaths.has(path), + }; +} + +describe('Antigravity CLI resolver', () => { + beforeEach(() => { + availabilityResolution.current = null; + }); + + it('resolves agy from the service PATH', () => { + const binaryPath = '/service/bin/agy'; + const resolver = createAntigravityResolverForTest( + createHost({ processPathResult: binaryPath, existingPaths: [binaryPath] }) + ); + + expect(resolver.resolve()?.directory).toBe('/service/bin'); + }); + + it('falls back to a common install directory', () => { + const binaryPath = join(homedir(), '.local', 'bin', 'agy'); + const resolver = createAntigravityResolverForTest(createHost({ existingPaths: [binaryPath] })); + + expect(resolver.resolve()?.directory).toBe(join(homedir(), '.local', 'bin')); + }); + + it('resolves agy found only by the login shell', () => { + const binaryPath = '/login-shell/bin/agy'; + const resolver = createAntigravityResolverForTest( + createHost({ loginShellResults: [binaryPath], existingPaths: [binaryPath] }) + ); + + expect(resolver.resolve()?.directory).toBe('/login-shell/bin'); + }); + + it('returns null when agy is unavailable', () => { + const resolver = createAntigravityResolverForTest(createHost()); + + expect(resolver.resolve()).toBeNull(); + }); + + it('retries a failed lookup and caches the first successful login-shell discovery', () => { + const binaryPath = '/late-login-shell/bin/agy'; + const resolver = createAntigravityResolverForTest( + createHost({ loginShellResults: [null, binaryPath], existingPaths: [binaryPath] }) + ); + + expect(resolver.resolve()).toBeNull(); + expect(resolver.resolve()?.binaryPath).toBe(binaryPath); + expect(resolver.resolve()?.binaryPath).toBe(binaryPath); + }); + + it('reports the public wrapper as available when agy resolves', () => { + availabilityResolution.current = { + binaryPath: '/service/bin/agy', + directory: '/service/bin', + source: 'process-path', + }; + + expect(isAntigravityAvailable()).toBe(true); + }); + + it('reports the public wrapper as unavailable when agy does not resolve', () => { + expect(isAntigravityAvailable()).toBe(false); + }); +}); diff --git a/test/cli-executable-resolver.test.ts b/test/cli-executable-resolver.test.ts new file mode 100644 index 00000000..b1fd56cd --- /dev/null +++ b/test/cli-executable-resolver.test.ts @@ -0,0 +1,319 @@ +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { EXEC_TIMEOUT_MS } from '../src/config/exec-timeout.js'; +import { + createCliExecutableResolver, + createProductionCliResolverHost, + formatCliNotFoundMessage, + type CliResolverHost, +} from '../src/utils/cli-executable-resolver.js'; + +const BEGIN_MARKER = '__CODEMAN_CLI_RESOLVE_BEGIN__'; +const END_MARKER = '__CODEMAN_CLI_RESOLVE_END__'; + +const temporaryDirectories: string[] = []; + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +function host(overrides: Partial = {}): CliResolverHost { + return { + processPath: '/usr/bin:/bin', + shellPath: '/bin/bash', + shellArgs: ['-i', '-l'], + findOnProcessPath: vi.fn(() => null), + findInLoginShell: vi.fn(() => null), + exists: vi.fn(() => false), + ...overrides, + }; +} + +describe('createCliExecutableResolver', () => { + it('prefers the server process PATH over common directories and the login shell', () => { + const h = host({ + findOnProcessPath: vi.fn(() => '/process/bin/codex'), + findInLoginShell: vi.fn(() => '/shell/bin/codex'), + exists: vi.fn(() => true), + }); + const resolver = createCliExecutableResolver({ binary: 'codex', searchDirs: ['/known/bin'] }, h); + + expect(resolver.resolve()).toMatchObject({ binaryPath: '/process/bin/codex', source: 'process-path' }); + expect(h.exists).toHaveBeenCalledTimes(1); + expect(h.findInLoginShell).not.toHaveBeenCalled(); + }); + + it('prefers common directories in order over the login shell', () => { + const h = host({ + findInLoginShell: vi.fn(() => '/shell/bin/codex'), + exists: vi.fn((path) => path === '/second/bin/codex' || path === '/shell/bin/codex'), + }); + const resolver = createCliExecutableResolver({ binary: 'codex', searchDirs: ['/first/bin', '/second/bin'] }, h); + + expect(resolver.resolve()).toMatchObject({ binaryPath: '/second/bin/codex', source: 'common-directory' }); + expect(h.exists).toHaveBeenNthCalledWith(1, '/first/bin/codex'); + expect(h.exists).toHaveBeenNthCalledWith(2, '/second/bin/codex'); + expect(h.findInLoginShell).not.toHaveBeenCalled(); + }); + + it('finds an executable exposed only by the interactive login shell', () => { + const h = host({ + findInLoginShell: vi.fn(() => '/home/u/.nvm/versions/node/v22/bin/codex'), + exists: vi.fn((path) => path === '/home/u/.nvm/versions/node/v22/bin/codex'), + }); + const resolver = createCliExecutableResolver({ binary: 'codex', searchDirs: ['/known/bin'] }, h); + + expect(resolver.resolve()).toMatchObject({ + binaryPath: '/home/u/.nvm/versions/node/v22/bin/codex', + directory: '/home/u/.nvm/versions/node/v22/bin', + source: 'login-shell', + }); + }); + + it('continues after a validator rejects an earlier candidate', () => { + const h = host({ + findOnProcessPath: vi.fn(() => '/usr/bin/pi'), + findInLoginShell: vi.fn(() => '/home/u/.npm/bin/pi'), + exists: vi.fn(() => true), + }); + const resolver = createCliExecutableResolver( + { + binary: 'pi', + searchDirs: [], + validateCandidate: (path) => + path.includes('.npm') ? { accepted: true, metadata: '0.84.1' } : { accepted: false }, + }, + h + ); + + expect(resolver.resolve()).toMatchObject({ + binaryPath: '/home/u/.npm/bin/pi', + source: 'login-shell', + metadata: '0.84.1', + }); + }); + + it('caches success but retries failure', () => { + const findInLoginShell = vi.fn<() => string | null>().mockReturnValueOnce(null).mockReturnValue('/new/bin/codex'); + const h = host({ findInLoginShell, exists: vi.fn((path) => path === '/new/bin/codex') }); + const resolver = createCliExecutableResolver({ binary: 'codex', searchDirs: [] }, h); + + expect(resolver.resolve()).toBeNull(); + expect(resolver.resolve()?.binaryPath).toBe('/new/bin/codex'); + expect(resolver.resolve()?.binaryPath).toBe('/new/bin/codex'); + expect(findInLoginShell).toHaveBeenCalledTimes(2); + }); + + it('rejects unsafe binary names', () => { + const h = host(); + + expect(() => createCliExecutableResolver({ binary: 'codex;id', searchDirs: [] }, h)).toThrow( + 'Unsafe CLI binary name' + ); + expect(() => createCliExecutableResolver({ binary: '../codex', searchDirs: [] }, h)).toThrow( + 'Unsafe CLI binary name' + ); + }); + + it('rejects relative and nonexistent candidates', () => { + const findInLoginShell = vi + .fn<() => string | null>() + .mockReturnValueOnce('relative/codex') + .mockReturnValue('/missing/codex'); + const h = host({ findInLoginShell, exists: vi.fn(() => false) }); + const resolver = createCliExecutableResolver({ binary: 'codex', searchDirs: [] }, h); + + expect(resolver.resolve()).toBeNull(); + expect(resolver.resolve()).toBeNull(); + expect(h.exists).toHaveBeenCalledTimes(1); + expect(h.exists).toHaveBeenCalledWith('/missing/codex'); + }); +}); + +describe('formatCliNotFoundMessage', () => { + it('includes only the base install hint and bounded resolution diagnostics', () => { + const base = 'Codex CLI not found. Install with: npm install -g @openai/codex'; + const diagnostics = { + binary: 'codex', + processPath: '/usr/bin:/bin', + shellPath: '/bin/bash', + shellArgs: ['-i', '-l'], + searchDirs: ['/home/u/.local/bin', '/usr/local/bin'], + API_KEY: 'super-secret', + }; + const message = formatCliNotFoundMessage(base, diagnostics); + + expect(message).toContain(base); + expect(message).toContain('Server PATH: /usr/bin:/bin'); + expect(message).toContain('Login shell: /bin/bash -i -l'); + expect(message).toContain('Checked directories: /home/u/.local/bin, /usr/local/bin'); + expect(message).not.toContain('API_KEY'); + expect(message).not.toContain('super-secret'); + }); + + it('marks empty diagnostic values without dumping arbitrary environment data', () => { + const message = formatCliNotFoundMessage('Missing CLI', { + binary: 'codex', + processPath: '', + shellPath: '', + shellArgs: [], + searchDirs: [], + }); + + expect(message).toBe('Missing CLI\nServer PATH: (empty)\nLogin shell: (none)\nChecked directories: (none)'); + expect(message).not.toContain('HOME='); + expect(message).not.toContain('TOKEN='); + }); + + it('flattens control characters and bounds every diagnostic field', () => { + const pathological = `first\r\nforged label: value\u0000${'x'.repeat(10_000)}`; + const message = formatCliNotFoundMessage('Missing CLI', { + binary: 'codex', + processPath: pathological, + shellPath: pathological, + shellArgs: [pathological], + searchDirs: [pathological, pathological], + }); + const lines = message.split('\n'); + + expect(lines).toHaveLength(4); + expect(lines[1]).toMatch(/^Server PATH: first forged label: value x+…$/); + expect(lines[2]).toMatch(/^Login shell: first forged label: value x+…$/); + expect(lines[3]).toMatch(/^Checked directories: first forged label: value x+…$/); + expect(lines.slice(1).every((line) => line.length <= 1_050)).toBe(true); + }); +}); + +describe('createProductionCliResolverHost', () => { + it('contains no ambient VITEST branch in the production resolver source', () => { + const source = readFileSync(new URL('../src/utils/cli-executable-resolver.ts', import.meta.url), 'utf8'); + + expect(source).not.toContain('process.env.VITEST'); + }); + + it("resolves through the injected login-shell runner when VITEST is 'false'", () => { + const hadVitest = Object.hasOwn(process.env, 'VITEST'); + const previousVitest = process.env.VITEST; + process.env.VITEST = 'false'; + try { + const runCommand = vi.fn(() => `${BEGIN_MARKER}\n/home/u/.nvm/bin/codex\n${END_MARKER}`); + const productionHost = createProductionCliResolverHost({ + processPath: '', + shellPath: '/bin/bash', + shellArgs: ['-i', '-l'], + runCommand, + isExecutableFile: () => true, + }); + const resolver = createCliExecutableResolver({ binary: 'codex', searchDirs: [] }, productionHost); + + expect(resolver.resolve()).toMatchObject({ + binaryPath: '/home/u/.nvm/bin/codex', + source: 'login-shell', + }); + expect(runCommand).toHaveBeenCalledTimes(1); + } finally { + if (hadVitest) process.env.VITEST = previousVitest; + else delete process.env.VITEST; + } + }); + + it('searches the captured process PATH directly in directory order without running a command', () => { + const runCommand = vi.fn(() => ''); + const isExecutableFile = vi.fn((path: string) => path === '/second/bin/codex'); + const productionHost = createProductionCliResolverHost({ + processPath: '/first/bin:/second/bin:/third/bin', + shellPath: '/bin/bash', + shellArgs: ['-i', '-l'], + runCommand, + isExecutableFile, + }); + + expect(productionHost.findOnProcessPath('codex')).toBe('/second/bin/codex'); + expect(isExecutableFile).toHaveBeenNthCalledWith(1, '/first/bin/codex'); + expect(isExecutableFile).toHaveBeenNthCalledWith(2, '/second/bin/codex'); + expect(runCommand).not.toHaveBeenCalled(); + }); + + it('accepts only executable regular files with the production predicate', () => { + const root = mkdtempSync(join(tmpdir(), 'codeman-cli-resolver-')); + temporaryDirectories.push(root); + const executableDirectory = join(root, 'executable'); + const plainDirectory = join(root, 'plain'); + const directoryCandidate = join(root, 'directory'); + mkdirSync(executableDirectory); + mkdirSync(plainDirectory); + mkdirSync(directoryCandidate); + writeFileSync(join(executableDirectory, 'codex'), '#!/bin/sh\n'); + chmodSync(join(executableDirectory, 'codex'), 0o755); + writeFileSync(join(plainDirectory, 'codex'), '#!/bin/sh\n'); + mkdirSync(join(directoryCandidate, 'codex')); + const productionHost = createProductionCliResolverHost({ + processPath: [directoryCandidate, plainDirectory, executableDirectory].join(':'), + shellPath: '/bin/bash', + shellArgs: ['-i', '-l'], + }); + + expect(productionHost.findOnProcessPath('codex')).toBe(join(executableDirectory, 'codex')); + }); + + it('uses the resolved shell, allowlisted args, tagged command, and bounded timeout', () => { + const runCommand = vi.fn(() => + ['/profile/absolute-noise', BEGIN_MARKER, '/home/u/.nvm/bin/codex', END_MARKER, '/exit-trap/absolute-noise'].join( + '\n' + ) + ); + const productionHost = createProductionCliResolverHost({ + processPath: '', + shellPath: '/usr/bin/fish', + shellArgs: ['-i', '-l'], + runCommand, + isExecutableFile: () => true, + }); + + expect(productionHost.findInLoginShell('codex')).toBe('/home/u/.nvm/bin/codex'); + expect(runCommand).toHaveBeenCalledWith( + '/usr/bin/fish', + ['-i', '-l', '-c', `printf '%s\\n' '${BEGIN_MARKER}'; command -v -- codex; printf '%s\\n' '${END_MARKER}'`], + { + encoding: 'utf8', + timeout: EXEC_TIMEOUT_MS, + stdio: ['ignore', 'pipe', 'ignore'], + } + ); + }); + + it.each([ + ['mismatched basename', `${BEGIN_MARKER}\n/opt/bin/not-codex\n${END_MARKER}`], + ['missing begin marker', `/opt/bin/codex\n${END_MARKER}`], + ['missing end marker', `${BEGIN_MARKER}\n/opt/bin/codex`], + ['absolute output outside markers', `/profile/codex\n${BEGIN_MARKER}\nrelative/codex\n${END_MARKER}\n/exit/codex`], + ])('rejects malformed tagged shell output: %s', (_name, output) => { + const productionHost = createProductionCliResolverHost({ + processPath: '', + shellPath: '/bin/bash', + shellArgs: ['-i', '-l'], + runCommand: () => output, + isExecutableFile: () => true, + }); + + expect(productionHost.findInLoginShell('codex')).toBeNull(); + }); + + it('returns null when the shell command throws', () => { + const productionHost = createProductionCliResolverHost({ + processPath: '', + shellPath: '/bin/bash', + shellArgs: ['-i', '-l'], + runCommand: () => { + throw new Error('exit 1'); + }, + isExecutableFile: () => true, + }); + + expect(productionHost.findInLoginShell('codex')).toBeNull(); + }); +});