mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
fix(cli-resolvers): negative-result caching, SIGKILL on probes, restored VITEST hermeticity, wired not-found diagnostics
Post-merge follow-ups for PR #329 (shared CLI executable resolution): - Negative-cache resolution misses with a doubling backoff (1min -> 5min cap, cliResolveRetryDelayMs, mirroring claudeVersionRetryDelayMs): the shared resolver cached success only, so a missing CLI re-ran the whole chain - ending in a synchronous interactive login-shell spawn bounded by the 5s EXEC_TIMEOUT_MS - on every /api/<cli>/status request and Run attempt, stalling the event loop each time, forever. Success still caches for the process lifetime, so an installed CLI is picked up within minutes without a restart. Tests drive the backoff via an injectable clock (createCliExecutableResolver `now` option, threaded through the createPiResolverForTest / createAntigravityResolverForTest wrappers). - Pass killSignal: 'SIGKILL' on the resolver's login-shell spawn and on the pi/claude --version probes: execFileSync's timeout only SENDS the kill signal and then keeps waiting for the child to exit, and interactive bash ignores SIGTERM, so a login shell stuck in a blocking .bash_profile survived the timeout and blocked the server permanently. - Restore test hermeticity (PR #329 deleted pi's VITEST guards, and one test pinned the deletion): under vitest the production resolver host now replaces un-injected IO primitives with inert stubs - no real PATH scanning, no login-shell spawns - and probePiVersion never executes a `pi` candidate again (`pi` is a generic binary name, so route tests hitting /api/pi/status executed whatever binary the machine carried). Tests opt in through the runCommand/isExecutableFile injection hooks or allowRealIoUnderVitest for real-filesystem fixtures. The deletion-pinning test is replaced by behavioral pins, including a real-executable fixture in the new test/pi-cli-resolver.test.ts that fails loudly if the pi gate is ever removed again. - Wire the six get*NotFoundMessage() exports (previously dead) into their intended call sites: the createSession throws in tmux-manager and the availability gates on POST /api/sessions and POST /api/quick-start in session-routes, replacing a third hardcoded copy of the text. A not-found error now names where resolution looked (server PATH, login shell, checked directories). npm run knip no longer reports any unused export from the resolver modules. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+16
-11
@@ -75,11 +75,17 @@ import {
|
|||||||
SAFE_PATH_PATTERN,
|
SAFE_PATH_PATTERN,
|
||||||
findClaudeDir,
|
findClaudeDir,
|
||||||
getClaudeCliVersion,
|
getClaudeCliVersion,
|
||||||
|
getClaudeNotFoundMessage,
|
||||||
resolveOpenCodeDir,
|
resolveOpenCodeDir,
|
||||||
|
getOpenCodeNotFoundMessage,
|
||||||
resolveCodexDir,
|
resolveCodexDir,
|
||||||
|
getCodexNotFoundMessage,
|
||||||
resolveGeminiDir,
|
resolveGeminiDir,
|
||||||
|
getGeminiNotFoundMessage,
|
||||||
resolveAntigravityDir,
|
resolveAntigravityDir,
|
||||||
|
getAntigravityNotFoundMessage,
|
||||||
resolvePiDir,
|
resolvePiDir,
|
||||||
|
getPiNotFoundMessage,
|
||||||
resolveLocalShell,
|
resolveLocalShell,
|
||||||
loginShellArgs,
|
loginShellArgs,
|
||||||
} from './utils/index.js';
|
} from './utils/index.js';
|
||||||
@@ -1853,29 +1859,28 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve CLI binary directory based on mode
|
// Resolve CLI binary directory based on mode. The not-found messages come
|
||||||
|
// from the resolvers (formatCliNotFoundMessage) so the error names WHERE it
|
||||||
|
// looked — server PATH, login shell, checked directories — instead of just
|
||||||
|
// asserting the CLI is missing (the classic systemd/launchd PATH trap).
|
||||||
const { pathExport, dir: cliDir } = this.buildPathExport(mode);
|
const { pathExport, dir: cliDir } = this.buildPathExport(mode);
|
||||||
if (mode === 'claude' && !cliDir) {
|
if (mode === 'claude' && !cliDir) {
|
||||||
throw new Error('Claude CLI not found. Install it with: curl -fsSL https://claude.ai/install.sh | bash');
|
throw new Error(getClaudeNotFoundMessage());
|
||||||
}
|
}
|
||||||
if (mode === 'opencode' && !cliDir) {
|
if (mode === 'opencode' && !cliDir) {
|
||||||
throw new Error('OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash');
|
throw new Error(getOpenCodeNotFoundMessage());
|
||||||
}
|
}
|
||||||
if (mode === 'codex' && !cliDir) {
|
if (mode === 'codex' && !cliDir) {
|
||||||
throw new Error('Codex CLI not found. Install with: npm install -g @openai/codex');
|
throw new Error(getCodexNotFoundMessage());
|
||||||
}
|
}
|
||||||
if (mode === 'gemini' && !cliDir) {
|
if (mode === 'gemini' && !cliDir) {
|
||||||
throw new Error('Gemini CLI not found. Install with: npm install -g @google/gemini-cli');
|
throw new Error(getGeminiNotFoundMessage());
|
||||||
}
|
}
|
||||||
if (mode === 'antigravity' && !cliDir) {
|
if (mode === 'antigravity' && !cliDir) {
|
||||||
throw new Error(
|
throw new Error(getAntigravityNotFoundMessage());
|
||||||
'Antigravity CLI not found. Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (mode === 'pi' && !cliDir) {
|
if (mode === 'pi' && !cliDir) {
|
||||||
throw new Error(
|
throw new Error(getPiNotFoundMessage());
|
||||||
'Pi CLI not found. Install with: npm install -g --ignore-scripts @earendil-works/pi-coding-agent'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && ');
|
const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && ');
|
||||||
|
|||||||
@@ -28,13 +28,13 @@ const ANTIGRAVITY_SEARCH_DIRS = [
|
|||||||
const ANTIGRAVITY_NOT_FOUND =
|
const ANTIGRAVITY_NOT_FOUND =
|
||||||
'Antigravity CLI not found. Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash';
|
'Antigravity CLI not found. Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash';
|
||||||
|
|
||||||
function createAntigravityResolver(host?: CliResolverHost) {
|
function createAntigravityResolver(host?: CliResolverHost, now?: () => number) {
|
||||||
return createCliExecutableResolver({ binary: 'agy', searchDirs: ANTIGRAVITY_SEARCH_DIRS }, host);
|
return createCliExecutableResolver({ binary: 'agy', searchDirs: ANTIGRAVITY_SEARCH_DIRS, now }, host);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Creates an isolated Antigravity wrapper around an injected resolver host. */
|
/** Creates an isolated Antigravity wrapper around an injected resolver host and clock. */
|
||||||
export function createAntigravityResolverForTest(host: CliResolverHost) {
|
export function createAntigravityResolverForTest(host: CliResolverHost, now?: () => number) {
|
||||||
return createAntigravityResolver(host);
|
return createAntigravityResolver(host, now);
|
||||||
}
|
}
|
||||||
|
|
||||||
const antigravityResolver = createAntigravityResolver();
|
const antigravityResolver = createAntigravityResolver();
|
||||||
|
|||||||
@@ -177,6 +177,9 @@ function probeClaudeCliVersion(): string | null {
|
|||||||
encoding: 'utf-8',
|
encoding: 'utf-8',
|
||||||
timeout: EXEC_TIMEOUT_MS,
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
env: { ...process.env, PATH: getAugmentedPath() },
|
env: { ...process.env, PATH: getAugmentedPath() },
|
||||||
|
// execFileSync's timeout only SENDS the signal and then keeps waiting; a
|
||||||
|
// child that ignores SIGTERM would block the server thread permanently.
|
||||||
|
killSignal: 'SIGKILL',
|
||||||
});
|
});
|
||||||
const match = out.match(/(\d+\.\d+\.\d+)/);
|
const match = out.match(/(\d+\.\d+\.\d+)/);
|
||||||
return match ? match[1] : null;
|
return match ? match[1] : null;
|
||||||
|
|||||||
@@ -1,3 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Shared CLI executable resolution for the per-CLI resolvers.
|
||||||
|
*
|
||||||
|
* One lookup chain behind all six *-cli-resolver modules (claude, opencode,
|
||||||
|
* codex, gemini, antigravity, pi): the server process PATH first, then the
|
||||||
|
* CLI's common install directories in order, then — last, because it is the
|
||||||
|
* only step that spawns anything — an interactive login shell, which is what
|
||||||
|
* finds nvm/Homebrew/user-npm installs when Codeman runs as a systemd/launchd
|
||||||
|
* service with a minimal PATH (launchd hands a job `/usr/bin:/bin:/usr/sbin:/sbin`).
|
||||||
|
*
|
||||||
|
* Caching is asymmetric, same shape as `resolveClaudeCliVersion` in
|
||||||
|
* claude-cli-resolver.ts: a successful resolution is cached for the process
|
||||||
|
* lifetime, a MISS is negative-cached and retried only after a doubling backoff
|
||||||
|
* (`cliResolveRetryDelayMs`). The callers are request-facing (the per-CLI
|
||||||
|
* status endpoints in system-routes.ts, the availability gates in
|
||||||
|
* session-routes.ts, and tmux-manager's spawn path), and the login-shell probe
|
||||||
|
* is a SYNCHRONOUS spawn bounded by `EXEC_TIMEOUT_MS` — without the negative
|
||||||
|
* cache, a missing CLI re-ran the whole chain and stalled the event loop for up
|
||||||
|
* to 5s on every request, forever.
|
||||||
|
*
|
||||||
|
* Test hermeticity: under vitest (`process.env.VITEST`) the production host
|
||||||
|
* short-circuits — IO primitives that were not injected become inert stubs, so
|
||||||
|
* a suite can never scan the machine's PATH or spawn login shells (the same
|
||||||
|
* rule as `IS_TEST_MODE` in tmux-manager and the VITEST gate in
|
||||||
|
* `getClaudeCliVersion`). Tests opt back in through the injection hooks
|
||||||
|
* (`runCommand`/`isExecutableFile` fakes do no real IO by construction) or, for
|
||||||
|
* fixtures that need the real filesystem predicate against their own temp
|
||||||
|
* files, via `allowRealIoUnderVitest`.
|
||||||
|
*
|
||||||
|
* @module utils/cli-executable-resolver
|
||||||
|
*/
|
||||||
|
|
||||||
import { execFileSync } from 'node:child_process';
|
import { execFileSync } from 'node:child_process';
|
||||||
import { accessSync, constants, statSync } from 'node:fs';
|
import { accessSync, constants, statSync } from 'node:fs';
|
||||||
import { basename, delimiter, dirname, isAbsolute, join } from 'node:path';
|
import { basename, delimiter, dirname, isAbsolute, join } from 'node:path';
|
||||||
@@ -10,6 +42,26 @@ const LOGIN_SHELL_END_MARKER = '__CODEMAN_CLI_RESOLVE_END__';
|
|||||||
/** Maximum rendered length of each bounded diagnostic field, excluding its label. */
|
/** Maximum rendered length of each bounded diagnostic field, excluding its label. */
|
||||||
const DIAGNOSTIC_FIELD_MAX_LENGTH = 1024;
|
const DIAGNOSTIC_FIELD_MAX_LENGTH = 1024;
|
||||||
|
|
||||||
|
/** First retry window after a full-chain resolution miss. */
|
||||||
|
const RESOLVE_RETRY_BASE_MS = 60_000;
|
||||||
|
/**
|
||||||
|
* Ceiling for the doubling backoff. Deliberately shorter than the 15min cap on
|
||||||
|
* the claude version probe: that one is cosmetic, while this gates the Run
|
||||||
|
* flow, and "installing a CLI while the server is running is picked up without
|
||||||
|
* a restart" should stay true within minutes.
|
||||||
|
*/
|
||||||
|
const RESOLVE_RETRY_MAX_MS = 5 * 60_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long to wait before re-running the resolution chain after `failures`
|
||||||
|
* consecutive misses: 1min, 2min, 4min… capped at 5min. Mirrors
|
||||||
|
* `claudeVersionRetryDelayMs` in claude-cli-resolver.ts. Exported for tests.
|
||||||
|
*/
|
||||||
|
export function cliResolveRetryDelayMs(failures: number): number {
|
||||||
|
if (failures <= 0) return 0;
|
||||||
|
return Math.min(RESOLVE_RETRY_BASE_MS * 2 ** (failures - 1), RESOLVE_RETRY_MAX_MS);
|
||||||
|
}
|
||||||
|
|
||||||
export type CliResolutionSource = 'process-path' | 'common-directory' | 'login-shell';
|
export type CliResolutionSource = 'process-path' | 'common-directory' | 'login-shell';
|
||||||
|
|
||||||
export interface CliResolutionDiagnostics {
|
export interface CliResolutionDiagnostics {
|
||||||
@@ -50,6 +102,7 @@ export interface CliResolverCommandOptions {
|
|||||||
encoding: 'utf8';
|
encoding: 'utf8';
|
||||||
timeout: number;
|
timeout: number;
|
||||||
stdio: ['ignore', 'pipe', 'ignore'];
|
stdio: ['ignore', 'pipe', 'ignore'];
|
||||||
|
killSignal: 'SIGKILL';
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CliResolverCommandRunner = (file: string, args: string[], options: CliResolverCommandOptions) => string;
|
export type CliResolverCommandRunner = (file: string, args: string[], options: CliResolverCommandOptions) => string;
|
||||||
@@ -60,6 +113,13 @@ export interface ProductionCliResolverHostOptions {
|
|||||||
shellArgs?: string[];
|
shellArgs?: string[];
|
||||||
runCommand?: CliResolverCommandRunner;
|
runCommand?: CliResolverCommandRunner;
|
||||||
isExecutableFile?: (path: string) => boolean;
|
isExecutableFile?: (path: string) => boolean;
|
||||||
|
/**
|
||||||
|
* Test-only escape hatch: keep the REAL IO primitives even under vitest.
|
||||||
|
* For tests that exercise `isExecutableRegularFile` against their own temp
|
||||||
|
* fixtures. Such a test must still inject `runCommand` if it can reach the
|
||||||
|
* login-shell step, or it would spawn a real interactive shell.
|
||||||
|
*/
|
||||||
|
allowRealIoUnderVitest?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isExecutableRegularFile(path: string): boolean {
|
function isExecutableRegularFile(path: string): boolean {
|
||||||
@@ -97,15 +157,26 @@ export function createProductionCliResolverHost(options: ProductionCliResolverHo
|
|||||||
const shellPath = options.shellPath ?? resolveLocalShell();
|
const shellPath = options.shellPath ?? resolveLocalShell();
|
||||||
const shellArgs = options.shellArgs ?? loginShellArgs(shellPath).trim().split(/\s+/).filter(Boolean);
|
const shellArgs = options.shellArgs ?? loginShellArgs(shellPath).trim().split(/\s+/).filter(Boolean);
|
||||||
const processPath = options.processPath ?? process.env.PATH ?? '';
|
const processPath = options.processPath ?? process.env.PATH ?? '';
|
||||||
const isExecutableFile = options.isExecutableFile ?? isExecutableRegularFile;
|
// Hermeticity gate (see @fileoverview): under vitest, any IO primitive the
|
||||||
|
// caller did not inject is replaced by an inert stub. The suites must never
|
||||||
|
// depend on — or execute — whatever happens to be installed on the machine
|
||||||
|
// running them, and route tests hitting the per-CLI status endpoints would
|
||||||
|
// otherwise scan the real PATH and spawn real login shells on CI.
|
||||||
|
const inert = Boolean(process.env.VITEST) && options.allowRealIoUnderVitest !== true;
|
||||||
|
const isExecutableFile = options.isExecutableFile ?? (inert ? () => false : isExecutableRegularFile);
|
||||||
const runCommand: CliResolverCommandRunner =
|
const runCommand: CliResolverCommandRunner =
|
||||||
options.runCommand ?? ((file, args, commandOptions) => execFileSync(file, args, commandOptions));
|
options.runCommand ?? (inert ? () => '' : (file, args, commandOptions) => execFileSync(file, args, commandOptions));
|
||||||
const run = (file: string, args: string[]): string => {
|
const run = (file: string, args: string[]): string => {
|
||||||
try {
|
try {
|
||||||
return runCommand(file, args, {
|
return runCommand(file, args, {
|
||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
timeout: EXEC_TIMEOUT_MS,
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
stdio: ['ignore', 'pipe', 'ignore'],
|
stdio: ['ignore', 'pipe', 'ignore'],
|
||||||
|
// SIGKILL is load-bearing: execFileSync's `timeout` only SENDS the kill
|
||||||
|
// signal and then keeps waiting for the child to exit. Interactive bash
|
||||||
|
// ignores SIGTERM (the default), so a login shell stuck in a blocking
|
||||||
|
// .bash_profile would survive the timeout and block the server forever.
|
||||||
|
killSignal: 'SIGKILL',
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
return '';
|
return '';
|
||||||
@@ -138,6 +209,8 @@ export function createCliExecutableResolver<T = undefined>(
|
|||||||
binary: string;
|
binary: string;
|
||||||
searchDirs: string[];
|
searchDirs: string[];
|
||||||
validateCandidate?: (path: string) => CandidateValidation<T>;
|
validateCandidate?: (path: string) => CandidateValidation<T>;
|
||||||
|
/** Clock injection for tests driving the failure backoff. Defaults to `Date.now`. */
|
||||||
|
now?: () => number;
|
||||||
},
|
},
|
||||||
host: CliResolverHost = createProductionCliResolverHost()
|
host: CliResolverHost = createProductionCliResolverHost()
|
||||||
): CliExecutableResolver<T> {
|
): CliExecutableResolver<T> {
|
||||||
@@ -145,7 +218,13 @@ export function createCliExecutableResolver<T = undefined>(
|
|||||||
throw new Error(`Unsafe CLI binary name: ${options.binary}`);
|
throw new Error(`Unsafe CLI binary name: ${options.binary}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const now = options.now ?? Date.now;
|
||||||
|
/** Successful resolution, cached for the process lifetime. */
|
||||||
let cached: CliResolution<T> | null = null;
|
let cached: CliResolution<T> | null = null;
|
||||||
|
/** Consecutive full-chain misses (drives the retry backoff). */
|
||||||
|
let failures = 0;
|
||||||
|
/** Timestamp of the most recent miss. */
|
||||||
|
let lastFailureAt = 0;
|
||||||
const accept = (path: string | null, source: CliResolutionSource): CliResolution<T> | null => {
|
const accept = (path: string | null, source: CliResolutionSource): CliResolution<T> | null => {
|
||||||
if (!path || !isAbsolute(path) || !host.exists(path)) return null;
|
if (!path || !isAbsolute(path) || !host.exists(path)) return null;
|
||||||
const validation = options.validateCandidate?.(path) ?? ({ accepted: true } as CandidateValidation<T>);
|
const validation = options.validateCandidate?.(path) ?? ({ accepted: true } as CandidateValidation<T>);
|
||||||
@@ -161,17 +240,31 @@ export function createCliExecutableResolver<T = undefined>(
|
|||||||
return {
|
return {
|
||||||
resolve() {
|
resolve() {
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
// Negative cache: a miss is remembered and the chain — whose login-shell
|
||||||
|
// tail is a synchronous 5s-bounded spawn — is not re-run until the
|
||||||
|
// backoff elapses. Without this, every status poll and Run click against
|
||||||
|
// a missing CLI froze the event loop for the full probe, forever.
|
||||||
|
if (failures > 0 && now() - lastFailureAt < cliResolveRetryDelayMs(failures)) return null;
|
||||||
|
|
||||||
cached = accept(host.findOnProcessPath(options.binary), 'process-path');
|
cached = accept(host.findOnProcessPath(options.binary), 'process-path');
|
||||||
if (cached) return cached;
|
if (!cached) {
|
||||||
|
for (const dir of options.searchDirs) {
|
||||||
for (const dir of options.searchDirs) {
|
cached = accept(join(dir, options.binary), 'common-directory');
|
||||||
cached = accept(join(dir, options.binary), 'common-directory');
|
if (cached) break;
|
||||||
if (cached) return cached;
|
}
|
||||||
|
}
|
||||||
|
if (!cached) {
|
||||||
|
cached = accept(host.findInLoginShell(options.binary), 'login-shell');
|
||||||
}
|
}
|
||||||
|
|
||||||
cached = accept(host.findInLoginShell(options.binary), 'login-shell');
|
if (cached) {
|
||||||
return cached;
|
failures = 0;
|
||||||
|
lastFailureAt = 0;
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
failures += 1;
|
||||||
|
lastFailureAt = now();
|
||||||
|
return null;
|
||||||
},
|
},
|
||||||
diagnostics: () => ({
|
diagnostics: () => ({
|
||||||
binary: options.binary,
|
binary: options.binary,
|
||||||
|
|||||||
+16
-6
@@ -28,12 +28,22 @@ export { stringSimilarity, fuzzyPhraseMatch, todoContentHash } from './string-si
|
|||||||
export { assertNever } from './type-safety.js';
|
export { assertNever } from './type-safety.js';
|
||||||
export { wrapWithNice } from './nice-wrapper.js';
|
export { wrapWithNice } from './nice-wrapper.js';
|
||||||
export { resolveLocalShell, loginShellArgs } from './shell-resolver.js';
|
export { resolveLocalShell, loginShellArgs } from './shell-resolver.js';
|
||||||
export { findClaudeDir, getAugmentedPath, getClaudeCliVersion, getClaudeBinaryPath } from './claude-cli-resolver.js';
|
export {
|
||||||
|
findClaudeDir,
|
||||||
|
getAugmentedPath,
|
||||||
|
getClaudeCliVersion,
|
||||||
|
getClaudeBinaryPath,
|
||||||
|
getClaudeNotFoundMessage,
|
||||||
|
} from './claude-cli-resolver.js';
|
||||||
export { spawnPtyWithHelperRepair } from './node-pty-repair.js';
|
export { spawnPtyWithHelperRepair } from './node-pty-repair.js';
|
||||||
export { resolveOpenCodeDir } from './opencode-cli-resolver.js';
|
export { resolveOpenCodeDir, getOpenCodeNotFoundMessage } from './opencode-cli-resolver.js';
|
||||||
export { resolveCodexDir, isCodexAvailable } from './codex-cli-resolver.js';
|
export { resolveCodexDir, isCodexAvailable, getCodexNotFoundMessage } from './codex-cli-resolver.js';
|
||||||
export { resolveGeminiDir, isGeminiAvailable } from './gemini-cli-resolver.js';
|
export { resolveGeminiDir, isGeminiAvailable, getGeminiNotFoundMessage } from './gemini-cli-resolver.js';
|
||||||
export { resolveAntigravityDir, isAntigravityAvailable } from './antigravity-cli-resolver.js';
|
export {
|
||||||
export { resolvePiDir, isPiAvailable, getPiCliVersion } from './pi-cli-resolver.js';
|
resolveAntigravityDir,
|
||||||
|
isAntigravityAvailable,
|
||||||
|
getAntigravityNotFoundMessage,
|
||||||
|
} from './antigravity-cli-resolver.js';
|
||||||
|
export { resolvePiDir, isPiAvailable, getPiCliVersion, getPiNotFoundMessage } from './pi-cli-resolver.js';
|
||||||
export { compileFileQuery, matchFileQuery } from './file-query.js';
|
export { compileFileQuery, matchFileQuery } from './file-query.js';
|
||||||
export type { FileQueryMatcher } from './file-query.js';
|
export type { FileQueryMatcher } from './file-query.js';
|
||||||
|
|||||||
@@ -56,13 +56,25 @@ const PI_NOT_FOUND = 'Pi CLI not found. Install with: npm install -g --ignore-sc
|
|||||||
* looks like the coding agent. Returns null for anything else — a missing
|
* 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
|
* 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).
|
* (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 — and since `pi` is a short
|
||||||
|
* GENERIC name, this probe would EXECUTE whatever binary of that name the
|
||||||
|
* machine carries. The shared resolver host is already inert under vitest, so
|
||||||
|
* this gate is defense in depth for any opted-in host that still carries the
|
||||||
|
* default probe; tests drive resolution via `createPiResolverForTest`, whose
|
||||||
|
* injected probe bypasses it. Pinned by test/pi-cli-resolver.test.ts.
|
||||||
*/
|
*/
|
||||||
function probePiVersion(binPath: string): string | null {
|
function probePiVersion(binPath: string): string | null {
|
||||||
|
if (process.env.VITEST) return null;
|
||||||
try {
|
try {
|
||||||
const out = execFileSync(binPath, ['--version'], {
|
const out = execFileSync(binPath, ['--version'], {
|
||||||
encoding: 'utf-8',
|
encoding: 'utf-8',
|
||||||
timeout: EXEC_TIMEOUT_MS,
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
stdio: ['ignore', 'pipe', 'ignore'],
|
stdio: ['ignore', 'pipe', 'ignore'],
|
||||||
|
// A stuck or hostile `pi` that ignores SIGTERM would survive the timeout
|
||||||
|
// and block the server (execFileSync keeps waiting after the signal).
|
||||||
|
killSignal: 'SIGKILL',
|
||||||
}).trim();
|
}).trim();
|
||||||
// Upstream prints a bare version today; tolerate a `pi 0.84.1` style prefix too.
|
// Upstream prints a bare version today; tolerate a `pi 0.84.1` style prefix too.
|
||||||
const candidate = PI_VERSION_REGEX.exec(out)?.[1];
|
const candidate = PI_VERSION_REGEX.exec(out)?.[1];
|
||||||
@@ -76,7 +88,7 @@ function probePiVersion(binPath: string): string | null {
|
|||||||
|
|
||||||
type PiVersionProbe = (binPath: string) => string | null;
|
type PiVersionProbe = (binPath: string) => string | null;
|
||||||
|
|
||||||
function createPiResolver(host?: CliResolverHost, versionProbe: PiVersionProbe = probePiVersion) {
|
function createPiResolver(host?: CliResolverHost, versionProbe: PiVersionProbe = probePiVersion, now?: () => number) {
|
||||||
return createCliExecutableResolver<string>(
|
return createCliExecutableResolver<string>(
|
||||||
{
|
{
|
||||||
binary: 'pi',
|
binary: 'pi',
|
||||||
@@ -85,14 +97,19 @@ function createPiResolver(host?: CliResolverHost, versionProbe: PiVersionProbe =
|
|||||||
const version = versionProbe(binPath);
|
const version = versionProbe(binPath);
|
||||||
return version ? { accepted: true, metadata: version } : { accepted: false };
|
return version ? { accepted: true, metadata: version } : { accepted: false };
|
||||||
},
|
},
|
||||||
|
now,
|
||||||
},
|
},
|
||||||
host
|
host
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Creates an isolated Pi wrapper around an injected host and version probe. */
|
/**
|
||||||
export function createPiResolverForTest(host: CliResolverHost, versionProbe: PiVersionProbe) {
|
* Creates an isolated Pi wrapper around an injected host, version probe and
|
||||||
return createPiResolver(host, versionProbe);
|
* clock. Omitting `versionProbe` keeps the ambient (VITEST-gated) probe, which
|
||||||
|
* is exactly what the hermeticity test exercises.
|
||||||
|
*/
|
||||||
|
export function createPiResolverForTest(host: CliResolverHost, versionProbe?: PiVersionProbe, now?: () => number) {
|
||||||
|
return createPiResolver(host, versionProbe ?? probePiVersion, now);
|
||||||
}
|
}
|
||||||
|
|
||||||
const piResolver = createPiResolver();
|
const piResolver = createPiResolver();
|
||||||
|
|||||||
@@ -802,54 +802,42 @@ export function registerSessionRoutes(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check OpenCode availability if requested
|
// Check OpenCode availability if requested. The error text comes from the
|
||||||
|
// resolver (formatCliNotFoundMessage) so it names where resolution looked —
|
||||||
|
// server PATH, login shell, common directories — same for the modes below.
|
||||||
if (body.mode === 'opencode') {
|
if (body.mode === 'opencode') {
|
||||||
const { isOpenCodeAvailable } = await import('../../utils/opencode-cli-resolver.js');
|
const { isOpenCodeAvailable, getOpenCodeNotFoundMessage } = await import('../../utils/opencode-cli-resolver.js');
|
||||||
if (!isOpenCodeAvailable()) {
|
if (!isOpenCodeAvailable()) {
|
||||||
return createErrorResponse(
|
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getOpenCodeNotFoundMessage());
|
||||||
ApiErrorCode.OPERATION_FAILED,
|
|
||||||
'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Codex availability if requested
|
// Check Codex availability if requested
|
||||||
if (body.mode === 'codex') {
|
if (body.mode === 'codex') {
|
||||||
const { isCodexAvailable } = await import('../../utils/codex-cli-resolver.js');
|
const { isCodexAvailable, getCodexNotFoundMessage } = await import('../../utils/codex-cli-resolver.js');
|
||||||
if (!isCodexAvailable()) {
|
if (!isCodexAvailable()) {
|
||||||
return createErrorResponse(
|
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getCodexNotFoundMessage());
|
||||||
ApiErrorCode.OPERATION_FAILED,
|
|
||||||
'Codex CLI not found. Install with: npm install -g @openai/codex'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Gemini availability if requested
|
// Check Gemini availability if requested
|
||||||
if (body.mode === 'gemini') {
|
if (body.mode === 'gemini') {
|
||||||
const { isGeminiAvailable } = await import('../../utils/gemini-cli-resolver.js');
|
const { isGeminiAvailable, getGeminiNotFoundMessage } = await import('../../utils/gemini-cli-resolver.js');
|
||||||
if (!isGeminiAvailable()) {
|
if (!isGeminiAvailable()) {
|
||||||
return createErrorResponse(
|
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getGeminiNotFoundMessage());
|
||||||
ApiErrorCode.OPERATION_FAILED,
|
|
||||||
'Gemini CLI not found. Install with: npm install -g @google/gemini-cli'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (body.mode === 'antigravity') {
|
if (body.mode === 'antigravity') {
|
||||||
const { isAntigravityAvailable } = await import('../../utils/antigravity-cli-resolver.js');
|
const { isAntigravityAvailable, getAntigravityNotFoundMessage } =
|
||||||
|
await import('../../utils/antigravity-cli-resolver.js');
|
||||||
if (!isAntigravityAvailable()) {
|
if (!isAntigravityAvailable()) {
|
||||||
return createErrorResponse(
|
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getAntigravityNotFoundMessage());
|
||||||
ApiErrorCode.OPERATION_FAILED,
|
|
||||||
'Antigravity CLI not found. Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (body.mode === 'pi') {
|
if (body.mode === 'pi') {
|
||||||
const { isPiAvailable } = await import('../../utils/pi-cli-resolver.js');
|
const { isPiAvailable, getPiNotFoundMessage } = await import('../../utils/pi-cli-resolver.js');
|
||||||
if (!isPiAvailable()) {
|
if (!isPiAvailable()) {
|
||||||
return createErrorResponse(
|
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getPiNotFoundMessage());
|
||||||
ApiErrorCode.OPERATION_FAILED,
|
|
||||||
'Pi CLI not found. Install with: npm install -g --ignore-scripts @earendil-works/pi-coding-agent'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2827,58 +2815,46 @@ export function registerSessionRoutes(
|
|||||||
dockerResumeId = dockerCase.lastClaudeSessionId;
|
dockerResumeId = dockerCase.lastClaudeSessionId;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Check OpenCode availability if requested
|
// Check OpenCode availability if requested. Error text comes from the
|
||||||
|
// resolver so it carries the resolution diagnostics; same for the modes below.
|
||||||
if (mode === 'opencode') {
|
if (mode === 'opencode') {
|
||||||
const { isOpenCodeAvailable } = await import('../../utils/opencode-cli-resolver.js');
|
const { isOpenCodeAvailable, getOpenCodeNotFoundMessage } =
|
||||||
|
await import('../../utils/opencode-cli-resolver.js');
|
||||||
if (!isOpenCodeAvailable()) {
|
if (!isOpenCodeAvailable()) {
|
||||||
return createErrorResponse(
|
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getOpenCodeNotFoundMessage());
|
||||||
ApiErrorCode.OPERATION_FAILED,
|
|
||||||
'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Codex availability if requested
|
// Check Codex availability if requested
|
||||||
if (mode === 'codex') {
|
if (mode === 'codex') {
|
||||||
const { isCodexAvailable } = await import('../../utils/codex-cli-resolver.js');
|
const { isCodexAvailable, getCodexNotFoundMessage } = await import('../../utils/codex-cli-resolver.js');
|
||||||
if (!isCodexAvailable()) {
|
if (!isCodexAvailable()) {
|
||||||
return createErrorResponse(
|
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getCodexNotFoundMessage());
|
||||||
ApiErrorCode.OPERATION_FAILED,
|
|
||||||
'Codex CLI not found. Install with: npm install -g @openai/codex'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Gemini availability if requested
|
// Check Gemini availability if requested
|
||||||
if (mode === 'gemini') {
|
if (mode === 'gemini') {
|
||||||
const { isGeminiAvailable } = await import('../../utils/gemini-cli-resolver.js');
|
const { isGeminiAvailable, getGeminiNotFoundMessage } = await import('../../utils/gemini-cli-resolver.js');
|
||||||
if (!isGeminiAvailable()) {
|
if (!isGeminiAvailable()) {
|
||||||
return createErrorResponse(
|
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getGeminiNotFoundMessage());
|
||||||
ApiErrorCode.OPERATION_FAILED,
|
|
||||||
'Gemini CLI not found. Install with: npm install -g @google/gemini-cli'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Antigravity availability if requested
|
// Check Antigravity availability if requested
|
||||||
if (mode === 'antigravity') {
|
if (mode === 'antigravity') {
|
||||||
const { isAntigravityAvailable } = await import('../../utils/antigravity-cli-resolver.js');
|
const { isAntigravityAvailable, getAntigravityNotFoundMessage } =
|
||||||
|
await import('../../utils/antigravity-cli-resolver.js');
|
||||||
if (!isAntigravityAvailable()) {
|
if (!isAntigravityAvailable()) {
|
||||||
return createErrorResponse(
|
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getAntigravityNotFoundMessage());
|
||||||
ApiErrorCode.OPERATION_FAILED,
|
|
||||||
'Antigravity CLI not found. Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Pi availability if requested
|
// Check Pi availability if requested
|
||||||
if (mode === 'pi') {
|
if (mode === 'pi') {
|
||||||
const { isPiAvailable } = await import('../../utils/pi-cli-resolver.js');
|
const { isPiAvailable, getPiNotFoundMessage } = await import('../../utils/pi-cli-resolver.js');
|
||||||
if (!isPiAvailable()) {
|
if (!isPiAvailable()) {
|
||||||
return createErrorResponse(
|
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getPiNotFoundMessage());
|
||||||
ApiErrorCode.OPERATION_FAILED,
|
|
||||||
'Pi CLI not found. Install with: npm install -g --ignore-scripts @earendil-works/pi-coding-agent'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ import { homedir } from 'node:os';
|
|||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { createAntigravityResolverForTest, isAntigravityAvailable } from '../src/utils/antigravity-cli-resolver.js';
|
import { createAntigravityResolverForTest, isAntigravityAvailable } from '../src/utils/antigravity-cli-resolver.js';
|
||||||
import type { CliResolution, CliResolverHost } from '../src/utils/cli-executable-resolver.js';
|
import {
|
||||||
|
cliResolveRetryDelayMs,
|
||||||
|
type CliResolution,
|
||||||
|
type CliResolverHost,
|
||||||
|
} from '../src/utils/cli-executable-resolver.js';
|
||||||
|
|
||||||
const availabilityResolution = vi.hoisted(() => ({ current: null as CliResolution | null }));
|
const availabilityResolution = vi.hoisted(() => ({ current: null as CliResolution | null }));
|
||||||
|
|
||||||
@@ -84,13 +88,19 @@ describe('Antigravity CLI resolver', () => {
|
|||||||
expect(resolver.resolve()).toBeNull();
|
expect(resolver.resolve()).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('retries a failed lookup and caches the first successful login-shell discovery', () => {
|
it('retries a failed lookup after the backoff and caches the first successful login-shell discovery', () => {
|
||||||
const binaryPath = '/late-login-shell/bin/agy';
|
const binaryPath = '/late-login-shell/bin/agy';
|
||||||
|
let now = 0;
|
||||||
const resolver = createAntigravityResolverForTest(
|
const resolver = createAntigravityResolverForTest(
|
||||||
createHost({ loginShellResults: [null, binaryPath], existingPaths: [binaryPath] })
|
createHost({ loginShellResults: [null, binaryPath], existingPaths: [binaryPath] }),
|
||||||
|
() => now
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(resolver.resolve()).toBeNull();
|
expect(resolver.resolve()).toBeNull();
|
||||||
|
// A miss is negative-cached: within the backoff window nothing re-runs the
|
||||||
|
// chain (its login-shell tail is a synchronous bounded spawn in production).
|
||||||
|
expect(resolver.resolve()).toBeNull();
|
||||||
|
now = cliResolveRetryDelayMs(1);
|
||||||
expect(resolver.resolve()?.binaryPath).toBe(binaryPath);
|
expect(resolver.resolve()?.binaryPath).toBe(binaryPath);
|
||||||
expect(resolver.resolve()?.binaryPath).toBe(binaryPath);
|
expect(resolver.resolve()?.binaryPath).toBe(binaryPath);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,15 +1,25 @@
|
|||||||
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { EXEC_TIMEOUT_MS } from '../src/config/exec-timeout.js';
|
import { EXEC_TIMEOUT_MS } from '../src/config/exec-timeout.js';
|
||||||
import {
|
import {
|
||||||
|
cliResolveRetryDelayMs,
|
||||||
createCliExecutableResolver,
|
createCliExecutableResolver,
|
||||||
createProductionCliResolverHost,
|
createProductionCliResolverHost,
|
||||||
formatCliNotFoundMessage,
|
formatCliNotFoundMessage,
|
||||||
type CliResolverHost,
|
type CliResolverHost,
|
||||||
} from '../src/utils/cli-executable-resolver.js';
|
} from '../src/utils/cli-executable-resolver.js';
|
||||||
|
|
||||||
|
// Pass-through spy on execFileSync so the vitest-hermeticity test below can
|
||||||
|
// PROVE the un-injected production host never spawns anything.
|
||||||
|
const { execFileSyncSpy } = vi.hoisted(() => ({ execFileSyncSpy: vi.fn() }));
|
||||||
|
vi.mock('node:child_process', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('node:child_process')>();
|
||||||
|
execFileSyncSpy.mockImplementation(actual.execFileSync as (...args: unknown[]) => unknown);
|
||||||
|
return { ...actual, execFileSync: execFileSyncSpy };
|
||||||
|
});
|
||||||
|
|
||||||
const BEGIN_MARKER = '__CODEMAN_CLI_RESOLVE_BEGIN__';
|
const BEGIN_MARKER = '__CODEMAN_CLI_RESOLVE_BEGIN__';
|
||||||
const END_MARKER = '__CODEMAN_CLI_RESOLVE_END__';
|
const END_MARKER = '__CODEMAN_CLI_RESOLVE_END__';
|
||||||
|
|
||||||
@@ -97,17 +107,56 @@ describe('createCliExecutableResolver', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('caches success but retries failure', () => {
|
it('caches success, and retries a miss only after the backoff elapses', () => {
|
||||||
|
let now = 0;
|
||||||
const findInLoginShell = vi.fn<() => string | null>().mockReturnValueOnce(null).mockReturnValue('/new/bin/codex');
|
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 h = host({ findInLoginShell, exists: vi.fn((path) => path === '/new/bin/codex') });
|
||||||
const resolver = createCliExecutableResolver({ binary: 'codex', searchDirs: [] }, h);
|
const resolver = createCliExecutableResolver({ binary: 'codex', searchDirs: [], now: () => now }, h);
|
||||||
|
|
||||||
expect(resolver.resolve()).toBeNull();
|
expect(resolver.resolve()).toBeNull();
|
||||||
|
// Within the backoff window the miss is answered from the negative cache:
|
||||||
|
// the chain — whose login-shell tail is a synchronous 5s-bounded spawn —
|
||||||
|
// must NOT re-run per call, or a missing CLI stalls every status request.
|
||||||
|
now = cliResolveRetryDelayMs(1) - 1;
|
||||||
|
expect(resolver.resolve()).toBeNull();
|
||||||
|
expect(findInLoginShell).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Once the backoff elapses the retry runs, so installing a CLI while the
|
||||||
|
// server is up is still picked up without a restart.
|
||||||
|
now = cliResolveRetryDelayMs(1);
|
||||||
expect(resolver.resolve()?.binaryPath).toBe('/new/bin/codex');
|
expect(resolver.resolve()?.binaryPath).toBe('/new/bin/codex');
|
||||||
expect(resolver.resolve()?.binaryPath).toBe('/new/bin/codex');
|
expect(resolver.resolve()?.binaryPath).toBe('/new/bin/codex');
|
||||||
expect(findInLoginShell).toHaveBeenCalledTimes(2);
|
expect(findInLoginShell).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('doubles the retry delay per consecutive miss and caps it at five minutes', () => {
|
||||||
|
let now = 0;
|
||||||
|
const findInLoginShell = vi.fn(() => null);
|
||||||
|
const resolver = createCliExecutableResolver(
|
||||||
|
{ binary: 'codex', searchDirs: [], now: () => now },
|
||||||
|
host({ findInLoginShell })
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(cliResolveRetryDelayMs(0)).toBe(0);
|
||||||
|
expect(cliResolveRetryDelayMs(1)).toBe(60_000);
|
||||||
|
expect(cliResolveRetryDelayMs(2)).toBe(120_000);
|
||||||
|
expect(cliResolveRetryDelayMs(3)).toBe(240_000);
|
||||||
|
expect(cliResolveRetryDelayMs(4)).toBe(300_000);
|
||||||
|
expect(cliResolveRetryDelayMs(60)).toBe(300_000);
|
||||||
|
|
||||||
|
// Consecutive misses stack: after the second miss the SECOND delay applies.
|
||||||
|
expect(resolver.resolve()).toBeNull();
|
||||||
|
now += cliResolveRetryDelayMs(1);
|
||||||
|
expect(resolver.resolve()).toBeNull();
|
||||||
|
expect(findInLoginShell).toHaveBeenCalledTimes(2);
|
||||||
|
now += cliResolveRetryDelayMs(2) - 1;
|
||||||
|
expect(resolver.resolve()).toBeNull();
|
||||||
|
expect(findInLoginShell).toHaveBeenCalledTimes(2);
|
||||||
|
now += 1;
|
||||||
|
expect(resolver.resolve()).toBeNull();
|
||||||
|
expect(findInLoginShell).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects unsafe binary names', () => {
|
it('rejects unsafe binary names', () => {
|
||||||
const h = host();
|
const h = host();
|
||||||
|
|
||||||
@@ -120,14 +169,16 @@ describe('createCliExecutableResolver', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('rejects relative and nonexistent candidates', () => {
|
it('rejects relative and nonexistent candidates', () => {
|
||||||
|
let now = 0;
|
||||||
const findInLoginShell = vi
|
const findInLoginShell = vi
|
||||||
.fn<() => string | null>()
|
.fn<() => string | null>()
|
||||||
.mockReturnValueOnce('relative/codex')
|
.mockReturnValueOnce('relative/codex')
|
||||||
.mockReturnValue('/missing/codex');
|
.mockReturnValue('/missing/codex');
|
||||||
const h = host({ findInLoginShell, exists: vi.fn(() => false) });
|
const h = host({ findInLoginShell, exists: vi.fn(() => false) });
|
||||||
const resolver = createCliExecutableResolver({ binary: 'codex', searchDirs: [] }, h);
|
const resolver = createCliExecutableResolver({ binary: 'codex', searchDirs: [], now: () => now }, h);
|
||||||
|
|
||||||
expect(resolver.resolve()).toBeNull();
|
expect(resolver.resolve()).toBeNull();
|
||||||
|
now = cliResolveRetryDelayMs(1);
|
||||||
expect(resolver.resolve()).toBeNull();
|
expect(resolver.resolve()).toBeNull();
|
||||||
expect(h.exists).toHaveBeenCalledTimes(1);
|
expect(h.exists).toHaveBeenCalledTimes(1);
|
||||||
expect(h.exists).toHaveBeenCalledWith('/missing/codex');
|
expect(h.exists).toHaveBeenCalledWith('/missing/codex');
|
||||||
@@ -189,36 +240,58 @@ describe('formatCliNotFoundMessage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('createProductionCliResolverHost', () => {
|
describe('createProductionCliResolverHost', () => {
|
||||||
it('contains no ambient VITEST branch in the production resolver source', () => {
|
// Hermeticity gate (the guards PR #329 deleted, restored shared): under
|
||||||
const source = readFileSync(new URL('../src/utils/cli-executable-resolver.ts', import.meta.url), 'utf8');
|
// vitest an un-injected host must neither scan the machine nor spawn a login
|
||||||
|
// shell — route tests hitting the per-CLI status endpoints would otherwise
|
||||||
|
// walk the real PATH and execute real binaries on whatever box runs the suite.
|
||||||
|
it('never scans the machine or spawns a login shell under vitest without injected IO', () => {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'codeman-cli-vitest-gate-'));
|
||||||
|
temporaryDirectories.push(root);
|
||||||
|
writeFileSync(join(root, 'codex'), '#!/bin/sh\n');
|
||||||
|
chmodSync(join(root, 'codex'), 0o755);
|
||||||
|
execFileSyncSpy.mockClear();
|
||||||
|
|
||||||
expect(source).not.toContain('process.env.VITEST');
|
const gatedHost = createProductionCliResolverHost({
|
||||||
|
processPath: root,
|
||||||
|
shellPath: '/bin/bash',
|
||||||
|
shellArgs: ['-i', '-l'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// The real, executable candidate is invisible: the filesystem predicate is inert.
|
||||||
|
expect(gatedHost.findOnProcessPath('codex')).toBeNull();
|
||||||
|
expect(gatedHost.exists(join(root, 'codex'))).toBe(false);
|
||||||
|
// The login-shell step yields nothing and never reaches execFileSync.
|
||||||
|
expect(gatedHost.findInLoginShell('codex')).toBeNull();
|
||||||
|
expect(execFileSyncSpy).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// The same fixture through the test-only real-IO opt-in IS found, proving
|
||||||
|
// the nulls above come from the vitest gate rather than from the fixture.
|
||||||
|
const optedInHost = createProductionCliResolverHost({
|
||||||
|
processPath: root,
|
||||||
|
shellPath: '/bin/bash',
|
||||||
|
shellArgs: ['-i', '-l'],
|
||||||
|
runCommand: () => '',
|
||||||
|
allowRealIoUnderVitest: true,
|
||||||
|
});
|
||||||
|
expect(optedInHost.findOnProcessPath('codex')).toBe(join(root, 'codex'));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resolves through the injected login-shell runner when VITEST is 'false'", () => {
|
it('resolves through injected IO hooks under vitest (injection is the opt-in)', () => {
|
||||||
const hadVitest = Object.hasOwn(process.env, 'VITEST');
|
const runCommand = vi.fn(() => `${BEGIN_MARKER}\n/home/u/.nvm/bin/codex\n${END_MARKER}`);
|
||||||
const previousVitest = process.env.VITEST;
|
const productionHost = createProductionCliResolverHost({
|
||||||
process.env.VITEST = 'false';
|
processPath: '',
|
||||||
try {
|
shellPath: '/bin/bash',
|
||||||
const runCommand = vi.fn(() => `${BEGIN_MARKER}\n/home/u/.nvm/bin/codex\n${END_MARKER}`);
|
shellArgs: ['-i', '-l'],
|
||||||
const productionHost = createProductionCliResolverHost({
|
runCommand,
|
||||||
processPath: '',
|
isExecutableFile: () => true,
|
||||||
shellPath: '/bin/bash',
|
});
|
||||||
shellArgs: ['-i', '-l'],
|
const resolver = createCliExecutableResolver({ binary: 'codex', searchDirs: [] }, productionHost);
|
||||||
runCommand,
|
|
||||||
isExecutableFile: () => true,
|
|
||||||
});
|
|
||||||
const resolver = createCliExecutableResolver({ binary: 'codex', searchDirs: [] }, productionHost);
|
|
||||||
|
|
||||||
expect(resolver.resolve()).toMatchObject({
|
expect(resolver.resolve()).toMatchObject({
|
||||||
binaryPath: '/home/u/.nvm/bin/codex',
|
binaryPath: '/home/u/.nvm/bin/codex',
|
||||||
source: 'login-shell',
|
source: 'login-shell',
|
||||||
});
|
});
|
||||||
expect(runCommand).toHaveBeenCalledTimes(1);
|
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', () => {
|
it('searches the captured process PATH directly in directory order without running a command', () => {
|
||||||
@@ -255,6 +328,11 @@ describe('createProductionCliResolverHost', () => {
|
|||||||
processPath: [directoryCandidate, plainDirectory, executableDirectory].join(':'),
|
processPath: [directoryCandidate, plainDirectory, executableDirectory].join(':'),
|
||||||
shellPath: '/bin/bash',
|
shellPath: '/bin/bash',
|
||||||
shellArgs: ['-i', '-l'],
|
shellArgs: ['-i', '-l'],
|
||||||
|
// This test exists to exercise the REAL executable-regular-file predicate
|
||||||
|
// against its own temp fixtures, so it opts out of the vitest inert-IO
|
||||||
|
// gate; the stubbed runCommand keeps the login-shell path inert anyway.
|
||||||
|
runCommand: () => '',
|
||||||
|
allowRealIoUnderVitest: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(productionHost.findOnProcessPath('codex')).toBe(join(executableDirectory, 'codex'));
|
expect(productionHost.findOnProcessPath('codex')).toBe(join(executableDirectory, 'codex'));
|
||||||
@@ -282,6 +360,9 @@ describe('createProductionCliResolverHost', () => {
|
|||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
timeout: EXEC_TIMEOUT_MS,
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
stdio: ['ignore', 'pipe', 'ignore'],
|
stdio: ['ignore', 'pipe', 'ignore'],
|
||||||
|
// SIGKILL is load-bearing: interactive bash ignores SIGTERM, and
|
||||||
|
// execFileSync's timeout only sends the signal, then keeps waiting.
|
||||||
|
killSignal: 'SIGKILL',
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Tests for the Pi CLI resolver wrapper.
|
||||||
|
*
|
||||||
|
* Pi is the resolver with a version probe: `pi` is a short, generic binary
|
||||||
|
* name, so a resolved path is only accepted once `pi --version` prints a
|
||||||
|
* semver-shaped string. The probe EXECUTES the candidate, which is exactly why
|
||||||
|
* it must never run under vitest — the hermeticity test below pins that gate
|
||||||
|
* with a real executable fixture that would make the test fail loudly if the
|
||||||
|
* gate were deleted again (as PR #329 once did).
|
||||||
|
*/
|
||||||
|
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { createPiResolverForTest } from '../src/utils/pi-cli-resolver.js';
|
||||||
|
import {
|
||||||
|
cliResolveRetryDelayMs,
|
||||||
|
createProductionCliResolverHost,
|
||||||
|
type CliResolverHost,
|
||||||
|
} from '../src/utils/cli-executable-resolver.js';
|
||||||
|
|
||||||
|
const temporaryDirectories: string[] = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const directory of temporaryDirectories.splice(0)) {
|
||||||
|
rmSync(directory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function createHost(
|
||||||
|
options: {
|
||||||
|
processPathResult?: string | null;
|
||||||
|
loginShellResults?: Array<string | null>;
|
||||||
|
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('Pi CLI resolver', () => {
|
||||||
|
it('accepts a candidate the version probe verifies and carries the version as metadata', () => {
|
||||||
|
const binaryPath = '/service/bin/pi';
|
||||||
|
const probe = vi.fn(() => '0.84.1');
|
||||||
|
const resolver = createPiResolverForTest(
|
||||||
|
createHost({ processPathResult: binaryPath, existingPaths: [binaryPath] }),
|
||||||
|
probe
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolver.resolve()).toMatchObject({
|
||||||
|
binaryPath,
|
||||||
|
directory: '/service/bin',
|
||||||
|
source: 'process-path',
|
||||||
|
metadata: '0.84.1',
|
||||||
|
});
|
||||||
|
expect(probe).toHaveBeenCalledWith(binaryPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a candidate the probe refuses and falls through to a later one', () => {
|
||||||
|
// An unrelated `pi` on the service PATH (probe returns null) must not mask
|
||||||
|
// the real coding agent found by the login shell.
|
||||||
|
const impostor = '/service/bin/pi';
|
||||||
|
const genuine = '/login-shell/bin/pi';
|
||||||
|
const probe = vi.fn((binPath: string) => (binPath === genuine ? '0.84.1' : null));
|
||||||
|
const resolver = createPiResolverForTest(
|
||||||
|
createHost({
|
||||||
|
processPathResult: impostor,
|
||||||
|
loginShellResults: [genuine],
|
||||||
|
existingPaths: [impostor, genuine],
|
||||||
|
}),
|
||||||
|
probe
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolver.resolve()).toMatchObject({ binaryPath: genuine, source: 'login-shell', metadata: '0.84.1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('negative-caches a miss and retries only after the backoff elapses', () => {
|
||||||
|
const binaryPath = '/late/bin/pi';
|
||||||
|
let now = 0;
|
||||||
|
const probe = vi.fn(() => '0.84.1');
|
||||||
|
const resolver = createPiResolverForTest(
|
||||||
|
createHost({ loginShellResults: [null, binaryPath], existingPaths: [binaryPath] }),
|
||||||
|
probe,
|
||||||
|
() => now
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolver.resolve()).toBeNull();
|
||||||
|
expect(resolver.resolve()).toBeNull(); // within the backoff: no re-run
|
||||||
|
expect(probe).not.toHaveBeenCalled();
|
||||||
|
now = cliResolveRetryDelayMs(1);
|
||||||
|
expect(resolver.resolve()?.metadata).toBe('0.84.1');
|
||||||
|
expect(resolver.resolve()?.binaryPath).toBe(binaryPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never executes a pi candidate under vitest (the ambient probe is VITEST-gated)', () => {
|
||||||
|
// A REAL executable fixture that prints a valid version. If the guard in
|
||||||
|
// probePiVersion is ever removed again, the probe runs this script, the
|
||||||
|
// resolution SUCCEEDS, and this test fails — pinning hermeticity by
|
||||||
|
// behavior rather than by source text. (The suites must never execute
|
||||||
|
// whatever `pi` binary the machine running them happens to carry.)
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'codeman-pi-vitest-gate-'));
|
||||||
|
temporaryDirectories.push(root);
|
||||||
|
const binaryPath = join(root, 'pi');
|
||||||
|
writeFileSync(binaryPath, '#!/bin/sh\necho 0.99.0\n');
|
||||||
|
chmodSync(binaryPath, 0o755);
|
||||||
|
const hostOptions = {
|
||||||
|
processPath: root,
|
||||||
|
shellPath: '/bin/bash',
|
||||||
|
shellArgs: ['-i', '-l'] as string[],
|
||||||
|
runCommand: () => '',
|
||||||
|
isExecutableFile: (path: string) => path === binaryPath,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Default (ambient) probe: the candidate is found but never executed, so
|
||||||
|
// the VITEST gate reports it unusable and resolution misses.
|
||||||
|
const gated = createPiResolverForTest(createProductionCliResolverHost(hostOptions));
|
||||||
|
expect(gated.resolve()).toBeNull();
|
||||||
|
|
||||||
|
// Control: identical setup with an injected probe resolves, proving the
|
||||||
|
// null above comes from the gate, not from the fixture or the host.
|
||||||
|
const control = createPiResolverForTest(createProductionCliResolverHost(hostOptions), () => '0.99.0');
|
||||||
|
expect(control.resolve()).toMatchObject({ binaryPath, metadata: '0.99.0' });
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user