From 6c4028e073694e6b7bdae2c61d6c060c623625cf Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 21 Jun 2026 11:15:05 +0200 Subject: [PATCH] fix(scripts): resolve Windows cmd shim launcher --- scripts/build-all.mjs | 2 +- scripts/crabbox-wrapper.mjs | 4 +- .../lib/upgrade-survivor/config-recipe.mjs | 4 +- scripts/e2e/parallels/host-command.ts | 9 +-- scripts/ensure-playwright-chromium.mjs | 2 +- scripts/format-docs.mjs | 4 +- .../lib/codex-app-server-protocol-source.ts | 7 +- scripts/lib/managed-child-process.mjs | 4 +- scripts/lib/windows-taskkill.mjs | 65 ++---------------- scripts/npm-runner.mjs | 8 ++- scripts/openclaw-cross-os-release-checks.ts | 13 ++-- scripts/openclaw-npm-postpublish-verify.ts | 4 +- scripts/openclaw-npm-release-check.ts | 8 +-- scripts/pnpm-runner.mjs | 16 +++-- scripts/tsdown-build.mjs | 2 +- scripts/ui.js | 6 +- scripts/windows-cmd-helpers.d.mts | 4 ++ scripts/windows-cmd-helpers.mjs | 67 +++++++++++++++++++ scripts/zai-fallback-repro.ts | 7 +- test/scripts/npm-runner.test.ts | 18 +++++ .../openclaw-cross-os-release-checks.test.ts | 26 +++++++ test/scripts/parallels-smoke-model.test.ts | 17 ++++- test/scripts/pnpm-runner.test.ts | 20 ++++++ test/scripts/ui.test.ts | 14 ++++ 24 files changed, 217 insertions(+), 114 deletions(-) diff --git a/scripts/build-all.mjs b/scripts/build-all.mjs index 61a42d74deac..905e59e8fea5 100644 --- a/scripts/build-all.mjs +++ b/scripts/build-all.mjs @@ -341,7 +341,7 @@ export function resolveBuildAllStep(step, params = {}) { pnpmArgs: step.pnpmArgs, nodeExecPath: params.nodeExecPath ?? nodeBin, npmExecPath: params.npmExecPath ?? env.npm_execpath, - comSpec: params.comSpec ?? env.ComSpec, + comSpec: params.comSpec, platform, }); return { diff --git a/scripts/crabbox-wrapper.mjs b/scripts/crabbox-wrapper.mjs index 64fe8fe4250d..0b2e7527bb41 100755 --- a/scripts/crabbox-wrapper.mjs +++ b/scripts/crabbox-wrapper.mjs @@ -19,7 +19,7 @@ import { import { homedir, tmpdir } from "node:os"; import { delimiter, dirname, extname, isAbsolute, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { resolvePathEnvKey } from "./windows-cmd-helpers.mjs"; +import { resolvePathEnvKey, resolveWindowsCmdExePath } from "./windows-cmd-helpers.mjs"; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const ignoreRepoBinary = process.env.OPENCLAW_CRABBOX_WRAPPER_IGNORE_REPO_BINARY === "1"; @@ -119,7 +119,7 @@ function spawnInvocation(command, commandArgs, env, platform) { const extension = extname(command).toLowerCase(); if (platform === "win32" && (extension === ".cmd" || extension === ".bat")) { return { - command: env.ComSpec ?? "cmd.exe", + command: resolveWindowsCmdExePath(env), args: ["/d", "/s", "/c", buildBatchCommandLine(command, commandArgs)], windowsVerbatimArguments: true, }; diff --git a/scripts/e2e/lib/upgrade-survivor/config-recipe.mjs b/scripts/e2e/lib/upgrade-survivor/config-recipe.mjs index 4aaa7a3e3a0a..9c90dac1ee39 100644 --- a/scripts/e2e/lib/upgrade-survivor/config-recipe.mjs +++ b/scripts/e2e/lib/upgrade-survivor/config-recipe.mjs @@ -5,7 +5,7 @@ import fs from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { parseReleaseVersion } from "../../../lib/npm-publish-plan.mjs"; -import { buildCmdExeCommandLine } from "../../../windows-cmd-helpers.mjs"; +import { buildCmdExeCommandLine, resolveWindowsCmdExePath } from "../../../windows-cmd-helpers.mjs"; const args = process.argv.slice(2); const command = args.shift(); @@ -215,7 +215,7 @@ function adaptStepForBaseline(step, baselineVersion, summary) { export function resolveUpgradeSurvivorOpenClawCommand(argv, params = {}) { const platform = params.platform ?? process.platform; if (platform === "win32") { - const comSpec = params.comSpec ?? process.env.ComSpec ?? "cmd.exe"; + const comSpec = params.comSpec ?? resolveWindowsCmdExePath(params.env ?? process.env); return { command: comSpec, args: ["/d", "/s", "/c", buildCmdExeCommandLine("openclaw.cmd", argv)], diff --git a/scripts/e2e/parallels/host-command.ts b/scripts/e2e/parallels/host-command.ts index d30243168d6a..b9b5d5bbd442 100644 --- a/scripts/e2e/parallels/host-command.ts +++ b/scripts/e2e/parallels/host-command.ts @@ -6,7 +6,7 @@ import { finished } from "node:stream/promises"; import { fileURLToPath } from "node:url"; import { resolveNpmRunner } from "../../npm-runner.mjs"; import { resolvePnpmRunner } from "../../pnpm-runner.mjs"; -import { buildCmdExeCommandLine } from "../../windows-cmd-helpers.mjs"; +import { buildCmdExeCommandLine, resolveWindowsCmdExePath } from "../../windows-cmd-helpers.mjs"; import type { CommandResult, RunOptions } from "./types.ts"; export const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); @@ -338,11 +338,6 @@ function isBareCommand(command: string, name: "npm" | "pnpm"): boolean { return portableBasename(command) === command && command.toLowerCase() === name; } -function resolveEnvValue(env: NodeJS.ProcessEnv, name: string): string | undefined { - const key = Object.keys(env).find((candidate) => candidate.toLowerCase() === name.toLowerCase()); - return key === undefined ? undefined : env[key]; -} - export function resolveHostCommandInvocation( command: string, args: string[], @@ -350,7 +345,7 @@ export function resolveHostCommandInvocation( ): HostCommandInvocation { const env = options.env ?? process.env; const platform = options.platform ?? process.platform; - const comSpec = options.comSpec ?? resolveEnvValue(env, "ComSpec") ?? "cmd.exe"; + const comSpec = options.comSpec ?? resolveWindowsCmdExePath(env); if (isBareCommand(command, "pnpm")) { const runner = resolvePnpmRunner({ diff --git a/scripts/ensure-playwright-chromium.mjs b/scripts/ensure-playwright-chromium.mjs index c0e472784c7a..e398476248dc 100644 --- a/scripts/ensure-playwright-chromium.mjs +++ b/scripts/ensure-playwright-chromium.mjs @@ -52,7 +52,7 @@ export function resolvePlaywrightInstallRunner(options = {}) { const env = options.env ?? process.env; const targets = options.targets ?? ["chromium"]; return resolvePnpmRunner({ - comSpec: options.comSpec ?? env.ComSpec ?? env.COMSPEC, + comSpec: options.comSpec, env, npmExecPath: env === process.env ? env.npm_execpath : (env.npm_execpath ?? ""), platform: options.platform, diff --git a/scripts/format-docs.mjs b/scripts/format-docs.mjs index 72a70e7d797c..4cad64c6c0a3 100644 --- a/scripts/format-docs.mjs +++ b/scripts/format-docs.mjs @@ -7,7 +7,7 @@ import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { repairMintlifyAccordionIndentation } from "./lib/mintlify-accordion.mjs"; -import { buildCmdExeCommandLine } from "./windows-cmd-helpers.mjs"; +import { buildCmdExeCommandLine, resolveWindowsCmdExePath } from "./windows-cmd-helpers.mjs"; const ROOT = path.resolve(import.meta.dirname, ".."); const CHECK = process.argv.includes("--check"); @@ -128,7 +128,7 @@ export function resolveOxfmtInvocation(args, params = {}) { if (existsSync(shimPath)) { if (platform === "win32") { - const comSpec = params.comSpec ?? process.env.ComSpec ?? "cmd.exe"; + const comSpec = params.comSpec ?? resolveWindowsCmdExePath(params.env ?? process.env); return { command: comSpec, args: ["/d", "/s", "/c", buildCmdExeCommandLine(shimPath, args)], diff --git a/scripts/lib/codex-app-server-protocol-source.ts b/scripts/lib/codex-app-server-protocol-source.ts index f2ffe3478415..f55f3d75d77c 100644 --- a/scripts/lib/codex-app-server-protocol-source.ts +++ b/scripts/lib/codex-app-server-protocol-source.ts @@ -42,18 +42,13 @@ type ResolvePnpmCommandOptions = { platform?: NodeJS.Platform; }; -function resolveEnvValue(env: NodeJS.ProcessEnv, name: string): string | undefined { - const key = Object.keys(env).find((candidate) => candidate.toLowerCase() === name.toLowerCase()); - return key === undefined ? undefined : env[key]; -} - export function resolveCodexProtocolPnpmCommand( args: string[], options: ResolvePnpmCommandOptions = {}, ): PnpmCommand { const env = options.env ?? process.env; const command = resolvePnpmRunner({ - comSpec: options.comSpec ?? resolveEnvValue(env, "ComSpec"), + comSpec: options.comSpec, env, npmExecPath: options.npmExecPath ?? env.npm_execpath, nodeExecPath: options.execPath ?? process.execPath, diff --git a/scripts/lib/managed-child-process.mjs b/scripts/lib/managed-child-process.mjs index 17e4732eb55d..452f68d50dc4 100644 --- a/scripts/lib/managed-child-process.mjs +++ b/scripts/lib/managed-child-process.mjs @@ -1,7 +1,7 @@ // Runs child commands with process-group signal forwarding and Windows shell normalization. import { spawn, spawnSync } from "node:child_process"; import { constants as osConstants } from "node:os"; -import { buildCmdExeCommandLine } from "../windows-cmd-helpers.mjs"; +import { buildCmdExeCommandLine, resolveWindowsCmdExePath } from "../windows-cmd-helpers.mjs"; import { resolveWindowsTaskkillPath } from "./windows-taskkill.mjs"; const FORWARDED_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"]; @@ -276,7 +276,7 @@ export function createManagedCommandInvocation({ if (platform === "win32" && shell && args.length > 0) { return { args: ["/d", "/s", "/c", buildCmdExeCommandLine(bin, args)], - command: comSpec ?? env?.ComSpec ?? env?.COMSPEC ?? process.env.ComSpec ?? "cmd.exe", + command: comSpec ?? resolveWindowsCmdExePath(env ?? process.env), shell: false, windowsVerbatimArguments: true, }; diff --git a/scripts/lib/windows-taskkill.mjs b/scripts/lib/windows-taskkill.mjs index 4a5e5b710eed..f4cebb56aefc 100644 --- a/scripts/lib/windows-taskkill.mjs +++ b/scripts/lib/windows-taskkill.mjs @@ -1,64 +1,11 @@ -// Resolves the Windows taskkill binary without trusting PATH. -import path from "node:path"; +// Resolves Windows system binaries without trusting PATH. +import { + resolveWindowsPowerShellPath, + resolveWindowsSystem32Path, +} from "../windows-cmd-helpers.mjs"; -const DEFAULT_WINDOWS_SYSTEM_ROOT = "C:\\Windows"; - -function getEnvValueCaseInsensitive(env, expectedKey) { - const direct = env[expectedKey]; - if (direct !== undefined) { - return direct; - } - const expected = expectedKey.toUpperCase(); - const actualKey = Object.keys(env).find((key) => key.toUpperCase() === expected); - return actualKey ? env[actualKey] : undefined; -} - -function normalizeWindowsSystemRoot(raw) { - const trimmed = raw?.trim(); - if ( - !trimmed || - trimmed.includes("\0") || - trimmed.includes("\r") || - trimmed.includes("\n") || - trimmed.includes(";") - ) { - return null; - } - const normalized = path.win32.normalize(trimmed); - if (!path.win32.isAbsolute(normalized) || normalized.startsWith("\\\\")) { - return null; - } - const parsed = path.win32.parse(normalized); - if (!/^[A-Za-z]:\\$/.test(parsed.root) || normalized.length <= parsed.root.length) { - return null; - } - return normalized.replace(/[\\/]+$/, ""); -} +export { resolveWindowsPowerShellPath, resolveWindowsSystem32Path }; export function resolveWindowsTaskkillPath(env = process.env) { return resolveWindowsSystem32Path("taskkill.exe", env); } - -function resolveWindowsSystemRoot(env) { - return ( - normalizeWindowsSystemRoot(getEnvValueCaseInsensitive(env, "SystemRoot")) ?? - normalizeWindowsSystemRoot(getEnvValueCaseInsensitive(env, "WINDIR")) ?? - DEFAULT_WINDOWS_SYSTEM_ROOT - ); -} - -export function resolveWindowsSystem32Path(executableName, env = process.env) { - if ( - path.win32.basename(executableName) !== executableName || - !/^[A-Za-z0-9_.-]+\.exe$/u.test(executableName) - ) { - throw new Error(`Invalid Windows System32 executable name: ${executableName}`); - } - const systemRoot = resolveWindowsSystemRoot(env); - return path.win32.join(systemRoot, "System32", executableName); -} - -export function resolveWindowsPowerShellPath(env = process.env) { - const systemRoot = resolveWindowsSystemRoot(env); - return path.win32.join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"); -} diff --git a/scripts/npm-runner.mjs b/scripts/npm-runner.mjs index 597e41cf6efc..9c60632b387d 100644 --- a/scripts/npm-runner.mjs +++ b/scripts/npm-runner.mjs @@ -1,7 +1,11 @@ // Resolves npm commands from the active Node toolchain, especially on Windows. import fs from "node:fs"; import path from "node:path"; -import { buildCmdExeCommandLine, resolvePathEnvKey } from "./windows-cmd-helpers.mjs"; +import { + buildCmdExeCommandLine, + resolvePathEnvKey, + resolveWindowsCmdExePath, +} from "./windows-cmd-helpers.mjs"; function resolveToolchainNpmRunner(params) { const npmCliCandidates = [ @@ -48,7 +52,7 @@ export function resolveNpmRunner(params = {}) { const existsSync = params.existsSync ?? fs.existsSync; const env = params.env ?? process.env; const platform = params.platform ?? process.platform; - const comSpec = params.comSpec ?? env.ComSpec ?? "cmd.exe"; + const comSpec = params.comSpec ?? (platform === "win32" ? resolveWindowsCmdExePath(env) : ""); const pathImpl = platform === "win32" ? path.win32 : path.posix; const nodeDir = pathImpl.dirname(execPath); const npmToolchain = resolveToolchainNpmRunner({ diff --git a/scripts/openclaw-cross-os-release-checks.ts b/scripts/openclaw-cross-os-release-checks.ts index fc05b79fbb6f..e4e6dbd99b17 100644 --- a/scripts/openclaw-cross-os-release-checks.ts +++ b/scripts/openclaw-cross-os-release-checks.ts @@ -30,7 +30,7 @@ import { basename, dirname, join, relative, resolve, win32 as pathWin32 } from " import { fileURLToPath, pathToFileURL } from "node:url"; import { isLocalBuildMetadataDistPath } from "./lib/local-build-metadata-paths.mjs"; import { resolveWindowsTaskkillPath } from "./lib/windows-taskkill.mjs"; -import { buildCmdExeCommandLine } from "./windows-cmd-helpers.mjs"; +import { buildCmdExeCommandLine, resolveWindowsCmdExePath } from "./windows-cmd-helpers.mjs"; const SCRIPT_PATH = fileURLToPath(import.meta.url); const PUBLISHED_INSTALLER_BASE_URL = "https://openclaw.ai"; @@ -1780,13 +1780,12 @@ export function resolveCommandSpawnInvocation( args, options = { platform: process.platform, - comSpec: process.env.ComSpec, }, ) { const platform = options.platform ?? process.platform; if (platform === "win32" && /\.(cmd|bat)$/iu.test(command)) { return { - command: options.comSpec ?? process.env.ComSpec ?? "cmd.exe", + command: options.comSpec ?? resolveWindowsCmdExePath(options.env ?? process.env), args: ["/d", "/s", "/c", buildCmdExeCommandLine(command, args)], shell: false, windowsVerbatimArguments: true, @@ -1800,7 +1799,6 @@ export function resolveInstalledCliInvocation( args = [], options = { platform: process.platform, - comSpec: process.env.ComSpec, }, ) { const platform = options.platform ?? process.platform; @@ -1823,6 +1821,7 @@ export function resolveInstalledCliInvocation( } return resolveCommandSpawnInvocation(normalizedCliPath, args, { comSpec: options.comSpec, + env: options.env, platform, }); } @@ -2029,7 +2028,7 @@ async function verifyFreshShellCommand(params) { async function runInstalledCli(params) { const invocation = resolveInstalledCliInvocation(params.cliPath, params.args, { - comSpec: params.env?.ComSpec ?? params.env?.COMSPEC, + env: params.env, platform: process.platform, }); return runCommandInvocation(invocation, { @@ -2120,7 +2119,7 @@ async function startManualGatewayFromInstalledCli(params) { params.cliPath, ["gateway", "run", "--bind", "loopback", "--port", String(params.lane.gatewayPort), "--force"], { - comSpec: params.env?.ComSpec ?? params.env?.COMSPEC, + env: params.env, platform: process.platform, }, ); @@ -3897,7 +3896,7 @@ function appendBoundedCommandOutput(current, chunk, maxBytes) { export async function runCommand(command, args, options) { const invocation = resolveCommandSpawnInvocation(command, args, { - comSpec: options.env?.ComSpec ?? options.env?.COMSPEC, + env: options.env, platform: process.platform, }); return runCommandInvocation(invocation, options); diff --git a/scripts/openclaw-npm-postpublish-verify.ts b/scripts/openclaw-npm-postpublish-verify.ts index 20c7f63bb519..93358e911de8 100644 --- a/scripts/openclaw-npm-postpublish-verify.ts +++ b/scripts/openclaw-npm-postpublish-verify.ts @@ -35,7 +35,7 @@ import { } from "./lib/plugin-package-dependencies.mjs"; import { runInstalledWorkspaceBootstrapSmoke } from "./lib/workspace-bootstrap-smoke.mjs"; import { parseReleaseVersion, resolveNpmCommandInvocation } from "./openclaw-npm-release-check.ts"; -import { buildCmdExeCommandLine } from "./windows-cmd-helpers.mjs"; +import { buildCmdExeCommandLine, resolveWindowsCmdExePath } from "./windows-cmd-helpers.mjs"; type InstalledPackageJson = { version?: string; @@ -853,7 +853,7 @@ export function resolveInstalledBinaryCommandInvocation( const binaryPath = resolveInstalledBinaryPath(prefixDir, platform); if (platform === "win32") { return { - command: params.comSpec ?? process.env.ComSpec ?? "cmd.exe", + command: params.comSpec ?? resolveWindowsCmdExePath(), args: ["/d", "/s", "/c", buildCmdExeCommandLine(binaryPath, args)], windowsVerbatimArguments: true, }; diff --git a/scripts/openclaw-npm-release-check.ts b/scripts/openclaw-npm-release-check.ts index b26d5b971bcf..b983c4948ce7 100644 --- a/scripts/openclaw-npm-release-check.ts +++ b/scripts/openclaw-npm-release-check.ts @@ -17,7 +17,7 @@ import { parseReleaseVersion as parseReleaseVersionBase, } from "./lib/npm-publish-plan.mjs"; import { WORKSPACE_TEMPLATE_PACK_PATHS } from "./lib/workspace-bootstrap-smoke.mjs"; -import { buildCmdExeCommandLine } from "./windows-cmd-helpers.mjs"; +import { buildCmdExeCommandLine, resolveWindowsCmdExePath } from "./windows-cmd-helpers.mjs"; type PackageJson = { name?: string; @@ -508,7 +508,7 @@ export function resolveNpmCommandInvocation( const name = portableBasename(npmExecPath).toLowerCase(); if (platform === "win32" && (name.endsWith(".cmd") || name.endsWith(".bat"))) { return { - command: params.comSpec ?? process.env.ComSpec ?? "cmd.exe", + command: params.comSpec ?? resolveWindowsCmdExePath(), args: ["/d", "/s", "/c", buildCmdExeCommandLine(npmExecPath, npmArgs)], windowsVerbatimArguments: true, }; @@ -518,7 +518,7 @@ export function resolveNpmCommandInvocation( } if (platform === "win32" && name === "npm") { return { - command: params.comSpec ?? process.env.ComSpec ?? "cmd.exe", + command: params.comSpec ?? resolveWindowsCmdExePath(), args: ["/d", "/s", "/c", buildCmdExeCommandLine(`${npmExecPath}.cmd`, npmArgs)], windowsVerbatimArguments: true, }; @@ -531,7 +531,7 @@ export function resolveNpmCommandInvocation( if (platform === "win32") { return { - command: params.comSpec ?? process.env.ComSpec ?? "cmd.exe", + command: params.comSpec ?? resolveWindowsCmdExePath(), args: ["/d", "/s", "/c", buildCmdExeCommandLine("npm.cmd", npmArgs)], windowsVerbatimArguments: true, }; diff --git a/scripts/pnpm-runner.mjs b/scripts/pnpm-runner.mjs index 2cf1424da7e8..cee9c7facf4c 100644 --- a/scripts/pnpm-runner.mjs +++ b/scripts/pnpm-runner.mjs @@ -2,7 +2,11 @@ import { spawn } from "node:child_process"; import { accessSync, closeSync, constants, openSync, readSync, statSync } from "node:fs"; import path from "node:path"; -import { buildCmdExeCommandLine, resolvePathEnvKey } from "./windows-cmd-helpers.mjs"; +import { + buildCmdExeCommandLine, + resolvePathEnvKey, + resolveWindowsCmdExePath, +} from "./windows-cmd-helpers.mjs"; function getPortableBasename(value) { return value.split(/[/\\]/).at(-1) ?? value; @@ -61,8 +65,10 @@ function findExecutableOnPath(command, envPath, platform, env, cwd) { } const extensions = platform === "win32" - ? (env[Object.keys(env).find((key) => key.toLowerCase() === "pathext") ?? "PATHEXT"] ?? - ".COM;.EXE;.BAT;.CMD") + ? ( + env[Object.keys(env).find((key) => key.toLowerCase() === "pathext") ?? "PATHEXT"] ?? + ".COM;.EXE;.BAT;.CMD" + ) .split(";") .filter(Boolean) .map((extension) => extension.toLowerCase()) @@ -75,7 +81,7 @@ function findExecutableOnPath(command, envPath, platform, env, cwd) { const resolvedDirectory = path.isAbsolute(directory) ? directory : path.resolve(cwd, directory); for (const extension of extensions) { const candidate = path.join(resolvedDirectory, `${command}${extension}`); - if ((platform === "win32" ? isFile(candidate) : isExecutableFile(candidate))) { + if (platform === "win32" ? isFile(candidate) : isExecutableFile(candidate)) { return candidate; } } @@ -119,8 +125,8 @@ export function resolvePnpmRunner(params = {}) { const npmExecPath = params.npmExecPath ?? process.env.npm_execpath; const nodeExecPath = params.nodeExecPath ?? process.execPath; const platform = params.platform ?? process.platform; - const comSpec = params.comSpec ?? process.env.ComSpec ?? "cmd.exe"; const env = params.env ?? process.env; + const comSpec = params.comSpec ?? (platform === "win32" ? resolveWindowsCmdExePath(env) : ""); const envPath = env[platform === "win32" ? resolvePathEnvKey(env) : "PATH"]; const cwd = params.cwd ?? process.cwd(); diff --git a/scripts/tsdown-build.mjs b/scripts/tsdown-build.mjs index 362eff751ba3..2198670def01 100644 --- a/scripts/tsdown-build.mjs +++ b/scripts/tsdown-build.mjs @@ -579,7 +579,7 @@ export function resolveTsdownBuildInvocation(params = {}) { pnpmArgs: ["exec", "tsdown", ...tsdownArgs], nodeExecPath: params.nodeExecPath ?? process.execPath, npmExecPath: params.npmExecPath ?? env.npm_execpath, - comSpec: params.comSpec ?? env.ComSpec, + comSpec: params.comSpec, platform: params.platform ?? process.platform, }); return { diff --git a/scripts/ui.js b/scripts/ui.js index 7e2bca0c20b5..1f65d79c6272 100644 --- a/scripts/ui.js +++ b/scripts/ui.js @@ -6,7 +6,7 @@ import { createRequire } from "node:module"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { resolvePnpmRunner } from "./pnpm-runner.mjs"; -import { buildCmdExeCommandLine } from "./windows-cmd-helpers.mjs"; +import { buildCmdExeCommandLine, resolveWindowsCmdExePath } from "./windows-cmd-helpers.mjs"; const here = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(here, ".."); @@ -35,7 +35,6 @@ export function shouldUseCmdExeForCommand(cmd, platform = process.platform) { */ 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", @@ -44,6 +43,7 @@ export function resolveSpawnCall(cmd, args, envOverride, params = {}) { }; if (shouldUseCmdExeForCommand(cmd, platform)) { + const comSpec = params.comSpec ?? resolveWindowsCmdExePath(options.env); return { command: comSpec, args: ["/d", "/s", "/c", buildCmdExeCommandLine(cmd, args)], @@ -74,7 +74,7 @@ export function resolvePnpmSpawnCall(pnpmArgs, envOverride, params = {}) { pnpmArgs, nodeExecPath: params.nodeExecPath ?? process.execPath, npmExecPath: params.npmExecPath ?? env.npm_execpath, - comSpec: params.comSpec ?? env.ComSpec, + comSpec: params.comSpec, platform, }); return { diff --git a/scripts/windows-cmd-helpers.d.mts b/scripts/windows-cmd-helpers.d.mts index 8945cf560461..638f498b3bf7 100644 --- a/scripts/windows-cmd-helpers.d.mts +++ b/scripts/windows-cmd-helpers.d.mts @@ -1,2 +1,6 @@ export function resolvePathEnvKey(env: NodeJS.ProcessEnv): string; +export function resolveWindowsSystemRoot(env?: NodeJS.ProcessEnv): string; +export function resolveWindowsSystem32Path(executableName: string, env?: NodeJS.ProcessEnv): string; +export function resolveWindowsCmdExePath(env?: NodeJS.ProcessEnv): string; +export function resolveWindowsPowerShellPath(env?: NodeJS.ProcessEnv): string; export function buildCmdExeCommandLine(command: string, args: string[]): string; diff --git a/scripts/windows-cmd-helpers.mjs b/scripts/windows-cmd-helpers.mjs index 43ae6e2422cb..80871dcca010 100644 --- a/scripts/windows-cmd-helpers.mjs +++ b/scripts/windows-cmd-helpers.mjs @@ -1,5 +1,40 @@ // Windows cmd.exe quoting helpers for npm/pnpm command shims. +import path from "node:path"; + const WINDOWS_UNSAFE_CMD_CHARS_RE = /[&|<>%\r\n]/; +const DEFAULT_WINDOWS_SYSTEM_ROOT = "C:\\Windows"; + +function getEnvValueCaseInsensitive(env, expectedKey) { + const direct = env[expectedKey]; + if (direct !== undefined) { + return direct; + } + const expected = expectedKey.toUpperCase(); + const actualKey = Object.keys(env).find((key) => key.toUpperCase() === expected); + return actualKey ? env[actualKey] : undefined; +} + +function normalizeWindowsSystemRoot(raw) { + const trimmed = raw?.trim(); + if ( + !trimmed || + trimmed.includes("\0") || + trimmed.includes("\r") || + trimmed.includes("\n") || + trimmed.includes(";") + ) { + return null; + } + const normalized = path.win32.normalize(trimmed); + if (!path.win32.isAbsolute(normalized) || normalized.startsWith("\\\\")) { + return null; + } + const parsed = path.win32.parse(normalized); + if (!/^[A-Za-z]:\\$/.test(parsed.root) || normalized.length <= parsed.root.length) { + return null; + } + return normalized.replace(/[\\/]+$/, ""); +} /** * Resolves the correctly cased PATH key in a Windows-style env object. @@ -8,6 +43,38 @@ export function resolvePathEnvKey(env) { return Object.keys(env).find((key) => key.toLowerCase() === "path") ?? "PATH"; } +export function resolveWindowsSystemRoot(env = process.env) { + return ( + normalizeWindowsSystemRoot(getEnvValueCaseInsensitive(env, "SystemRoot")) ?? + normalizeWindowsSystemRoot(getEnvValueCaseInsensitive(env, "WINDIR")) ?? + DEFAULT_WINDOWS_SYSTEM_ROOT + ); +} + +export function resolveWindowsSystem32Path(executableName, env = process.env) { + if ( + path.win32.basename(executableName) !== executableName || + !/^[A-Za-z0-9_.-]+\.exe$/u.test(executableName) + ) { + throw new Error(`Invalid Windows System32 executable name: ${executableName}`); + } + return path.win32.join(resolveWindowsSystemRoot(env), "System32", executableName); +} + +export function resolveWindowsCmdExePath(env = process.env) { + return resolveWindowsSystem32Path("cmd.exe", env); +} + +export function resolveWindowsPowerShellPath(env = process.env) { + return path.win32.join( + resolveWindowsSystemRoot(env), + "System32", + "WindowsPowerShell", + "v1.0", + "powershell.exe", + ); +} + function escapeForCmdExe(arg) { if (WINDOWS_UNSAFE_CMD_CHARS_RE.test(arg)) { throw new Error(`unsafe Windows cmd.exe argument detected: ${JSON.stringify(arg)}`); diff --git a/scripts/zai-fallback-repro.ts b/scripts/zai-fallback-repro.ts index e1635fbeca76..3fd17fee73de 100644 --- a/scripts/zai-fallback-repro.ts +++ b/scripts/zai-fallback-repro.ts @@ -53,11 +53,6 @@ type RunZaiFallbackReproDeps = { writeFile?: typeof fs.writeFile; }; -function resolveEnvValue(env: NodeJS.ProcessEnv, name: string): string | undefined { - const key = Object.keys(env).find((candidate) => candidate.toLowerCase() === name.toLowerCase()); - return key === undefined ? undefined : env[key]; -} - export function appendBoundedReproOutput( capture: OutputCapture, chunk: unknown, @@ -124,7 +119,7 @@ export function resolveZaiFallbackPnpmCommand( ): PnpmCommand { const env = options.env ?? process.env; const command = resolvePnpmRunner({ - comSpec: options.comSpec ?? resolveEnvValue(env, "ComSpec"), + comSpec: options.comSpec, env, npmExecPath: options.npmExecPath ?? env.npm_execpath, nodeExecPath: options.execPath ?? process.execPath, diff --git a/test/scripts/npm-runner.test.ts b/test/scripts/npm-runner.test.ts index 258ac6bd9ff5..78acf5eb9452 100644 --- a/test/scripts/npm-runner.test.ts +++ b/test/scripts/npm-runner.test.ts @@ -129,6 +129,24 @@ describe("resolveNpmRunner", () => { }); }); + it("ignores ambient ComSpec when wrapping an adjacent npm.cmd on Windows", () => { + const execPath = "C:\\nodejs\\node.exe"; + const npmCmdPath = path.win32.resolve(path.win32.dirname(execPath), "npm.cmd"); + + const runner = resolveNpmRunner({ + env: { + ComSpec: "C:\\Users\\test\\bin\\cmd.exe", + SystemRoot: "D:\\Windows", + }, + execPath, + existsSync: (candidate) => candidate === npmCmdPath, + npmArgs: ["install"], + platform: "win32", + }); + + expect(runner.command).toBe("D:\\Windows\\System32\\cmd.exe"); + }); + it("prefixes PATH with the active node dir when falling back to bare npm", () => { expect( resolveNpmRunner({ diff --git a/test/scripts/openclaw-cross-os-release-checks.test.ts b/test/scripts/openclaw-cross-os-release-checks.test.ts index ac159fffc723..4d5be2c1caa4 100644 --- a/test/scripts/openclaw-cross-os-release-checks.test.ts +++ b/test/scripts/openclaw-cross-os-release-checks.test.ts @@ -1161,6 +1161,32 @@ describe("scripts/openclaw-cross-os-release-checks", () => { }); }); + it("does not trust ambient ComSpec when wrapping Windows cmd shims", () => { + const originalComSpec = process.env.ComSpec; + const originalSystemRoot = process.env.SystemRoot; + try { + process.env.ComSpec = String.raw`C:\Users\test\bin\cmd.exe`; + process.env.SystemRoot = String.raw`D:\Windows`; + + expect( + resolveCommandSpawnInvocation(String.raw`C:\Program Files\nodejs\npm.cmd`, ["--version"], { + platform: "win32", + }).command, + ).toBe(String.raw`D:\Windows\System32\cmd.exe`); + } finally { + if (originalComSpec === undefined) { + delete process.env.ComSpec; + } else { + process.env.ComSpec = originalComSpec; + } + if (originalSystemRoot === undefined) { + delete process.env.SystemRoot; + } else { + process.env.SystemRoot = originalSystemRoot; + } + } + }); + it("wraps installed Windows CLI cmd fallbacks without Node shell argv", () => { expect( resolveInstalledCliInvocation( diff --git a/test/scripts/parallels-smoke-model.test.ts b/test/scripts/parallels-smoke-model.test.ts index b75cf588001c..2c50b7217cb7 100644 --- a/test/scripts/parallels-smoke-model.test.ts +++ b/test/scripts/parallels-smoke-model.test.ts @@ -1293,8 +1293,9 @@ if (isPrlctl) { expect(windowsGit.indexOf('"MinGit-2.53.0.2-64-bit.zip"')).toBeLessThan( windowsGit.indexOf('"MinGit-2.53.0.2-arm64.zip"'), ); - expect(combined.match(/curl\.exe -fsSL --connect-timeout 10 --max-time 120 --retry 2/g)) - .toHaveLength(2); + expect( + combined.match(/curl\.exe -fsSL --connect-timeout 10 --max-time 120 --retry 2/g), + ).toHaveLength(2); expect(script).toContain("Invoke-RestMethod -Uri"); expect(script).toContain("-TimeoutSec 120"); expect(windowsGit).toContain('if "-64-bit." in name:'); @@ -1907,6 +1908,18 @@ setInterval(() => {}, 1000); }); }); + it("ignores ambient ComSpec for Windows host batch commands", () => { + expect( + resolveHostCommandInvocation("C:\\Tools\\helper.cmd", ["build"], { + env: { + ComSpec: "C:\\Users\\test\\bin\\cmd.exe", + SystemRoot: "D:\\Windows", + }, + platform: "win32", + }).command, + ).toBe("D:\\Windows\\System32\\cmd.exe"); + }); + it("runs the Windows agent turn through the detached done-file runner", () => { const script = readFileSync(TS_PATHS.windows, "utf8"); diff --git a/test/scripts/pnpm-runner.test.ts b/test/scripts/pnpm-runner.test.ts index 6268e4ecdc72..08218765a540 100644 --- a/test/scripts/pnpm-runner.test.ts +++ b/test/scripts/pnpm-runner.test.ts @@ -328,6 +328,26 @@ describe("resolvePnpmRunner", () => { }); }); + it("ignores ambient ComSpec when defaulting the Windows cmd shim launcher", () => { + expect( + resolvePnpmRunner({ + env: { + ComSpec: "C:\\Users\\test\\bin\\cmd.exe", + PATH: "", + SystemRoot: "D:\\Windows", + }, + npmExecPath: "", + pnpmArgs: ["exec", "vitest", "run"], + platform: "win32", + }), + ).toEqual({ + command: "D:\\Windows\\System32\\cmd.exe", + args: ["/d", "/s", "/c", "pnpm.cmd exec vitest run"], + shell: false, + windowsVerbatimArguments: true, + }); + }); + it("uses Corepack on Windows when no pnpm shim is available", () => { const tempDir = mkdtempSync(path.join(os.tmpdir(), "pnpm-runner-corepack-")); const corepackPath = path.join(tempDir, "corepack.cmd"); diff --git a/test/scripts/ui.test.ts b/test/scripts/ui.test.ts index 31c4356ce9e5..6c438f715dd3 100644 --- a/test/scripts/ui.test.ts +++ b/test/scripts/ui.test.ts @@ -111,6 +111,20 @@ describe("scripts/ui windows spawn behavior", () => { ).toThrow(/unsafe windows cmd\.exe argument/i); }); + it("uses a trusted cmd.exe path when no explicit Windows launcher is injected", () => { + expect( + resolveSpawnCall( + "C:\\tools\\pnpm.cmd", + ["run", "build"], + { + ComSpec: "C:\\Users\\test\\bin\\cmd.exe", + SystemRoot: "D:\\Windows", + }, + { cwd: "C:\\repo\\ui", platform: "win32" }, + ).command, + ).toBe("D:\\Windows\\System32\\cmd.exe"); + }); + it("routes Windows Corepack pnpm entrypoints through node", () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-pnpm-runner-")); const npmExecPath = path.join(tempDir, "pnpm.mjs");