Files
Codeman/src/utils/cli-executable-resolver.ts
T
DevvynandClaude Opus 5 4830e662f9 refactor(cli-registry): make CLI backends data instead of per-mode branching
Every run mode is now a `CliEntry` in `src/config/cli-registry/` — discovery
(search dirs, version + identity probes), the launch argv template, env
handling, the `capabilities` flags that replace per-CLI branching, and the
`overlays` that back the remote/docker pane commands. Code that used to ask
"which CLI is this?" reads the entry instead.

Behaviour is unchanged. `test/cli-registry-spawn-golden.test.ts` pins every
spawn command as a literal string, captured from the hand-written builders
before they were deleted, and `test/location-overlay-commands.test.ts` does the
same for all 20 remote and in-container pane commands.

Config can never contain shell text: an entry declares typed argv tokens,
literals are validated against a safe-word pattern at LOAD time (a bad literal
rejects the whole entry — a silently dropped `--no-approve` is not cosmetic),
and values resolve through patterns NAMED in code, so a user `clis.json` cannot
widen its own validation. `~/.codeman/clis.json` overrides any entry, read-only
in this release.

OMP is included as a registry entry rather than a tenth hand-written builder,
so `buildOmpCommand()`, the omp availability pre-flight, the omp arm of
`buildPathExport()` and the omp entries in the truecolor/NO_COLOR, alt-screen
and doctor ladders all drop out.

Guard rails:

- `test/cli-registry-no-id-branching.test.ts` fails the build if per-CLI-id
  branching reappears outside `stock.ts`, in any of its four shapes (`===`,
  `!==`, `switch`/`case`, `includes`) — an `===`-only version would miss the
  negated forms, which is how 36 of them survived an earlier pass. Every
  allowlisted branch carries its reason.
- `external`, `hooks` and `altScreen` stay three INDEPENDENT capabilities;
  deriving one from another shipped the `until=stop`-hangs-on-shell bug.
- `param` is two namespaces. `launch.params` keys, `configSetenv.fromParam` and
  `privilegedParams[].param` all name a LAUNCH param; the legacy `<Mode>Config`
  wire field is separate, bridged only by `legacyConfigAliases`. Getting
  `privilegedParams[].param` wrong is SILENT — it is the multi-user bypass
  clamp's only handle on a CLI's privilege switch, and a wrong name clamps
  nothing with no error and no failing test — so `schema.ts` rejects an entry
  naming a param it never declared.
- Registry data resolves AT CALL TIME (`sessionModeSchema()`,
  `allowedEnvPrefixes()`, `dependencyRegistry()`, the resolvers' `searchDirs`
  thunks). A module-level const freezes at first import, so a CLI enabled while
  the server ran moved the run menu but not that surface.
- Six fields are annotated DECLARED-FOR-LATER and read by nothing
  (`shortBadge`, `accent`, `capabilities.echo`/`wheelForward`/
  `keyboardAccessory`/`maxFrameBytes`): all frontend behaviour, transcribed
  rather than measured. A test pins the list so it cannot quietly grow.

Three user-visible changes, all deliberate and named:

- `probeDockerCliVersion()` derives the in-container binary from the registry
  rather than assuming it equals the mode name (`antigravity` runs `agy`).
- The remote CLI version probe now covers grok and deepseek, which the
  hardcoded map it replaces omitted while its own comment said the rule was
  "every mode except shell".
- `codeman doctor`'s CLI rows are generated from the entries, so Claude's
  install hint is the install command rather than a docs URL, five CLIs gain
  hints they never had, and the row order follows the catalog.

Also hardened along the way: `sessionModeSchema()` is bounded at 24 chars
(matching the `cliId` pattern) before its failure message quotes the value
back, and `deepMerge` skips `__proto__`/`constructor`/`prototype` when reading
the hand-editable `clis.json`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQkoi1cNegqVwZHgzx5SbJ
2026-09-02 08:26:45 +08:00

312 lines
12 KiB
TypeScript

/**
* @fileoverview Shared CLI executable resolution for the per-CLI resolvers.
*
* One lookup chain behind all seven *-cli-resolver modules (claude, opencode,
* codex, gemini, antigravity, pi, grok): 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 { 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;
/** 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 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<T> {
accepted: boolean;
metadata?: T;
}
export interface CliResolution<T = undefined> {
binaryPath: string;
directory: string;
source: CliResolutionSource;
metadata?: T;
}
export interface CliExecutableResolver<T = undefined> {
resolve(): CliResolution<T> | null;
diagnostics(): CliResolutionDiagnostics;
}
export interface CliResolverCommandOptions {
encoding: 'utf8';
timeout: number;
stdio: ['ignore', 'pipe', 'ignore'];
killSignal: 'SIGKILL';
}
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;
/**
* 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 {
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 ?? '';
// 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 =
options.runCommand ?? (inert ? () => '' : (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'],
// 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 {
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<T = undefined>(
options: {
binary: string;
/**
* Where to look after the process PATH. A THUNK is accepted alongside an array so a
* caller sourcing its dirs from the CLI registry can defer the lookup: passing
* `searchDirs: FOO_SEARCH_DIRS()` evaluates at module import, which froze the dirs
* before a user `clis.json` or a `reloadCliRegistry()` could be seen. Resolved on each
* probe and each `diagnostics()` call — a handful of string ops, and only when a probe
* actually runs.
*/
searchDirs: string[] | (() => string[]);
validateCandidate?: (path: string) => CandidateValidation<T>;
/** Clock injection for tests driving the failure backoff. Defaults to `Date.now`. */
now?: () => number;
},
host: CliResolverHost = createProductionCliResolverHost()
): CliExecutableResolver<T> {
const resolveSearchDirs = (): string[] =>
typeof options.searchDirs === 'function' ? options.searchDirs() : options.searchDirs;
if (!SAFE_BINARY_NAME.test(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;
/** 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 => {
if (!path || !isAbsolute(path) || !host.exists(path)) return null;
const validation = options.validateCandidate?.(path) ?? ({ accepted: true } as CandidateValidation<T>);
if (!validation.accepted) return null;
return {
binaryPath: path,
directory: dirname(path),
source,
metadata: validation.metadata,
};
};
return {
resolve() {
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');
if (!cached) {
for (const dir of resolveSearchDirs()) {
cached = accept(join(dir, options.binary), 'common-directory');
if (cached) break;
}
}
if (!cached) {
cached = accept(host.findInLoginShell(options.binary), 'login-shell');
}
if (cached) {
failures = 0;
lastFailureAt = 0;
return cached;
}
failures += 1;
lastFailureAt = now();
return null;
},
diagnostics: () => ({
binary: options.binary,
processPath: host.processPath,
shellPath: host.shellPath,
shellArgs: [...host.shellArgs],
searchDirs: [...resolveSearchDirs()],
}),
};
}
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}`;
}