mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 13:39:41 +02:00
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>
207 lines
7.8 KiB
TypeScript
207 lines
7.8 KiB
TypeScript
/**
|
|
* @fileoverview Shared Claude CLI binary resolution.
|
|
*
|
|
* Finds the `claude` binary across common installation paths and provides
|
|
* an augmented PATH string. Used by session.ts and tmux-manager.ts
|
|
* to locate the Claude CLI.
|
|
*
|
|
* @module utils/claude-cli-resolver
|
|
*/
|
|
|
|
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 = [
|
|
join(homedir(), '.local', 'bin'),
|
|
join(homedir(), '.claude', 'local'),
|
|
'/usr/local/bin',
|
|
join(homedir(), '.npm-global', 'bin'),
|
|
join(homedir(), 'bin'),
|
|
];
|
|
|
|
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
|
|
* the common install directories). Mirrors `isGeminiAvailable`/`isAntigravityAvailable`/`isOpenCodeAvailable`/
|
|
* `isCodexAvailable` in the sibling resolvers.
|
|
*/
|
|
export function isClaudeAvailable(): boolean {
|
|
return findClaudeDir() !== null;
|
|
}
|
|
|
|
/**
|
|
* Finds the directory containing the `claude` binary.
|
|
* Checks `which claude` first, then falls back to common install locations.
|
|
* Result is cached for subsequent calls.
|
|
*
|
|
* @returns Directory path, or null if not found
|
|
*/
|
|
export function findClaudeDir(): string | null {
|
|
return claudeResolver.resolve()?.directory ?? null;
|
|
}
|
|
|
|
export function getClaudeNotFoundMessage(): string {
|
|
return formatCliNotFoundMessage(CLAUDE_NOT_FOUND, claudeResolver.diagnostics());
|
|
}
|
|
|
|
/**
|
|
* Returns an absolute path to the `claude` binary, falling back to the bare
|
|
* name `'claude'` when it cannot be located (so PATH resolution still gets a
|
|
* chance).
|
|
*
|
|
* Preferred over passing `'claude'` to `pty.spawn()`: a PTY child resolves the
|
|
* command against the environment it is handed, and an install that lives in
|
|
* `~/.local/bin` or `~/.claude/local` is frequently absent from the PATH the
|
|
* server process inherited (issue #6).
|
|
*/
|
|
export function getClaudeBinaryPath(): string {
|
|
const dir = findClaudeDir();
|
|
return dir ? join(dir, 'claude') : 'claude';
|
|
}
|
|
|
|
/** Cached augmented PATH string */
|
|
let _augmentedPath: string | null = null;
|
|
|
|
/**
|
|
* Returns a PATH string that includes the directory containing `claude`.
|
|
*
|
|
* Finds the claude binary (via `which` or common install locations), then
|
|
* prepends its directory to the current PATH if not already present.
|
|
* Result is cached for subsequent calls.
|
|
*/
|
|
export function getAugmentedPath(): string {
|
|
if (_augmentedPath) return _augmentedPath;
|
|
|
|
const currentPath = process.env.PATH || '';
|
|
const claudeDir = findClaudeDir();
|
|
|
|
if (!claudeDir) return currentPath;
|
|
|
|
if (!currentPath.split(delimiter).includes(claudeDir)) {
|
|
_augmentedPath = `${claudeDir}${delimiter}${currentPath}`;
|
|
return _augmentedPath;
|
|
}
|
|
|
|
_augmentedPath = currentPath;
|
|
return _augmentedPath;
|
|
}
|
|
|
|
/**
|
|
* Cache state for the `claude --version` probe.
|
|
*
|
|
* `version` is only ever set from a SUCCESSFUL probe and then kept for the
|
|
* process lifetime (the binary can't change under a running server without a
|
|
* restart). Failures are tracked separately so they expire.
|
|
*/
|
|
export interface ClaudeVersionProbeState {
|
|
/** Successful probe result; `undefined` until one succeeds. */
|
|
version?: string;
|
|
/** Consecutive failed probes (drives the retry backoff). */
|
|
failures: number;
|
|
/** Timestamp of the most recent failed probe. */
|
|
lastFailureAt: number;
|
|
}
|
|
|
|
/** First retry window after a failed probe. */
|
|
const VERSION_PROBE_BASE_RETRY_MS = 60_000;
|
|
/** Ceiling for the doubling backoff, so a permanently missing binary settles down. */
|
|
const VERSION_PROBE_MAX_RETRY_MS = 15 * 60_000;
|
|
|
|
/**
|
|
* How long to wait before re-probing after `failures` consecutive failures:
|
|
* 1min, 2min, 4min… capped at 15min. Exported for tests.
|
|
*/
|
|
export function claudeVersionRetryDelayMs(failures: number): number {
|
|
if (failures <= 0) return 0;
|
|
return Math.min(VERSION_PROBE_BASE_RETRY_MS * 2 ** (failures - 1), VERSION_PROBE_MAX_RETRY_MS);
|
|
}
|
|
|
|
/**
|
|
* Cache policy for the version probe, pure apart from the `state` it mutates
|
|
* and the injected `probe` (exported so tests can drive it with a fake clock).
|
|
*
|
|
* Success is cached forever; FAILURE is not. That asymmetry is the fix for a
|
|
* real shipped bug: the old cache stored `null` on any exception and guarded on
|
|
* `!== undefined`, so a single failed probe — a 5s `EXEC_TIMEOUT_MS` timeout, a
|
|
* PATH-starved systemd/launchd environment, a transient fs hiccup — at the FIRST
|
|
* Claude session start left `cliVersion` undefined for EVERY Claude session
|
|
* until the server restarted. An undefined `cliVersion` silently disables
|
|
* wheel-forwarding to Claude's own transcript (`_shouldForwardWheelToApp`),
|
|
* which is the only route to history in repaint mode: a dead wheel on every
|
|
* device at once, matching the issue #205 retest reports.
|
|
*
|
|
* Retries back off so a genuinely absent binary still can't spawn a probe per
|
|
* session start.
|
|
*/
|
|
export function resolveClaudeCliVersion(
|
|
state: ClaudeVersionProbeState,
|
|
now: number,
|
|
probe: () => string | null
|
|
): string | null {
|
|
if (state.version !== undefined) return state.version;
|
|
if (state.failures > 0 && now - state.lastFailureAt < claudeVersionRetryDelayMs(state.failures)) return null;
|
|
|
|
let version: string | null = null;
|
|
try {
|
|
version = probe();
|
|
} catch {
|
|
version = null;
|
|
}
|
|
|
|
if (version) {
|
|
state.version = version;
|
|
state.failures = 0;
|
|
state.lastFailureAt = 0;
|
|
return version;
|
|
}
|
|
state.failures += 1;
|
|
state.lastFailureAt = now;
|
|
return null;
|
|
}
|
|
|
|
const _claudeVersionState: ClaudeVersionProbeState = { failures: 0, lastFailureAt: 0 };
|
|
|
|
/** One `claude --version` run. Throws on spawn/timeout failure. */
|
|
function probeClaudeCliVersion(): string | null {
|
|
const dir = findClaudeDir();
|
|
const bin = dir ? join(dir, 'claude') : 'claude';
|
|
// execFileSync (no shell) — the resolved path may contain spaces, and there
|
|
// is no untrusted input, but avoid a shell either way.
|
|
const out = execFileSync(bin, ['--version'], {
|
|
encoding: 'utf-8',
|
|
timeout: EXEC_TIMEOUT_MS,
|
|
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+)/);
|
|
return match ? match[1] : null;
|
|
}
|
|
|
|
/**
|
|
* Returns the installed Claude CLI version (e.g. `"2.1.210"`), or null if it
|
|
* can't be determined. Runs `claude --version` at most once per successful
|
|
* resolution; failed probes retry with backoff (see `resolveClaudeCliVersion`).
|
|
*
|
|
* This is a deterministic alternative to scraping the interactive startup
|
|
* banner (`parseClaudeCodeInfo` in session.ts): newer Claude Code builds don't
|
|
* reliably print `Claude Code vX.Y.Z` at startup, and resumed sessions never
|
|
* show it, which left `cliVersion` undefined and silently disabled features
|
|
* gated on it (e.g. wheel-forwarding to Claude's transcript — issue #154).
|
|
*/
|
|
export function getClaudeCliVersion(): string | null {
|
|
// Keep the test suite hermetic — never spawn a real `claude` subprocess under
|
|
// vitest (matches IS_TEST_MODE in tmux-manager). Tests that need a version set
|
|
// it on the session directly. Deliberately does NOT touch the cache state:
|
|
// recording a phantom failure here would be the very poisoning this fixes.
|
|
if (process.env.VITEST) return null;
|
|
return resolveClaudeCliVersion(_claudeVersionState, Date.now(), probeClaudeCliVersion);
|
|
}
|