* 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>
554 lines
17 KiB
TypeScript
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 [];
|
|
}
|
|
}
|