agent-ecosystem/src/main/utils/childProcess.ts
infiniti 2b3a184bef
fix(opencode): recover empty bridge output sends
* fix(opencode): handle empty readiness bridge output

* fix(opencode): retry read-only bridge no-output

* fix(opencode): recover empty bridge output sends

---------

Co-authored-by: iliya <iliyazelenkog@gmail.com>
2026-05-25 00:41:54 +03:00

554 lines
17 KiB
TypeScript

import {
type ChildProcess,
exec,
execFile,
type ExecFileOptions,
type ExecOptions,
spawn,
type SpawnOptions,
spawnSync,
} from 'child_process';
import { existsSync, readFileSync } from 'fs';
import path from 'path';
/**
* Promise wrapper for execFile that always returns { stdout, stderr }.
* Unlike promisify(execFile), this works correctly with mocked execFile
* (promisify relies on a custom symbol that mocks don't have).
*/
function execFileAsync(
cmd: string,
args: string[],
options: ExecFileOptions = {}
): Promise<{ stdout: string; stderr: string }> {
return new Promise((resolve, reject) => {
const { timeout, killSignal, ...execOptions } = options;
const timeoutMs = typeof timeout === 'number' && timeout > 0 ? timeout : 0;
const timeoutSignal = normalizeKillSignal(killSignal);
let child: ChildProcess | null = null;
let settled = false;
let stdoutText = '';
let stderrText = '';
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
child = execFile(cmd, args, execOptions, (err, stdout, stderr) => {
if (settled) {
return;
}
settled = true;
timeoutHandle = cleanupTimedCliProcess(child, timeoutHandle);
if (err) {
const normalizedError =
err instanceof Error ? err : new Error(typeof err === 'string' ? err : 'Unknown error');
Object.assign(normalizedError, {
stdout: String(stdout),
stderr: String(stderr),
});
reject(normalizedError);
} else resolve({ stdout: String(stdout), stderr: String(stderr) });
});
if (!settled) {
trackCliProcess(child);
if (timeoutMs > 0) {
child.stdout?.on('data', (chunk: Buffer | string) => {
stdoutText += chunk.toString();
});
child.stderr?.on('data', (chunk: Buffer | string) => {
stderrText += chunk.toString();
});
timeoutHandle = setTimeout(() => {
if (settled) {
return;
}
settled = true;
timeoutHandle = cleanupTimedCliProcess(child, timeoutHandle);
killProcessTree(child, timeoutSignal);
const error = new Error(
`Command timed out after ${timeoutMs}ms: ${cmd} ${args.join(' ')}`
);
Object.assign(error, {
killed: true,
signal: timeoutSignal,
stdout: stdoutText,
stderr: stderrText,
});
reject(error);
}, timeoutMs);
timeoutHandle.unref?.();
}
}
});
}
/**
* Promise wrapper for exec. Used exclusively as a Windows shell fallback
* when execFile fails with EINVAL on non-ASCII binary paths. The command
* string is built from a known binary path + args, NOT from user input.
*/
function execShellAsync(
cmd: string,
options: ExecOptions = {}
): Promise<{ stdout: string; stderr: string }> {
return new Promise((resolve, reject) => {
const { timeout, killSignal, ...execOptions } = options;
const timeoutMs = typeof timeout === 'number' && timeout > 0 ? timeout : 0;
const timeoutSignal = normalizeKillSignal(killSignal);
let child: ChildProcess | null = null;
let settled = false;
let stdoutText = '';
let stderrText = '';
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
// eslint-disable-next-line sonarjs/os-command, security/detect-child-process -- cmd from known binaryPath+args, not user input (Windows EINVAL fallback)
child = exec(cmd, execOptions, (err, stdout, stderr) => {
if (settled) {
return;
}
settled = true;
timeoutHandle = cleanupTimedCliProcess(child, timeoutHandle);
if (err)
reject(
err instanceof Error ? err : new Error(typeof err === 'string' ? err : 'Unknown error')
);
else resolve({ stdout: String(stdout), stderr: String(stderr) });
});
if (!settled) {
trackCliProcess(child);
if (timeoutMs > 0) {
child.stdout?.on('data', (chunk: Buffer | string) => {
stdoutText += chunk.toString();
});
child.stderr?.on('data', (chunk: Buffer | string) => {
stderrText += chunk.toString();
});
timeoutHandle = setTimeout(() => {
if (settled) {
return;
}
settled = true;
timeoutHandle = cleanupTimedCliProcess(child, timeoutHandle);
killProcessTree(child, timeoutSignal);
const error = new Error(`Command timed out after ${timeoutMs}ms: ${cmd}`);
Object.assign(error, {
killed: true,
signal: timeoutSignal,
stdout: stdoutText,
stderr: stderrText,
});
reject(error);
}, timeoutMs);
timeoutHandle.unref?.();
}
}
});
}
function cleanupTimedCliProcess(
child: ChildProcess | null,
timeoutHandle: ReturnType<typeof setTimeout> | null
): null {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
untrackCliProcess(child);
return null;
}
/**
* Returns true if the string contains any non-ASCII character.
*/
function containsNonAscii(str: string): boolean {
return [...str].some((c) => c.charCodeAt(0) > 127);
}
/**
* On Windows, batch launchers need cmd.exe, and creating a process whose
* path contains non-ASCII characters will often fail with `spawn EINVAL`.
* Detect both cases so callers can launch through a shell when needed.
*/
function needsShell(binaryPath: string): boolean {
if (process.platform !== 'win32') return false;
if (!binaryPath) return false;
const extension = path.extname(binaryPath).toLowerCase();
return extension === '.cmd' || extension === '.bat' || containsNonAscii(binaryPath);
}
interface DirectWindowsLauncher {
command: string;
argsPrefix: string[];
}
function isWindowsBatchLauncher(binaryPath: string): boolean {
const extension = path.extname(binaryPath).toLowerCase();
return extension === '.cmd' || extension === '.bat';
}
function resolveCmdPathTemplate(template: string, launcherDir: string): string {
const dirWithSep = launcherDir.endsWith(path.sep) ? launcherDir : `${launcherDir}${path.sep}`;
return path.resolve(
template
.replace(/%SCRIPT_DIR%/gi, dirWithSep)
.replace(/%~dp0/gi, dirWithSep)
.replace(/%dp0%/gi, dirWithSep)
.replace(/\\/g, path.sep)
);
}
function resolveGeneratedBunLauncher(
content: string,
launcherDir: string
): DirectWindowsLauncher | null {
if (!/\bbun\s+"%TARGET%"\s+%\*/i.test(content)) {
return null;
}
const targetMatch = /set\s+"TARGET=([^"]+)"/i.exec(content);
const targetTemplate = targetMatch?.[1];
if (!targetTemplate) {
return null;
}
const target = resolveCmdPathTemplate(targetTemplate, launcherDir);
if (!existsSync(target)) {
return null;
}
return { command: 'bun', argsPrefix: [target] };
}
function resolveNpmNodeShim(content: string, launcherDir: string): DirectWindowsLauncher | null {
const scriptMatch = /"%_prog%"\s+"([^"]+(?:\.(?:cjs|mjs|js))?)"\s+%\*/i.exec(content);
const scriptTemplate = scriptMatch?.[1];
if (!scriptTemplate) {
return null;
}
const scriptPath = resolveCmdPathTemplate(scriptTemplate, launcherDir);
if (!existsSync(scriptPath)) {
return null;
}
const localNode = path.join(launcherDir, 'node.exe');
return {
command: existsSync(localNode) ? localNode : 'node',
argsPrefix: [scriptPath],
};
}
function resolveNpmNativeShim(content: string, launcherDir: string): DirectWindowsLauncher | null {
const nativeTarget = /(?:^|[&|])\s*"([^"]+\.(?:exe|com))"\s+%\*/im.exec(content)?.[1];
if (!nativeTarget) {
return null;
}
const target = resolveCmdPathTemplate(nativeTarget, launcherDir);
if (!existsSync(target)) {
return null;
}
return { command: target, argsPrefix: [] };
}
/**
* Some Windows launchers are thin wrappers around a real JS entrypoint.
* Running that entrypoint directly with an argv array avoids cmd.exe's
* percent expansion, which cannot safely represent args like `%PATH%`.
*/
function resolveDirectWindowsLauncher(binaryPath: string): DirectWindowsLauncher | null {
if (process.platform !== 'win32' || !isWindowsBatchLauncher(binaryPath)) {
return null;
}
try {
const content = readFileSync(binaryPath, 'utf8');
const launcherDir = path.dirname(binaryPath);
return (
resolveGeneratedBunLauncher(content, launcherDir) ??
resolveNpmNodeShim(content, launcherDir) ??
resolveNpmNativeShim(content, launcherDir)
);
} catch {
return null;
}
}
/**
* Quote an argument for cmd.exe shell invocation on Windows.
*
* cmd.exe rules:
* - Double-quote args containing spaces or special characters
* - Inside double quotes, escape literal `"` as `\"` for the target argv parser
* - Double trailing backslashes so they do not escape the closing quote
* - `%` is expanded as env var even inside double quotes. Keep it outside
* quoted chunks and escape it as `^%`.
* - `^`, `&`, `|`, `<`, `>` are safe inside double quotes
*
* Our callers only pass controlled strings (binary paths, CLI flags),
* NOT arbitrary user input.
*/
function quoteCmdChunk(chunk: string): string {
const escaped = chunk
.replace(/(\\*)"/g, (_match, backslashes: string) => `${backslashes}${backslashes}\\"`)
.replace(/(\\+)$/g, '$1$1');
return `"${escaped}"`;
}
export function quoteWindowsCmdArg(arg: string): string {
if (/[^A-Za-z0-9_\-/.]/.test(arg)) {
return arg.split('%').map(quoteCmdChunk).join('^%');
}
return arg;
}
function quoteArg(arg: string): string {
return quoteWindowsCmdArg(arg);
}
/** Env vars injected into every spawned Claude CLI process. */
const CLI_ENV_DEFAULTS: Record<string, string> = {
CLAUDE_HOOK_JUDGE_MODE: 'true',
};
const activeCliProcesses = new Set<ChildProcess>();
export function untrackCliProcess(child: ChildProcess | null): void {
if (child) {
activeCliProcesses.delete(child);
}
}
function trackCliProcess<T extends ChildProcess>(child: T): T {
activeCliProcesses.add(child);
const cleanup = (): void => {
activeCliProcesses.delete(child);
};
child.once?.('exit', cleanup);
child.once?.('close', cleanup);
child.once?.('error', cleanup);
return child;
}
export function killTrackedCliProcesses(signal: NodeJS.Signals = 'SIGKILL'): void {
for (const child of Array.from(activeCliProcesses)) {
try {
killProcessTree(child, signal);
} catch {
// Best effort during shutdown.
}
}
}
/** Apply shared CLI process defaults without overriding explicit caller choices. */
function withCliProcessDefaults<
T extends {
env?: NodeJS.ProcessEnv | Record<string, string | undefined>;
windowsHide?: boolean;
},
>(options: T): T & { windowsHide: boolean } {
return {
...options,
windowsHide: options.windowsHide ?? true,
env: { ...(options.env ?? process.env), ...CLI_ENV_DEFAULTS },
};
}
/**
* Execute a CLI binary, falling back to running the command through a
* shell on Windows if the normal path-based spawn fails.
*
* The return value matches the shape of Node's `execFile` promise: an
* object with `stdout` and `stderr` strings.
*/
export interface ExecCliOptions extends ExecFileOptions {
/**
* Some generated Windows launchers are safe to run directly, but callers can
* force the .cmd/.bat path when they need the launcher environment exactly.
*/
preferShellForWindowsBatch?: boolean;
}
export async function execCli(
binaryPath: string | null,
args: string[],
options: ExecCliOptions = {}
): Promise<{ stdout: string; stderr: string }> {
if (!binaryPath) {
throw new Error(
'Claude CLI binary path is null. Resolve the binary via ClaudeBinaryResolver before calling execCli.'
);
}
const target = binaryPath;
const { preferShellForWindowsBatch = false, ...execOptions } = options;
const opts = withCliProcessDefaults(execOptions);
const directLauncher =
preferShellForWindowsBatch && isWindowsBatchLauncher(target)
? null
: resolveDirectWindowsLauncher(target);
if (directLauncher) {
const result = await execFileAsync(
directLauncher.command,
[...directLauncher.argsPrefix, ...args],
opts
);
return { stdout: String(result.stdout), stderr: String(result.stderr) };
}
// attempt the normal execFile path first
if (!needsShell(target)) {
try {
const result = await execFileAsync(target, args, opts);
return { stdout: String(result.stdout), stderr: String(result.stderr) };
} catch (err: unknown) {
// fall through to shell fallback only when the error matches the
// Windows "invalid argument" problem; otherwise rethrow.
const code =
err && typeof err === 'object' && 'code' in err
? (err as { code?: string }).code
: undefined;
if (code !== 'EINVAL') {
throw err;
}
}
}
// shell fallback (Windows only; others shouldn't reach here)
const cmd = [target, ...args].map(quoteArg).join(' ');
const shellResult = await execShellAsync(cmd, opts as unknown as ExecOptions);
return { stdout: String(shellResult.stdout), stderr: String(shellResult.stderr) };
}
/**
* Spawn a child process. If the initial `spawn()` call throws
* synchronously with EINVAL on Windows, retry using a shell-based
* command string. The returned `ChildProcess` is whatever the
* underlying call returned; listeners may safely be attached to it.
*/
export function spawnCli(
binaryPath: string,
args: string[],
options: SpawnOptions = {}
): ReturnType<typeof spawn> {
const opts = withCliProcessDefaults(options);
const directLauncher = resolveDirectWindowsLauncher(binaryPath);
if (directLauncher) {
const directOpts = { ...opts };
delete directOpts.shell;
return trackCliProcess(
spawn(directLauncher.command, [...directLauncher.argsPrefix, ...args], directOpts)
);
}
if (process.platform === 'win32' && needsShell(binaryPath)) {
const cmd = [binaryPath, ...args].map(quoteArg).join(' ');
// eslint-disable-next-line sonarjs/os-command -- cmd from known binaryPath+args, not user input (Windows EINVAL fallback)
return trackCliProcess(spawn(cmd, { ...opts, shell: true }));
}
try {
return trackCliProcess(spawn(binaryPath, args, opts));
} catch (err: unknown) {
const code =
err && typeof err === 'object' && 'code' in err ? (err as { code?: string }).code : undefined;
if (process.platform === 'win32' && code === 'EINVAL') {
const cmd = [binaryPath, ...args].map(quoteArg).join(' ');
// eslint-disable-next-line sonarjs/os-command -- cmd from known binaryPath+args, not user input (Windows EINVAL fallback)
return trackCliProcess(spawn(cmd, { ...opts, shell: true }));
}
throw err;
}
}
/**
* Kill a child process and its entire process tree.
*
* On Windows with `shell: true`, `child.kill()` only kills the intermediate
* `cmd.exe` shell, leaving the actual process (e.g. `claude.cmd`) orphaned.
* `taskkill /T /F /PID` recursively kills the entire process tree.
*
* On macOS/Linux, kill the child and descendants by PID so shell wrappers
* and spawned grandchildren do not survive a timeout or team stop.
*/
export function killProcessTree(
child: ChildProcess | null | undefined,
signal?: NodeJS.Signals
): void {
if (!child?.pid) {
// Process is null, never started, or already exited
return;
}
if (process.platform === 'win32') {
try {
const taskkillPath = path.join(
process.env.SystemRoot ?? 'C:\\Windows',
'System32',
'taskkill.exe'
);
execFile(taskkillPath, ['/T', '/F', '/PID', String(child.pid)], { windowsHide: true }, () => {
// Best-effort - ignore errors (process may have already exited)
});
return;
} catch {
// taskkill failed, fall through to standard kill
}
}
const childPid = child.pid;
const descendants = getDescendantProcessIds(childPid);
const targetSignal = signal ?? 'SIGTERM';
for (const pid of [childPid, ...descendants.reverse()]) {
try {
process.kill(pid, targetSignal);
} catch {
// Best-effort - process may have already exited.
}
}
}
function normalizeKillSignal(signal: ExecFileOptions['killSignal']): NodeJS.Signals {
return typeof signal === 'string' ? signal : 'SIGTERM';
}
function getDescendantProcessIds(parentPid: number): number[] {
if (process.platform === 'win32') {
return [];
}
try {
const result = spawnSync('ps', ['-axo', 'pid=,ppid='], {
encoding: 'utf8',
windowsHide: true,
});
if (result.error || result.status !== 0 || typeof result.stdout !== 'string') {
return [];
}
const childrenByParent = new Map<number, number[]>();
for (const line of result.stdout.split('\n')) {
const match = /^(\d+)\s+(\d+)$/.exec(line.trim());
if (!match) {
continue;
}
const pid = Number(match[1]);
const ppid = Number(match[2]);
const children = childrenByParent.get(ppid);
if (children) {
children.push(pid);
} else {
childrenByParent.set(ppid, [pid]);
}
}
const descendants: number[] = [];
const stack = [...(childrenByParent.get(parentPid) ?? [])];
const seen = new Set<number>();
while (stack.length > 0) {
const pid = stack.pop();
if (!pid || seen.has(pid) || pid === process.pid) {
continue;
}
seen.add(pid);
descendants.push(pid);
stack.push(...(childrenByParent.get(pid) ?? []));
}
return descendants;
} catch {
return [];
}
}