Process: fix Windows .cmd spawn EINVAL (openclaw#29759) thanks @icesword0760

Verified:
- pnpm vitest run src/process/exec.test.ts src/process/exec.windows.test.ts

Co-authored-by: icesword0760 <123886211+icesword0760@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
icesword0760
2026-03-02 21:49:54 +08:00
committed by GitHub
parent 732c4f3921
commit 6e008e93be
2 changed files with 157 additions and 10 deletions

View File

@@ -9,6 +9,35 @@ 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
@@ -100,7 +129,14 @@ export async function runExec(
execCommand = resolveCommand(command);
execArgs = args;
}
const { stdout, stderr } = await execFileAsync(execCommand, execArgs, options);
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());
@@ -178,15 +214,22 @@ export async function runCommandWithTimeout(
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 child = spawn(resolvedCommand, finalArgv.slice(1), {
stdio,
cwd,
env: resolvedEnv,
windowsVerbatimArguments,
...(shouldSpawnWithShell({ resolvedCommand, platform: process.platform })
? { shell: true }
: {}),
});
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 = "";