mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
76453add71
* refactor: pnpm workspace + @agent-device/kernel pilot (#1490 W0) Extend the workspace with packages/* and move the kernel behind an enforced public API: packages/kernel with nine consumer-earned subpath exports (errors, device, snapshot, contracts, collections, rect, redaction, daemon-error, bounds — the last absorbed from utils as Rect vocabulary). Every kernel import repo-wide becomes the @agent-device/kernel/<sub> specifier; kernel tests move to src/__tests__/kernel/ and exercise the package surface. The root declares the package in devDependencies (workspace:*), tsdown bundles it (noExternal) so the published artifact and its runtime dependency manifest are unchanged. Gate rewiring in the same change, per the W0 brief: - R1 kernel-sink retires (physically subsumed); new R11 package-boundaries guards no-root-back-imports, relative tunnelling past exports maps, undeclared workspace deps, and non-exported subpaths, with runtime resolution pins via import.meta.resolve. - resolveImportEdges and mutation ownership follow workspace specifiers through exports maps, keeping R4 cycle checks, depgraph, and derived test ownership connected across the seam (kernel-errors still owns 495 tests). listSourceFiles includes packages/*/src. - kernel becomes an unranked zone; mutation registry, stryker mutate globs, and the mutation-affected workflow path filter move to packages/kernel/src/errors.ts. - check:affected gains packages/ ownership (manifests fail open); vitest and coverage include packages/*/src; fallow ignores packages/** (its resolver cannot follow workspace specifiers). - The affected-selector CI job installs dependencies: its closure now crosses workspace specifiers, and the R8 relative exception is unsafe for production src files (Node ESM does not realpath, so dual specifier/relative loads would instantiate modules twice). The R8 zero-dep set is pinned empty with that rationale. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FUv7bvbWNryuXgSBuqTtep * fix: address W0 review — mutation sandbox, exports-map resolution, tsc -b Review findings on #1494, all five: 1. contracts-schema-public.test.ts reads the kernel source at its packages/ path (fs access invisible to the codemod and typecheck). 2. Mutation lane: Stryker sandboxes the tree but pnpm's node_modules symlink resolves @agent-device/* back to the real repo, so mutants in the sandbox never load and vitest.related finds no tests. vitest.mutation.config.ts now aliases each EXPORTED specifier to its source (derived from exports maps, never a wildcard), keeping resolution inside the mutated tree. Validated: kernel-errors module runs end to end (dry run 3,984 tests, mutants killed, exit 0). 3. Layering/depgraph resolve workspace specifiers through the exports-derived map (workspaceSpecifierTargets) instead of reconstructing paths, so '.'-facade packages resolve; the positional fallback remains only for map-less fixtures (P0 pin). 4. Per-package project references implemented: packages/kernel is composite (emitDeclarationOnly -> dist-types, gitignored), the root references it, and typecheck becomes tsc -b — probed to catch type errors on both sides under TypeScript 7 native. 5. R11's relative-route exception now requires membership in an actual R8 zero-dep job closure (zeroDepClosureFiles walks entries), not mere scripts/ placement — closing the dual-instantiation bypass. Also from review discussion: daemon-error moves out of the kernel package to src/client/ — its consumers (cli, client facade) rehydrate wire DaemonErrors client-side; the daemon only produces them. Kernel drops to 8 exported subpaths before any of them ship. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FUv7bvbWNryuXgSBuqTtep * refactor: one exports-map reader for mutation alias and ownership Fallow flagged workspaceExportAliases (cognitive 15, CRAP 90). The manifest-reading logic already exists as workspaceSpecifierTargets in scripts/layering/package-boundaries.ts, so both the Stryker sandbox alias table and the mutation ownership walker now consume it instead of carrying near-clones. Behavior unchanged; mutation suite 45/45 and changed-code fallow green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FUv7bvbWNryuXgSBuqTtep * fix: composite kernel without a root references edge FreeRange runs plain `tsc -p tsconfig.json`, and a root `references` entry makes non-build-mode TypeScript demand the referenced project's built declarations (TS6305) — a standing "build first" tax on every plain -p consumer (fr, editors). Keep the per-package composite project and build it in typecheck (`tsc -b packages/kernel` before the root and examples/sdk passes), but drop the root references edge: root consumption resolves through exports to source, identical to runtime and to the bundler. Probed: plain -p green with no prebuilt output; kernel-side type errors still caught by its own build. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FUv7bvbWNryuXgSBuqTtep * fix: R11 uses the layering parser; mutation config is a fallow entry Review blockers on #1494: - R11's private single-quote regex could miss a double-quoted or re-export route into packages/*/src. specifierSites now delegates to the layering model's parseImports (both quote styles, side-effect imports, re-exports, dynamic imports), with direct regressions for each formerly-invisible form. - vitest.mutation.config.ts becomes a declared fallow entry instead of a tolerated unused-file finding: the full-repo audit now reports it reachable (unused files 2 -> 1; the remainder predates this PR). FreeRange clean-checkout evidence: with packages/kernel/dist-types and every *.tsbuildinfo deleted, `pnpm check:freerange` reports 0 findings on this head — the TS6305 topology died with the root references edge in the previous commit; check:freerange has no build precondition. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FUv7bvbWNryuXgSBuqTtep --------- Co-authored-by: Claude <noreply@anthropic.com>
813 lines
24 KiB
TypeScript
813 lines
24 KiB
TypeScript
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
import { constants } from 'node:fs';
|
|
import { access, stat } from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import { spawn, spawnSync, type ChildProcess, type StdioOptions } from 'node:child_process';
|
|
import { Readable } from 'node:stream';
|
|
import { pipeline } from 'node:stream/promises';
|
|
import { AppError } from '@agent-device/kernel/errors';
|
|
import { emitDiagnostic, getDiagnosticsMeta, updateDiagnosticsScope } from './diagnostics.ts';
|
|
import { parseBooleanLiteral } from './source-value.ts';
|
|
|
|
export type ExecResult = {
|
|
stdout: string;
|
|
stderr: string;
|
|
exitCode: number;
|
|
stdoutBuffer?: Buffer;
|
|
};
|
|
|
|
export type ExecOptions = {
|
|
cwd?: string;
|
|
env?: NodeJS.ProcessEnv;
|
|
allowFailure?: boolean;
|
|
binaryStdout?: boolean;
|
|
stdin?: string | Buffer;
|
|
timeoutMs?: number;
|
|
detached?: boolean;
|
|
signal?: AbortSignal;
|
|
/** Max stdout/stderr bytes for synchronous runs (default Node ~1MB). */
|
|
maxBuffer?: number;
|
|
};
|
|
|
|
type ExecStreamOptions = ExecOptions & {
|
|
onStdoutChunk?: (chunk: string) => void;
|
|
onStderrChunk?: (chunk: string) => void;
|
|
onSpawn?: (child: ReturnType<typeof spawn>) => void;
|
|
};
|
|
|
|
export type ExecBackgroundResult = {
|
|
child: ReturnType<typeof spawn>;
|
|
wait: Promise<ExecResult>;
|
|
};
|
|
|
|
type ExecDetachedOptions = ExecOptions & {
|
|
stdio?: StdioOptions;
|
|
};
|
|
|
|
export type ExecDetachedExit = {
|
|
pid: number;
|
|
exitCode?: number;
|
|
signal?: NodeJS.Signals;
|
|
error?: string;
|
|
};
|
|
|
|
export type ExecDetachedProcess = {
|
|
pid: number;
|
|
exited: Promise<ExecDetachedExit>;
|
|
};
|
|
|
|
export type ExecBackgroundOptions = ExecOptions & {
|
|
/**
|
|
* Capture stdout/stderr into the wait result when the child has piped stdio.
|
|
* Set false when the caller owns, ignores, or forwards the streams.
|
|
*/
|
|
captureOutput?: boolean;
|
|
stdio?: StdioOptions;
|
|
};
|
|
|
|
const BARE_COMMAND_RE = /^[A-Za-z0-9][A-Za-z0-9._+-]*$/;
|
|
const WINDOWS_PATH_EXTENSIONS = ['.com', '.exe', '.bat', '.cmd'];
|
|
const EXEC_DIAGNOSTIC_ARG_LIMIT = 6;
|
|
export type CommandExecutorOverride = (
|
|
cmd: string,
|
|
args: string[],
|
|
options: ExecOptions,
|
|
) => Promise<ExecResult> | undefined;
|
|
|
|
const commandExecutorOverrideScope = new AsyncLocalStorage<CommandExecutorOverride | undefined>();
|
|
|
|
export async function withCommandExecutorOverride<T>(
|
|
override: CommandExecutorOverride | undefined,
|
|
fn: () => Promise<T>,
|
|
): Promise<T> {
|
|
if (!override) return await fn();
|
|
return await commandExecutorOverrideScope.run(override, fn);
|
|
}
|
|
|
|
// Used by local executors to bypass the active override for intentional host command execution.
|
|
export async function withoutCommandExecutorOverride<T>(fn: () => Promise<T>): Promise<T> {
|
|
return await commandExecutorOverrideScope.run(undefined, fn);
|
|
}
|
|
|
|
export async function runCmd(
|
|
cmd: string,
|
|
args: string[],
|
|
options: ExecOptions = {},
|
|
): Promise<ExecResult> {
|
|
const overrideResult = commandExecutorOverrideScope.getStore()?.(cmd, args, options);
|
|
if (overrideResult) return coerceExecResult(await overrideResult);
|
|
return await runSpawnedCommand(cmd, args, options);
|
|
}
|
|
|
|
export async function runCmdStreaming(
|
|
cmd: string,
|
|
args: string[],
|
|
options: ExecStreamOptions = {},
|
|
): Promise<ExecResult> {
|
|
const overrideResult = commandExecutorOverrideScope.getStore()?.(cmd, args, options);
|
|
if (overrideResult) return coerceExecResult(await overrideResult);
|
|
return await runSpawnedCommand(cmd, args, options);
|
|
}
|
|
|
|
/**
|
|
* Normalize an exec result produced outside this module. Tool providers,
|
|
* executor overrides, and SDK-supplied adb executors are plain callbacks whose
|
|
* results cross an unchecked boundary; coercing once here lets downstream code
|
|
* trust the ExecResult types instead of re-wrapping fields defensively at every
|
|
* use. A non-number exitCode coerces to 1 — the same failure branch such a
|
|
* result already landed in at every `exitCode !== 0` guard.
|
|
*/
|
|
export function coerceExecResult<T extends Pick<ExecResult, 'stdout' | 'stderr' | 'exitCode'>>(
|
|
result: T,
|
|
): T {
|
|
const stdout = typeof result.stdout === 'string' ? result.stdout : String(result.stdout ?? '');
|
|
const stderr = typeof result.stderr === 'string' ? result.stderr : String(result.stderr ?? '');
|
|
const exitCode = typeof result.exitCode === 'number' ? result.exitCode : 1;
|
|
if (stdout === result.stdout && stderr === result.stderr && exitCode === result.exitCode) {
|
|
return result;
|
|
}
|
|
return { ...result, stdout, stderr, exitCode };
|
|
}
|
|
|
|
function runSpawnedCommand(
|
|
cmd: string,
|
|
args: string[],
|
|
options: ExecStreamOptions = {},
|
|
): Promise<ExecResult> {
|
|
const executable = normalizeExecutableCommand(cmd);
|
|
const execTrace = createExecTraceContext();
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn(executable, args, {
|
|
cwd: options.cwd,
|
|
env: options.env,
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
detached: options.detached,
|
|
windowsHide: true,
|
|
shell: false,
|
|
});
|
|
options.onSpawn?.(child);
|
|
|
|
let stdout = '';
|
|
const stdoutChunks: Buffer[] | undefined = options.binaryStdout ? [] : undefined;
|
|
let stderr = '';
|
|
let didTimeout = false;
|
|
const timeoutMs = normalizeTimeoutMs(options.timeoutMs);
|
|
const timeoutHandle = timeoutMs
|
|
? setTimeout(() => {
|
|
didTimeout = true;
|
|
killProcessTree(child, options.detached);
|
|
}, timeoutMs)
|
|
: null;
|
|
const abort = watchCommandAbort(child, options);
|
|
|
|
if (!options.binaryStdout) child.stdout.setEncoding('utf8');
|
|
child.stderr.setEncoding('utf8');
|
|
|
|
void writeChildStdin(child, options.stdin).catch((err: unknown) => {
|
|
if (abort.didAbort || didTimeout) return;
|
|
if (isEpipeError(err)) return;
|
|
reject(createStdinError(executable, cmd, args, err));
|
|
killProcessTree(child, options.detached);
|
|
});
|
|
|
|
child.stdout.on('data', (chunk) => {
|
|
if (options.binaryStdout) {
|
|
stdoutChunks?.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
return;
|
|
}
|
|
const text = String(chunk);
|
|
stdout += text;
|
|
options.onStdoutChunk?.(text);
|
|
});
|
|
|
|
child.stderr.on('data', (chunk) => {
|
|
const text = String(chunk);
|
|
stderr += text;
|
|
options.onStderrChunk?.(text);
|
|
});
|
|
|
|
child.on('error', (err) => {
|
|
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
abort.dispose();
|
|
execTrace.emitForegroundCompletion(cmd, args);
|
|
reject(spawnRejectionError(abort, executable, cmd, args, err));
|
|
});
|
|
|
|
child.on('close', (code) => {
|
|
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
abort.dispose();
|
|
execTrace.emitForegroundCompletion(cmd, args);
|
|
const exitCode = code ?? 1;
|
|
if (!abort.didAbort && didTimeout && timeoutMs) {
|
|
reject(createTimeoutError(executable, cmd, args, timeoutMs, exitCode, stdout, stderr));
|
|
return;
|
|
}
|
|
const failure = commandCloseFailure(
|
|
abort,
|
|
executable,
|
|
cmd,
|
|
args,
|
|
exitCode,
|
|
options.allowFailure,
|
|
stdout,
|
|
stderr,
|
|
);
|
|
if (failure) {
|
|
reject(failure);
|
|
return;
|
|
}
|
|
resolve({
|
|
stdout,
|
|
stderr,
|
|
exitCode,
|
|
stdoutBuffer: stdoutChunks ? Buffer.concat(stdoutChunks) : undefined,
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
export async function whichCmd(cmd: string): Promise<boolean> {
|
|
const candidate = normalizeExecutableLookup(cmd);
|
|
if (!candidate) return false;
|
|
|
|
if (path.isAbsolute(candidate)) {
|
|
return isExecutablePath(candidate);
|
|
}
|
|
|
|
const pathValue = process.env.PATH;
|
|
if (!pathValue) return false;
|
|
const pathExtensions = resolvePathExtensions();
|
|
for (const directory of pathValue.split(path.delimiter)) {
|
|
const trimmedDirectory = directory.trim();
|
|
if (!trimmedDirectory) continue;
|
|
for (const entry of resolveExecutableCandidates(candidate, pathExtensions)) {
|
|
if (await isExecutablePath(path.join(trimmedDirectory, entry))) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
export async function resolveExecutableOverridePath(
|
|
rawPath: string | undefined,
|
|
envName: string,
|
|
): Promise<string | undefined> {
|
|
const candidate = normalizeOverridePath(rawPath, envName, 'executable');
|
|
if (!candidate) return undefined;
|
|
if (!(await isExecutablePath(candidate))) {
|
|
throw new AppError(
|
|
'TOOL_MISSING',
|
|
`${envName} points to a missing or non-executable file: ${candidate}`,
|
|
{ envName, path: candidate },
|
|
);
|
|
}
|
|
return candidate;
|
|
}
|
|
|
|
export async function resolveFileOverridePath(
|
|
rawPath: string | undefined,
|
|
envName: string,
|
|
): Promise<string | undefined> {
|
|
const candidate = normalizeOverridePath(rawPath, envName, 'file');
|
|
if (!candidate) return undefined;
|
|
if (!(await isFilePath(candidate))) {
|
|
throw new AppError(
|
|
'TOOL_MISSING',
|
|
`${envName} points to a missing or non-file path: ${candidate}`,
|
|
{ envName, path: candidate },
|
|
);
|
|
}
|
|
return candidate;
|
|
}
|
|
|
|
export function runCmdSync(cmd: string, args: string[], options: ExecOptions = {}): ExecResult {
|
|
const executable = normalizeExecutableCommand(cmd);
|
|
const result = spawnSync(executable, args, {
|
|
cwd: options.cwd,
|
|
env: options.env,
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
encoding: options.binaryStdout ? undefined : 'utf8',
|
|
input: options.stdin,
|
|
timeout: normalizeTimeoutMs(options.timeoutMs),
|
|
windowsHide: true,
|
|
shell: false,
|
|
...(options.maxBuffer !== undefined ? { maxBuffer: options.maxBuffer } : {}),
|
|
});
|
|
|
|
if (result.error) {
|
|
const code = (result.error as NodeJS.ErrnoException).code;
|
|
if (code === 'ETIMEDOUT') {
|
|
throw new AppError(
|
|
'COMMAND_FAILED',
|
|
`${executable} timed out after ${normalizeTimeoutMs(options.timeoutMs)}ms`,
|
|
{
|
|
cmd,
|
|
args,
|
|
timeoutMs: normalizeTimeoutMs(options.timeoutMs),
|
|
},
|
|
result.error,
|
|
);
|
|
}
|
|
if (code === 'ENOENT') {
|
|
throw createMissingToolError(executable, cmd, result.error);
|
|
}
|
|
throw createCommandFailedError(executable, cmd, args, result.error);
|
|
}
|
|
|
|
const stdoutBuffer = options.binaryStdout
|
|
? Buffer.isBuffer(result.stdout)
|
|
? result.stdout
|
|
: Buffer.from(result.stdout ?? '')
|
|
: undefined;
|
|
const stdout = options.binaryStdout
|
|
? ''
|
|
: typeof result.stdout === 'string'
|
|
? result.stdout
|
|
: (result.stdout ?? '').toString();
|
|
const stderr =
|
|
typeof result.stderr === 'string' ? result.stderr : (result.stderr ?? '').toString();
|
|
const exitCode = result.status ?? 1;
|
|
|
|
if (exitCode !== 0 && !options.allowFailure) {
|
|
throw createExitError(executable, cmd, args, exitCode, stdout, stderr);
|
|
}
|
|
|
|
return { stdout, stderr, exitCode, stdoutBuffer };
|
|
}
|
|
|
|
export function runCmdDetached(
|
|
cmd: string,
|
|
args: string[],
|
|
options: ExecDetachedOptions = {},
|
|
): number {
|
|
return runCmdDetachedMonitored(cmd, args, options).pid;
|
|
}
|
|
|
|
export function runCmdDetachedMonitored(
|
|
cmd: string,
|
|
args: string[],
|
|
options: ExecDetachedOptions = {},
|
|
): ExecDetachedProcess {
|
|
const executable = normalizeExecutableCommand(cmd);
|
|
const child = spawn(executable, args, {
|
|
cwd: options.cwd,
|
|
env: options.env,
|
|
stdio: options.stdio ?? 'ignore',
|
|
detached: true,
|
|
windowsHide: true,
|
|
shell: false,
|
|
});
|
|
const pid = child.pid ?? 0;
|
|
const exited = new Promise<ExecDetachedExit>((resolve) => {
|
|
child.once('error', (err) => {
|
|
resolve({ pid, error: err.message });
|
|
});
|
|
child.once('exit', (code, signal) => {
|
|
resolve({
|
|
pid,
|
|
...(typeof code === 'number' ? { exitCode: code } : {}),
|
|
...(signal ? { signal } : {}),
|
|
});
|
|
});
|
|
});
|
|
child.unref();
|
|
return { pid, exited };
|
|
}
|
|
|
|
export function runCmdBackground(
|
|
cmd: string,
|
|
args: string[],
|
|
options: ExecBackgroundOptions = {},
|
|
): ExecBackgroundResult {
|
|
const executable = normalizeExecutableCommand(cmd);
|
|
const execTrace = createExecTraceContext();
|
|
const child = spawn(executable, args, {
|
|
cwd: options.cwd,
|
|
env: options.env,
|
|
stdio: options.stdio ?? ['ignore', 'pipe', 'pipe'],
|
|
detached: options.detached,
|
|
windowsHide: true,
|
|
shell: false,
|
|
});
|
|
execTrace.emitBackgroundSpawn(cmd, args);
|
|
|
|
let stdout = '';
|
|
let stderr = '';
|
|
const captureOutput = options.captureOutput ?? true;
|
|
const abort = watchCommandAbort(child, options);
|
|
|
|
if (captureOutput) {
|
|
child.stdout?.setEncoding('utf8');
|
|
child.stderr?.setEncoding('utf8');
|
|
|
|
child.stdout?.on('data', (chunk) => {
|
|
stdout += chunk;
|
|
});
|
|
child.stderr?.on('data', (chunk) => {
|
|
stderr += chunk;
|
|
});
|
|
}
|
|
|
|
const wait = new Promise<ExecResult>((resolve, reject) => {
|
|
child.on('error', (err) => {
|
|
abort.dispose();
|
|
execTrace.emitBackgroundCompletion(cmd, args, 'error');
|
|
reject(spawnRejectionError(abort, executable, cmd, args, err));
|
|
});
|
|
child.on('close', (code) => {
|
|
abort.dispose();
|
|
execTrace.emitBackgroundCompletion(cmd, args, 'exit');
|
|
const exitCode = code ?? 1;
|
|
const failure = commandCloseFailure(
|
|
abort,
|
|
executable,
|
|
cmd,
|
|
args,
|
|
exitCode,
|
|
options.allowFailure,
|
|
stdout,
|
|
stderr,
|
|
);
|
|
if (failure) {
|
|
reject(failure);
|
|
return;
|
|
}
|
|
resolve({ stdout, stderr, exitCode });
|
|
});
|
|
});
|
|
|
|
return { child, wait };
|
|
}
|
|
|
|
type ExecTraceContext = {
|
|
emitBackgroundCompletion: (cmd: string, args: string[], event: 'error' | 'exit') => void;
|
|
emitBackgroundSpawn: (cmd: string, args: string[]) => void;
|
|
emitForegroundCompletion: (cmd: string, args: string[]) => void;
|
|
};
|
|
|
|
function createExecTraceContext(): ExecTraceContext {
|
|
const diagnosticsMeta = getDiagnosticsMeta();
|
|
const diagnosticsDebugEnabled = diagnosticsMeta.debug === true;
|
|
const envTraceEnabled = parseBooleanLiteral(process.env.AGENT_DEVICE_EXEC_TRACE ?? '') === true;
|
|
if (!diagnosticsDebugEnabled && !envTraceEnabled) {
|
|
return createDisabledExecTraceContext();
|
|
}
|
|
if (envTraceEnabled && diagnosticsMeta.flushOnSuccess !== true) {
|
|
updateDiagnosticsScope({ flushOnSuccess: true });
|
|
}
|
|
const startedAtMs = Date.now();
|
|
let completionEmitted = false;
|
|
return {
|
|
emitForegroundCompletion: (cmd, args) => {
|
|
if (completionEmitted) return;
|
|
completionEmitted = true;
|
|
emitExecCommandDiagnostic({
|
|
cmd,
|
|
args,
|
|
startedAtMs,
|
|
});
|
|
},
|
|
emitBackgroundSpawn: (cmd, args) => {
|
|
emitExecCommandDiagnostic({
|
|
cmd,
|
|
args,
|
|
data: { event: 'spawn' },
|
|
});
|
|
},
|
|
emitBackgroundCompletion: (cmd, args, event) => {
|
|
if (completionEmitted) return;
|
|
completionEmitted = true;
|
|
emitExecCommandDiagnostic({
|
|
cmd,
|
|
args,
|
|
startedAtMs,
|
|
data: { event },
|
|
});
|
|
},
|
|
};
|
|
}
|
|
|
|
function createDisabledExecTraceContext(): ExecTraceContext {
|
|
return {
|
|
emitForegroundCompletion: () => {},
|
|
emitBackgroundSpawn: () => {},
|
|
emitBackgroundCompletion: () => {},
|
|
};
|
|
}
|
|
|
|
function emitExecCommandDiagnostic(params: {
|
|
cmd: string;
|
|
args: string[];
|
|
startedAtMs?: number;
|
|
data?: Record<string, unknown>;
|
|
}): void {
|
|
const argsPrefix = params.args.slice(0, EXEC_DIAGNOSTIC_ARG_LIMIT);
|
|
emitDiagnostic({
|
|
level: 'debug',
|
|
phase: 'exec_command',
|
|
durationMs:
|
|
params.startedAtMs === undefined ? undefined : Math.max(0, Date.now() - params.startedAtMs),
|
|
data: {
|
|
command: params.cmd,
|
|
argsPrefix,
|
|
...(params.args.length > argsPrefix.length
|
|
? { omittedArgCount: params.args.length - argsPrefix.length }
|
|
: {}),
|
|
...(params.data ?? {}),
|
|
},
|
|
});
|
|
}
|
|
|
|
function normalizeExecutableCommand(cmd: string): string {
|
|
const candidate = normalizeExecutableLookup(cmd);
|
|
if (!candidate) {
|
|
throw new AppError('INVALID_ARGS', `Invalid executable command: ${JSON.stringify(cmd)}`, {
|
|
cmd,
|
|
hint: 'Use a bare command name from PATH or an absolute executable path.',
|
|
});
|
|
}
|
|
return candidate;
|
|
}
|
|
|
|
function createSpawnError(executable: string, cmd: string, args: string[], err: Error): AppError {
|
|
const code = (err as NodeJS.ErrnoException).code;
|
|
if (code === 'ENOENT') {
|
|
return createMissingToolError(executable, cmd, err);
|
|
}
|
|
return createCommandFailedError(executable, cmd, args, err);
|
|
}
|
|
|
|
function createMissingToolError(executable: string, cmd: string, cause: Error): AppError {
|
|
return new AppError('TOOL_MISSING', `${executable} not found in PATH`, { cmd }, cause);
|
|
}
|
|
|
|
function createCommandFailedError(
|
|
executable: string,
|
|
cmd: string,
|
|
args: string[],
|
|
cause: Error,
|
|
): AppError {
|
|
return new AppError('COMMAND_FAILED', `Failed to run ${executable}`, { cmd, args }, cause);
|
|
}
|
|
|
|
function createStdinError(
|
|
executable: string,
|
|
cmd: string,
|
|
args: string[],
|
|
cause: unknown,
|
|
): AppError {
|
|
return new AppError(
|
|
'COMMAND_FAILED',
|
|
`Failed to write stdin for ${executable}`,
|
|
{ cmd, args },
|
|
cause instanceof Error ? cause : undefined,
|
|
);
|
|
}
|
|
|
|
function createCommandCanceledError(executable: string, cmd: string, args: string[]): AppError {
|
|
return new AppError('COMMAND_FAILED', 'request canceled', {
|
|
cmd,
|
|
args,
|
|
executable,
|
|
reason: 'request_canceled',
|
|
});
|
|
}
|
|
|
|
function createTimeoutError(
|
|
executable: string,
|
|
cmd: string,
|
|
args: string[],
|
|
timeoutMs: number,
|
|
exitCode: number,
|
|
stdout: string,
|
|
stderr: string,
|
|
): AppError {
|
|
// exec-guard-allow: deliberately no processExitError — "timed out after Nms"
|
|
// beats whatever partial stderr the killed process left behind.
|
|
return new AppError('COMMAND_FAILED', `${executable} timed out after ${timeoutMs}ms`, {
|
|
cmd,
|
|
args,
|
|
stdout,
|
|
stderr,
|
|
exitCode,
|
|
timeoutMs,
|
|
});
|
|
}
|
|
|
|
function createExitError(
|
|
executable: string,
|
|
cmd: string,
|
|
args: string[],
|
|
exitCode: number,
|
|
stdout: string,
|
|
stderr: string,
|
|
): AppError {
|
|
return new AppError(
|
|
'COMMAND_FAILED',
|
|
`${executable} exited with code ${exitCode}`,
|
|
execFailureDetails({ stdout, stderr, exitCode }, { cmd, args }),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Guard an exec result that was obtained with `allowFailure: true`: throw a
|
|
* curated COMMAND_FAILED (with `processExitError` set, so normalizeError
|
|
* appends the stderr excerpt) on non-zero exit, and pass the result through
|
|
* otherwise. This is the standard shape for "run tool, fail with a specific
|
|
* message" call sites — it works under every executor (local spawn, tool
|
|
* providers, command overrides) because it checks the returned result rather
|
|
* than relying on the spawn layer to throw. `extra` accepts a function so
|
|
* failure-only work (hint classification) is not paid on the success path.
|
|
*/
|
|
export function requireExecSuccess(
|
|
result: ExecResult,
|
|
message: string,
|
|
extra?: Record<string, unknown> | ((result: ExecResult) => Record<string, unknown>),
|
|
): ExecResult {
|
|
if (result.exitCode === 0) return result;
|
|
throw new AppError(
|
|
'COMMAND_FAILED',
|
|
message,
|
|
execFailureDetails(result, typeof extra === 'function' ? extra(result) : extra),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* COMMAND_FAILED details for a non-zero exec result. `processExitError: true`
|
|
* lets normalizeError surface the first meaningful stderr line as the user-facing
|
|
* message instead of the generic wrap message.
|
|
*/
|
|
export function execFailureDetails(
|
|
result: Pick<ExecResult, 'stdout' | 'stderr' | 'exitCode'>,
|
|
extra?: Record<string, unknown>,
|
|
): Record<string, unknown> {
|
|
return {
|
|
stdout: result.stdout,
|
|
stderr: result.stderr,
|
|
exitCode: result.exitCode,
|
|
processExitError: true,
|
|
...extra,
|
|
};
|
|
}
|
|
|
|
type CommandAbort = { readonly didAbort: boolean };
|
|
|
|
// Error to reject a spawned child's `error` event with: canceled if we aborted, else a spawn error.
|
|
function spawnRejectionError(
|
|
abort: CommandAbort,
|
|
executable: string,
|
|
cmd: string,
|
|
args: string[],
|
|
err: Error,
|
|
): AppError {
|
|
return abort.didAbort
|
|
? createCommandCanceledError(executable, cmd, args)
|
|
: createSpawnError(executable, cmd, args, err);
|
|
}
|
|
|
|
// Failure (if any) for a spawned child's `close` event: canceled if we aborted, an exit error on
|
|
// a non-zero code unless allowed, otherwise null (the command resolves successfully).
|
|
function commandCloseFailure(
|
|
abort: CommandAbort,
|
|
executable: string,
|
|
cmd: string,
|
|
args: string[],
|
|
exitCode: number,
|
|
allowFailure: boolean | undefined,
|
|
stdout: string,
|
|
stderr: string,
|
|
): AppError | null {
|
|
if (abort.didAbort) return createCommandCanceledError(executable, cmd, args);
|
|
if (exitCode !== 0 && !allowFailure) {
|
|
return createExitError(executable, cmd, args, exitCode, stdout, stderr);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function normalizeOverridePath(
|
|
rawPath: string | undefined,
|
|
envName: string,
|
|
kind: 'executable' | 'file',
|
|
): string | undefined {
|
|
const candidate = rawPath?.trim();
|
|
if (!candidate) return undefined;
|
|
if (!path.isAbsolute(candidate) || candidate.includes('\0')) {
|
|
throw new AppError(
|
|
'INVALID_ARGS',
|
|
`${envName} must be an absolute ${kind} path, not ${JSON.stringify(rawPath)}`,
|
|
{ envName, path: rawPath },
|
|
);
|
|
}
|
|
return candidate;
|
|
}
|
|
|
|
function normalizeExecutableLookup(cmd: string): string | null {
|
|
const candidate = cmd.trim();
|
|
if (!candidate || candidate.includes('\0')) return null;
|
|
if (path.isAbsolute(candidate)) return candidate;
|
|
if (candidate.includes('/') || candidate.includes('\\')) {
|
|
return null;
|
|
}
|
|
return BARE_COMMAND_RE.test(candidate) ? candidate : null;
|
|
}
|
|
|
|
function resolvePathExtensions(): string[] {
|
|
if (process.platform !== 'win32') return [''];
|
|
const rawPathExt = process.env.PATHEXT;
|
|
if (!rawPathExt) return WINDOWS_PATH_EXTENSIONS;
|
|
const extensions = rawPathExt
|
|
.split(';')
|
|
.map((value) => value.trim().toLowerCase())
|
|
.filter((value) => value.length > 0);
|
|
return extensions.length > 0 ? extensions : WINDOWS_PATH_EXTENSIONS;
|
|
}
|
|
|
|
function resolveExecutableCandidates(cmd: string, pathExtensions: string[]): string[] {
|
|
if (process.platform !== 'win32') return [cmd];
|
|
const lowered = cmd.toLowerCase();
|
|
if (pathExtensions.some((extension) => lowered.endsWith(extension))) {
|
|
return [cmd];
|
|
}
|
|
return pathExtensions.map((extension) => `${cmd}${extension}`);
|
|
}
|
|
|
|
export async function isExecutablePath(filePath: string): Promise<boolean> {
|
|
try {
|
|
if (!(await isFilePath(filePath))) return false;
|
|
await access(filePath, process.platform === 'win32' ? constants.F_OK : constants.X_OK);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function isFilePath(filePath: string): Promise<boolean> {
|
|
try {
|
|
const fileStat = await stat(filePath);
|
|
return fileStat.isFile();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function normalizeTimeoutMs(value: number | undefined): number | undefined {
|
|
if (!Number.isFinite(value)) return undefined;
|
|
const timeout = Math.floor(value as number);
|
|
if (timeout <= 0) return undefined;
|
|
return timeout;
|
|
}
|
|
|
|
function watchCommandAbort(
|
|
child: ChildProcess,
|
|
options: Pick<ExecOptions, 'detached' | 'signal'>,
|
|
): { readonly didAbort: boolean; dispose: () => void } {
|
|
let didAbort = false;
|
|
const onAbort = () => {
|
|
didAbort = true;
|
|
killProcessTree(child, options.detached);
|
|
};
|
|
if (options.signal?.aborted) {
|
|
onAbort();
|
|
} else {
|
|
options.signal?.addEventListener('abort', onAbort, { once: true });
|
|
}
|
|
return {
|
|
get didAbort() {
|
|
return didAbort;
|
|
},
|
|
dispose: () => {
|
|
options.signal?.removeEventListener('abort', onAbort);
|
|
},
|
|
};
|
|
}
|
|
|
|
function killProcessTree(child: ChildProcess, detached: boolean | undefined): void {
|
|
if (detached && child.pid && process.platform !== 'win32') {
|
|
try {
|
|
process.kill(-child.pid, 'SIGKILL');
|
|
return;
|
|
} catch {}
|
|
}
|
|
child.kill('SIGKILL');
|
|
}
|
|
|
|
async function writeChildStdin(
|
|
child: ChildProcess,
|
|
stdin: string | Buffer | undefined,
|
|
): Promise<void> {
|
|
if (!child.stdin) return;
|
|
if (stdin === undefined) {
|
|
child.stdin?.end();
|
|
return;
|
|
}
|
|
await pipeline(Readable.from([stdin]), child.stdin);
|
|
}
|
|
|
|
function isEpipeError(error: unknown): boolean {
|
|
return (
|
|
error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'EPIPE'
|
|
);
|
|
}
|