Files
Codeman/src/utils/cli-executable-resolver.ts
T
Codeman maintainer 3f8c8e99d1 feat(grok): add Grok Build (xAI) as a seventh CLI run mode
SessionMode gains 'grok', a first-class backend alongside Claude Code,
shell, OpenCode, Codex, Gemini, Antigravity and Pi: its own PTY, tmux
session, charcoal tab identity ('gk' badge), welcome button, run-mode
entry, cron agentType, Docker and remote-SSH command defaults, and
clone-repo Brain option. Flag surface verified live against grok 1.0.5.

Grok mixes two existing shapes and the wiring follows from that:

- Codex-shaped on permissions: the bypass switch is GrokConfig.alwaysApprove
  (--always-approve, grok's bypassPermissions mode; config-level deny rules
  still apply on top). The Run button sends it true, like runAntigravity(),
  and clampExternalCliBypassForOwner() puts grok in the only-if-sent branch:
  a bare grok spawn is grok's own ask-mode default, which is already safe,
  so only a sent config needs the flag forced off. Cron needs nothing for
  the same reason.
- OpenCode-shaped on rendering: grok is a fullscreen alternate-screen TUI
  with mouse support, so it stays OUT of isAltScreenStripMode() and lands
  on the narrow tmux-attach strip and the 'buffer' local-echo fallthrough
  (unmeasured against an authenticated composer; documented fallback is the
  'off' branch).
- Pi-shaped on resolution: 'grok' has npm squatters (@vibe-kit/grok-cli
  also installs a grok bin), so grok-cli-resolver.ts version-probes every
  candidate (grok --version, killSignal SIGKILL, VITEST-gated) and
  GET /api/grok/status surfaces path AND version; GROK_VERSION_REGEX is
  shared with the dependency registry so doctor and run mode cannot drift.

Env allowlist gains GROK_* plus the XAI_* vendor namespace (XAI_API_KEY is
grok's documented headless auth var), the same narrow-vendor reasoning as
GOOGLE_* for gemini. Resume is id-regexed on purpose: grok's own --resume
also matches session titles, which are arbitrary user strings that must
never reach the bash -c spawn line.

Docker: grok is not on npm, so the agent image installs it in its own step
(xAI's installer has no --dir override; the binary is copied to
/usr/local/bin and root's ~/.grok dropped in the same layer), and
credentials are seeded per-file (auth.json, config.toml, pager.toml; the
dir also holds sessions/, memory/ and the ~160MB binary). Remote SSH routes
through the login-shell wrapper like the other agent CLIs.

Verified end to end on an isolated CODEMAN_INSTANCE with grok 1.0.5
installed: /api/grok/status resolves and reports the probed version,
quick-start spawns a pane whose command line ends in 'grok
--always-approve', the real TUI renders (OAuth device screen on an
unauthenticated box), and grokConfig round-trips through state.json.
Docs: docs/grok-integration.md (user guide) + docs/grok-integration-plan.md
(decisions, verification record, follow-ups).

Tests: test/grok-mode.test.ts, test/grok-cli-resolver.test.ts, plus
extended clamp/system-routes/render-index-html/run-mode-ui/mobile-overview/
local-echo-gating coverage. npm test (the CI gate) green: 5910 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 08:39:03 +02:00

302 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;
searchDirs: 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> {
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 options.searchDirs) {
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: [...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}`;
}