fix(scripts): avoid DEP0190 when spawning .cmd files on Windows

Use the shared Windows cmd.exe command-line builder for `.cmd` and `.bat` UI runner launches so Node.js v24 no longer sees `spawn(file, args, { shell: true })` and emits DEP0190.

The launcher keeps ordinary `.exe`/`.com` and non-Windows paths on direct argv spawning, while Windows command scripts now run through `cmd.exe /d /s /c` with `shell: false` and `windowsVerbatimArguments: true`.

Local and CI verification passed, including focused UI runner tests, build, check, Real behavior proof, and ClawSweeper gates.

Co-authored-by: Nandana Dileep <nandanadileep@users.noreply.github.com>
Co-authored-by: Brad Groux <3053586+BradGroux@users.noreply.github.com>
This commit is contained in:
Nandana Dileep
2026-05-09 05:15:20 +05:30
committed by GitHub
parent ccfbd96082
commit 5adbbaa3cb
3 changed files with 110 additions and 69 deletions

View File

@@ -662,6 +662,7 @@ Docs: https://docs.openclaw.ai
- Agents/reasoning: keep embedded reasoning deltas raw for correct same-line streaming while preserving formatted Telegram, Feishu, Discord, and heartbeat delivery at the channel edge. (#78397) Thanks @medns.
- Agents/failover: rotate auth profiles before deferred cooldown marking on rate-limit failures, so file-lock contention cannot stall profile failover. Fixes #57281. (#57283) Thanks @jeremyknows.
- Gateway/sessions: when `session.dmScope: "main"` is configured, route a bare webchat `/new` against the agent's main session (`sessions.create` with `emitCommandHooks=true`) to an in-place reset instead of creating a parallel `dashboard:` child, matching `/new` behavior on Telegram/Discord. Fixes #77434. (#71170) Thanks @statxc.
- Scripts/UI/Windows: launch `.cmd` and `.bat` UI runners through the shared cmd.exe escaping path with shell mode disabled, avoiding Node.js v24 DEP0190 warnings while preserving argument boundaries. (#62910) Thanks @nandanadileep.
## 2026.5.3-1

View File

@@ -4,13 +4,13 @@ import fs from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { buildCmdExeCommandLine } from "./windows-cmd-helpers.mjs";
const here = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(here, "..");
const uiDir = path.join(repoRoot, "ui");
const WINDOWS_SHELL_EXTENSIONS = new Set([".cmd", ".bat", ".com"]);
const WINDOWS_UNSAFE_SHELL_ARG_PATTERN = /[\r\n"&|<>^%!]/;
const WINDOWS_CMD_EXE_EXTENSIONS = new Set([".cmd", ".bat"]);
function usage() {
// keep this tiny; it's invoked from npm scripts too
@@ -53,50 +53,47 @@ function resolveRunner() {
return null;
}
export function shouldUseShellForCommand(cmd, platform = process.platform) {
export function shouldUseCmdExeForCommand(cmd, platform = process.platform) {
if (platform !== "win32") {
return false;
}
const extension = path.extname(cmd).toLowerCase();
return WINDOWS_SHELL_EXTENSIONS.has(extension);
return WINDOWS_CMD_EXE_EXTENSIONS.has(extension);
}
export function assertSafeWindowsShellArgs(args, platform = process.platform) {
if (platform !== "win32") {
return;
}
const unsafeArg = args.find((arg) => WINDOWS_UNSAFE_SHELL_ARG_PATTERN.test(arg));
if (!unsafeArg) {
return;
}
// SECURITY: `shell: true` routes through cmd.exe; reject risky metacharacters
// in forwarded args to prevent shell control-flow/env-expansion injection.
throw new Error(
`Unsafe Windows shell argument: ${unsafeArg}. Remove shell metacharacters (" & | < > ^ % !).`,
);
}
export function prepareSpawnCommand(cmd, platform = process.platform) {
return shouldUseShellForCommand(cmd, platform) ? `"${cmd}"` : cmd;
}
function createSpawnOptions(cmd, args, envOverride) {
const useShell = shouldUseShellForCommand(cmd);
if (useShell) {
assertSafeWindowsShellArgs(args);
}
return {
cwd: uiDir,
export function resolveSpawnCall(cmd, args, envOverride, params = {}) {
const platform = params.platform ?? process.platform;
const comSpec = params.comSpec ?? process.env.ComSpec ?? "cmd.exe";
const options = {
cwd: params.cwd ?? uiDir,
stdio: "inherit",
env: envOverride ?? process.env,
...(useShell ? { shell: true } : {}),
shell: false,
};
if (shouldUseCmdExeForCommand(cmd, platform)) {
return {
command: comSpec,
args: ["/d", "/s", "/c", buildCmdExeCommandLine(cmd, args)],
options: {
...options,
windowsVerbatimArguments: true,
},
};
}
return {
command: cmd,
args,
options,
};
}
function run(cmd, args) {
const { command, args: spawnArgs, options } = resolveSpawnCall(cmd, args);
let child;
try {
child = spawn(prepareSpawnCommand(cmd), args, createSpawnOptions(cmd, args));
child = spawn(command, spawnArgs, options);
} catch (err) {
console.error(`Failed to launch ${cmd}:`, err);
process.exit(1);
@@ -115,9 +112,10 @@ function run(cmd, args) {
}
function runSync(cmd, args, envOverride) {
const { command, args: spawnArgs, options } = resolveSpawnCall(cmd, args, envOverride);
let result;
try {
result = spawnSync(prepareSpawnCommand(cmd), args, createSpawnOptions(cmd, args, envOverride));
result = spawnSync(command, spawnArgs, options);
} catch (err) {
console.error(`Failed to launch ${cmd}:`, err);
process.exit(1);

View File

@@ -1,49 +1,91 @@
import { describe, expect, it } from "vitest";
import {
assertSafeWindowsShellArgs,
prepareSpawnCommand,
shouldUseShellForCommand,
} from "../../scripts/ui.js";
import { resolveSpawnCall, shouldUseCmdExeForCommand } from "../../scripts/ui.js";
describe("scripts/ui windows spawn behavior", () => {
it("enables shell for Windows command launchers that require cmd.exe", () => {
it("wraps Windows command launchers with cmd.exe without enabling shell mode", () => {
expect(
shouldUseShellForCommand("C:\\Users\\dev\\AppData\\Local\\pnpm\\pnpm.CMD", "win32"),
shouldUseCmdExeForCommand("C:\\Users\\dev\\AppData\\Local\\pnpm\\pnpm.CMD", "win32"),
).toBe(true);
expect(shouldUseShellForCommand("C:\\tools\\pnpm.bat", "win32")).toBe(true);
});
it("does not enable shell for non-shell launchers", () => {
expect(shouldUseShellForCommand("C:\\Program Files\\nodejs\\node.exe", "win32")).toBe(false);
expect(shouldUseShellForCommand("/usr/local/bin/pnpm", "linux")).toBe(false);
});
it("quotes Windows shell launcher paths before passing them to spawn", () => {
expect(prepareSpawnCommand("C:\\Program Files\\nodejs\\pnpm.cmd", "win32")).toBe(
'"C:\\Program Files\\nodejs\\pnpm.cmd"',
);
expect(prepareSpawnCommand("C:\\Program Files\\nodejs\\pnpm.exe", "win32")).toBe(
"C:\\Program Files\\nodejs\\pnpm.exe",
);
expect(prepareSpawnCommand("/usr/local/bin/pnpm", "linux")).toBe("/usr/local/bin/pnpm");
});
it("allows safe forwarded args when shell mode is required on Windows", () => {
expect(
assertSafeWindowsShellArgs(["run", "build", "--filter", "@openclaw/ui"], "win32"),
).toBeUndefined();
resolveSpawnCall(
"C:\\Program Files\\nodejs\\pnpm.cmd",
["run", "build", "-t", "path with spaces"],
{ PATH: "C:\\bin" },
{ comSpec: "C:\\Windows\\System32\\cmd.exe", cwd: "C:\\repo\\ui", platform: "win32" },
),
).toEqual({
command: "C:\\Windows\\System32\\cmd.exe",
args: [
"/d",
"/s",
"/c",
'"C:\\Program Files\\nodejs\\pnpm.cmd" run build -t "path with spaces"',
],
options: {
cwd: "C:\\repo\\ui",
stdio: "inherit",
env: { PATH: "C:\\bin" },
shell: false,
windowsVerbatimArguments: true,
},
});
});
it("rejects dangerous forwarded args when shell mode is required on Windows", () => {
expect(() => assertSafeWindowsShellArgs(["run", "build", "evil&calc"], "win32")).toThrow(
/unsafe windows shell argument/i,
);
expect(() => assertSafeWindowsShellArgs(["run", "build", "%PATH%"], "win32")).toThrow(
/unsafe windows shell argument/i,
);
it("does not use cmd.exe for non-command launchers", () => {
expect(shouldUseCmdExeForCommand("C:\\Program Files\\nodejs\\node.exe", "win32")).toBe(false);
expect(shouldUseCmdExeForCommand("C:\\tools\\pnpm.com", "win32")).toBe(false);
expect(shouldUseCmdExeForCommand("/usr/local/bin/pnpm", "linux")).toBe(false);
expect(
resolveSpawnCall(
"C:\\Program Files\\nodejs\\pnpm.exe",
["run", "build"],
{ PATH: "C:\\bin" },
{ cwd: "C:\\repo\\ui", platform: "win32" },
),
).toEqual({
command: "C:\\Program Files\\nodejs\\pnpm.exe",
args: ["run", "build"],
options: {
cwd: "C:\\repo\\ui",
stdio: "inherit",
env: { PATH: "C:\\bin" },
shell: false,
},
});
});
it("does not reject args on non-windows platforms", () => {
expect(assertSafeWindowsShellArgs(["contains&metacharacters"], "linux")).toBeUndefined();
it("rejects unsafe cmd.exe arguments before launch", () => {
expect(() =>
resolveSpawnCall("C:\\tools\\pnpm.cmd", ["run", "build", "evil&calc"], undefined, {
platform: "win32",
}),
).toThrow(/unsafe windows cmd\.exe argument/i);
expect(() =>
resolveSpawnCall("C:\\tools\\pnpm.cmd", ["run", "build", "%PATH%"], undefined, {
platform: "win32",
}),
).toThrow(/unsafe windows cmd\.exe argument/i);
});
it("keeps non-Windows launches direct even with shell metacharacters", () => {
expect(
resolveSpawnCall(
"/usr/local/bin/pnpm",
["run", "build", "contains&metacharacters"],
{ PATH: "/bin" },
{ cwd: "/repo/ui", platform: "linux" },
),
).toEqual({
command: "/usr/local/bin/pnpm",
args: ["run", "build", "contains&metacharacters"],
options: {
cwd: "/repo/ui",
stdio: "inherit",
env: { PATH: "/bin" },
shell: false,
},
});
});
});