import { execFile, spawn } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import process from "node:process"; import { promisify } from "node:util"; import { danger, shouldLogVerbose } from "../globals.js"; import { logDebug, logError } from "../logger.js"; import { resolveCommandStdio } from "./spawn-utils.js"; const execFileAsync = promisify(execFile); const WINDOWS_UNSAFE_CMD_CHARS_RE = /[&|<>^%\r\n]/; function isWindowsBatchCommand(resolvedCommand: string): boolean { if (process.platform !== "win32") { return false; } const ext = path.extname(resolvedCommand).toLowerCase(); return ext === ".cmd" || ext === ".bat"; } function escapeForCmdExe(arg: string): string { // Reject cmd metacharacters to avoid injection when we must pass a single command line. if (WINDOWS_UNSAFE_CMD_CHARS_RE.test(arg)) { throw new Error( `Unsafe Windows cmd.exe argument detected: ${JSON.stringify(arg)}. ` + "Pass an explicit shell-wrapper argv at the call site instead.", ); } // Quote when needed; double inner quotes for cmd parsing. if (!arg.includes(" ") && !arg.includes('"')) { return arg; } return `"${arg.replace(/"/g, '""')}"`; } function buildCmdExeCommandLine(resolvedCommand: string, args: string[]): string { return [escapeForCmdExe(resolvedCommand), ...args.map(escapeForCmdExe)].join(" "); } /** * On Windows, Node 18.20.2+ (CVE-2024-27980) rejects spawning .cmd/.bat directly * without shell, causing EINVAL. Resolve npm/npx to node + cli script so we * spawn node.exe instead of npm.cmd. */ function resolveNpmArgvForWindows(argv: string[]): string[] | null { if (process.platform !== "win32" || argv.length === 0) { return null; } const basename = path .basename(argv[0]) .toLowerCase() .replace(/\.(cmd|exe|bat)$/, ""); const cliName = basename === "npx" ? "npx-cli.js" : basename === "npm" ? "npm-cli.js" : null; if (!cliName) { return null; } const nodeDir = path.dirname(process.execPath); const cliPath = path.join(nodeDir, "node_modules", "npm", "bin", cliName); if (!fs.existsSync(cliPath)) { return null; } return [process.execPath, cliPath, ...argv.slice(1)]; } /** * Resolves a command for Windows compatibility. * On Windows, non-.exe commands (like pnpm, yarn) are resolved to .cmd; npm/npx * are handled by resolveNpmArgvForWindows to avoid spawn EINVAL (no direct .cmd). */ function resolveCommand(command: string): string { if (process.platform !== "win32") { return command; } const basename = path.basename(command).toLowerCase(); const ext = path.extname(basename); if (ext) { return command; } const cmdCommands = ["pnpm", "yarn"]; if (cmdCommands.includes(basename)) { return `${command}.cmd`; } return command; } export function shouldSpawnWithShell(params: { resolvedCommand: string; platform: NodeJS.Platform; }): boolean { // SECURITY: never enable `shell` for argv-based execution. // `shell` routes through cmd.exe on Windows, which turns untrusted argv values // (like chat prompts passed as CLI args) into command-injection primitives. // If you need a shell, use an explicit shell-wrapper argv (e.g. `cmd.exe /c ...`) // and validate/escape at the call site. void params; return false; } // Simple promise-wrapped execFile with optional verbosity logging. export async function runExec( command: string, args: string[], opts: number | { timeoutMs?: number; maxBuffer?: number; cwd?: string } = 10_000, ): Promise<{ stdout: string; stderr: string }> { const options = typeof opts === "number" ? { timeout: opts, encoding: "utf8" as const } : { timeout: opts.timeoutMs, maxBuffer: opts.maxBuffer, cwd: opts.cwd, encoding: "utf8" as const, }; try { const argv = [command, ...args]; let execCommand: string; let execArgs: string[]; if (process.platform === "win32") { const resolved = resolveNpmArgvForWindows(argv); if (resolved) { execCommand = resolved[0] ?? ""; execArgs = resolved.slice(1); } else { execCommand = resolveCommand(command); execArgs = args; } } else { execCommand = resolveCommand(command); execArgs = args; } const useCmdWrapper = isWindowsBatchCommand(execCommand); const { stdout, stderr } = useCmdWrapper ? await execFileAsync( process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", buildCmdExeCommandLine(execCommand, execArgs)], { ...options, windowsVerbatimArguments: true }, ) : await execFileAsync(execCommand, execArgs, options); if (shouldLogVerbose()) { if (stdout.trim()) { logDebug(stdout.trim()); } if (stderr.trim()) { logError(stderr.trim()); } } return { stdout, stderr }; } catch (err) { if (shouldLogVerbose()) { logError(danger(`Command failed: ${command} ${args.join(" ")}`)); } throw err; } } export type SpawnResult = { pid?: number; stdout: string; stderr: string; code: number | null; signal: NodeJS.Signals | null; killed: boolean; termination: "exit" | "timeout" | "no-output-timeout" | "signal"; noOutputTimedOut?: boolean; }; export type CommandOptions = { timeoutMs: number; cwd?: string; input?: string; env?: NodeJS.ProcessEnv; windowsVerbatimArguments?: boolean; noOutputTimeoutMs?: number; }; export function resolveCommandEnv(params: { argv: string[]; env?: NodeJS.ProcessEnv; baseEnv?: NodeJS.ProcessEnv; }): NodeJS.ProcessEnv { const baseEnv = params.baseEnv ?? process.env; const argv = params.argv; const shouldSuppressNpmFund = (() => { const cmd = path.basename(argv[0] ?? ""); if (cmd === "npm" || cmd === "npm.cmd" || cmd === "npm.exe") { return true; } if (cmd === "node" || cmd === "node.exe") { const script = argv[1] ?? ""; return script.includes("npm-cli.js"); } return false; })(); const mergedEnv = params.env ? { ...baseEnv, ...params.env } : { ...baseEnv }; const resolvedEnv = Object.fromEntries( Object.entries(mergedEnv) .filter(([, value]) => value !== undefined) .map(([key, value]) => [key, String(value)]), ); if (shouldSuppressNpmFund) { if (resolvedEnv.NPM_CONFIG_FUND == null) { resolvedEnv.NPM_CONFIG_FUND = "false"; } if (resolvedEnv.npm_config_fund == null) { resolvedEnv.npm_config_fund = "false"; } } return resolvedEnv; } export async function runCommandWithTimeout( argv: string[], optionsOrTimeout: number | CommandOptions, ): Promise { const options: CommandOptions = typeof optionsOrTimeout === "number" ? { timeoutMs: optionsOrTimeout } : optionsOrTimeout; const { timeoutMs, cwd, input, env, noOutputTimeoutMs } = options; const { windowsVerbatimArguments } = options; const hasInput = input !== undefined; const resolvedEnv = resolveCommandEnv({ argv, env }); const stdio = resolveCommandStdio({ hasInput, preferInherit: true }); const finalArgv = process.platform === "win32" ? (resolveNpmArgvForWindows(argv) ?? argv) : argv; const resolvedCommand = finalArgv !== argv ? (finalArgv[0] ?? "") : resolveCommand(argv[0] ?? ""); const useCmdWrapper = isWindowsBatchCommand(resolvedCommand); const child = spawn( useCmdWrapper ? (process.env.ComSpec ?? "cmd.exe") : resolvedCommand, useCmdWrapper ? ["/d", "/s", "/c", buildCmdExeCommandLine(resolvedCommand, finalArgv.slice(1))] : finalArgv.slice(1), { stdio, cwd, env: resolvedEnv, windowsVerbatimArguments: useCmdWrapper ? true : windowsVerbatimArguments, ...(shouldSpawnWithShell({ resolvedCommand, platform: process.platform }) ? { shell: true } : {}), }, ); // Spawn with inherited stdin (TTY) so tools like `pi` stay interactive when needed. return await new Promise((resolve, reject) => { let stdout = ""; let stderr = ""; let settled = false; let timedOut = false; let noOutputTimedOut = false; let noOutputTimer: NodeJS.Timeout | null = null; const shouldTrackOutputTimeout = typeof noOutputTimeoutMs === "number" && Number.isFinite(noOutputTimeoutMs) && noOutputTimeoutMs > 0; const clearNoOutputTimer = () => { if (!noOutputTimer) { return; } clearTimeout(noOutputTimer); noOutputTimer = null; }; const armNoOutputTimer = () => { if (!shouldTrackOutputTimeout || settled) { return; } clearNoOutputTimer(); noOutputTimer = setTimeout(() => { if (settled) { return; } noOutputTimedOut = true; if (typeof child.kill === "function") { child.kill("SIGKILL"); } }, Math.floor(noOutputTimeoutMs)); }; const timer = setTimeout(() => { timedOut = true; if (typeof child.kill === "function") { child.kill("SIGKILL"); } }, timeoutMs); armNoOutputTimer(); if (hasInput && child.stdin) { child.stdin.write(input ?? ""); child.stdin.end(); } child.stdout?.on("data", (d) => { stdout += d.toString(); armNoOutputTimer(); }); child.stderr?.on("data", (d) => { stderr += d.toString(); armNoOutputTimer(); }); child.on("error", (err) => { if (settled) { return; } settled = true; clearTimeout(timer); clearNoOutputTimer(); reject(err); }); child.on("close", (code, signal) => { if (settled) { return; } settled = true; clearTimeout(timer); clearNoOutputTimer(); const termination = noOutputTimedOut ? "no-output-timeout" : timedOut ? "timeout" : signal != null ? "signal" : "exit"; resolve({ pid: child.pid ?? undefined, stdout, stderr, code, signal, killed: child.killed, termination, noOutputTimedOut, }); }); }); }