diff --git a/CHANGELOG.md b/CHANGELOG.md index 593a8b51939..2d92a86d20c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/scripts/ui.js b/scripts/ui.js index 0b5f4be436e..82464aa010b 100644 --- a/scripts/ui.js +++ b/scripts/ui.js @@ -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); diff --git a/test/scripts/ui.test.ts b/test/scripts/ui.test.ts index e76e474b082..9a2a5c70d57 100644 --- a/test/scripts/ui.test.ts +++ b/test/scripts/ui.test.ts @@ -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, + }, + }); }); });