Files
openclaw/src/process/windows-command.ts
Peter Steinberger 4f287dd740 refactor(process): adopt Execa execution layer (#105939)
* refactor(process): adopt execa execution layer

* fix(process): preserve launch error semantics

* chore(process): ratchet exec size baseline

* fix(process): preserve Windows and error contracts

* chore(plugin-sdk): ratchet wildcard surface budget

* test(process): allow resolved Windows executable paths

* fix(process): preserve Windows shim completion

* test(process): isolate Execa mocks

* style(process): format Windows exec test

* style(process): apply Windows test formatting

* test(process): normalize Windows system path casing

* fix(process): harden execa edge handling

* test(tui): preserve ESM fixture semantics

* fix(process): preserve PATHEXT lookup contract

* fix(process): keep invocation type internal
2026-07-13 02:21:08 -07:00

227 lines
7.9 KiB
TypeScript

// Windows command helpers resolve executable and shell invocation details.
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
import { expectDefined } from "@openclaw/normalization-core";
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import {
isRegularFile,
resolveExecutableFromPathEnv,
resolveExecutablePathCandidate,
} from "../infra/executable-path.js";
import { getWindowsCmdExePath } from "../infra/windows-install-roots.js";
const WINDOWS_UNSAFE_CMD_CHARS_RE = /[&|<>%\r\n]/;
function resolveNpmArgvForWindows(argv: string[]): string[] | null {
if (process.platform !== "win32" || argv.length === 0) {
return null;
}
const basename = normalizeLowercaseStringOrEmpty(
path.basename(expectDefined(argv[0], "argv entry at 0")),
).replace(/\.(cmd|exe|bat)$/, "");
const cliName = basename === "npx" ? "npx-cli.js" : basename === "npm" ? "npm-cli.js" : null;
if (!cliName) {
return null;
}
const cliPath = path.join(path.dirname(process.execPath), "node_modules", "npm", "bin", cliName);
if (fs.existsSync(cliPath)) {
return [process.execPath, cliPath, ...argv.slice(1)];
}
// Bun-based runs don't ship npm-cli.js next to process.execPath. The .cmd
// fallback remains inside the trusted cmd.exe wrapper below.
const command = argv[0] ?? "";
const extension = normalizeLowercaseStringOrEmpty(path.extname(command));
return [extension ? command : `${command}.cmd`, ...argv.slice(1)];
}
function createWindowsCommandNotFoundError(command: string): NodeJS.ErrnoException {
const error = new Error(`spawn ${command} ENOENT`) as NodeJS.ErrnoException;
error.code = "ENOENT";
error.path = command;
error.syscall = `spawn ${command}`;
return error;
}
function resolveWindowsEnvironmentValue(env: NodeJS.ProcessEnv, name: string): string | undefined {
const normalizedName = name.toLowerCase();
return Object.entries(env).find(([key]) => key.toLowerCase() === normalizedName)?.[1];
}
function resolveWindowsCommandFromCwdOrPath(params: {
command: string;
cwd?: string;
env: NodeJS.ProcessEnv;
}): string | undefined {
const hasPathSeparator = params.command.includes("/") || params.command.includes("\\");
if (hasPathSeparator) {
const candidate = resolveExecutablePathCandidate(params.command, {
cwd: params.cwd,
env: params.env,
});
if (!candidate) {
return undefined;
}
// PATHEXT controls suffix probing, not whether an explicit path is executable.
// The supported-extension check below still rejects shell/script formats.
if (path.extname(candidate)) {
return isRegularFile(candidate) ? candidate : undefined;
}
return resolveExecutableFromPathEnv(
path.basename(candidate),
path.dirname(candidate),
params.env,
{ includeExtensionless: false },
);
}
const cwd = params.cwd?.trim() || process.cwd();
const pathValue =
resolveWindowsEnvironmentValue(params.env, "PATH") ??
resolveWindowsEnvironmentValue(process.env, "PATH") ??
"";
const pathEntries = pathValue
.split(";")
.map((entry) => entry.replace(/^"(.*)"$/, "$1").trim())
.filter(Boolean)
.map((entry) => (path.isAbsolute(entry) ? entry : path.resolve(cwd, entry)));
// Bare names search PATH only. Requiring an explicit relative path prevents
// an untrusted child cwd from shadowing installed tools such as git or node.
return resolveExecutableFromPathEnv(params.command, pathEntries.join(";"), params.env, {
includeExtensionless: false,
});
}
function resolveSupportedWindowsCommand(params: {
command: string;
cwd?: string;
env: NodeJS.ProcessEnv;
}): string {
if (process.platform !== "win32") {
return params.command;
}
let resolved = resolveWindowsCommandFromCwdOrPath(params);
const shimmedCommand = resolveWindowsCommandShim({
command: params.command,
cmdCommands: ["corepack", "pnpm", "yarn"],
});
if (!resolved && shimmedCommand !== params.command) {
resolved = resolveWindowsCommandFromCwdOrPath({ ...params, command: shimmedCommand });
}
if (!resolved) {
// Execa delegates Windows parsing to cross-spawn, which otherwise falls
// through to ambient ComSpec even with shell:false.
throw createWindowsCommandNotFoundError(params.command);
}
const extension = normalizeLowercaseStringOrEmpty(path.extname(resolved));
if ([".exe", ".com", ".cmd", ".bat"].includes(extension)) {
return resolved;
}
throw new Error(
`Unsupported Windows command extension ${JSON.stringify(extension || "<none>")} for ${JSON.stringify(params.command)}; use an explicit executable or shell wrapper.`,
);
}
type SafeChildProcessInvocation = {
args: string[];
command: string;
usesWindowsExitCodeShim: boolean;
windowsHide: true;
windowsVerbatimArguments?: boolean;
};
/** Resolve one shell-free invocation before Execa can apply Windows fallbacks. */
export function resolveSafeChildProcessInvocation(params: {
argv: string[];
cwd?: string | URL;
env: NodeJS.ProcessEnv;
windowsVerbatimArguments?: boolean;
}): SafeChildProcessInvocation {
const finalArgv = resolveNpmArgvForWindows(params.argv) ?? params.argv;
const cwd = params.cwd instanceof URL ? fileURLToPath(params.cwd) : params.cwd;
const resolvedCommand = resolveSupportedWindowsCommand({
command: finalArgv[0] ?? "",
cwd,
env: params.env,
});
const useCmdWrapper = isWindowsBatchCommand(resolvedCommand);
const command = useCmdWrapper
? resolveSupportedWindowsCommand({
command: resolveTrustedWindowsCmdExe(),
cwd,
env: params.env,
})
: resolvedCommand;
return {
command,
args: useCmdWrapper
? ["/d", "/s", "/c", buildWindowsCmdExeCommandLine(resolvedCommand, finalArgv.slice(1))]
: finalArgv.slice(1),
usesWindowsExitCodeShim:
process.platform === "win32" && (useCmdWrapper || finalArgv !== params.argv),
windowsHide: true,
windowsVerbatimArguments: useCmdWrapper ? true : params.windowsVerbatimArguments,
};
}
export function isWindowsBatchCommand(
resolvedCommand: string,
platform: NodeJS.Platform = process.platform,
): boolean {
if (platform !== "win32") {
return false;
}
const ext = normalizeLowercaseStringOrEmpty(path.extname(resolvedCommand));
return ext === ".cmd" || ext === ".bat";
}
function escapeForWindowsCmdExe(arg: string): string {
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.",
);
}
const escaped = arg.replace(/\^/g, "^^");
if (!escaped.includes(" ") && !escaped.includes('"')) {
return escaped;
}
return `"${escaped.replace(/"/g, '""')}"`;
}
export function buildWindowsCmdExeCommandLine(command: string, args: readonly string[]): string {
const escapedCommand = escapeForWindowsCmdExe(command);
const commandLine = [escapedCommand, ...args.map(escapeForWindowsCmdExe)].join(" ");
return escapedCommand.startsWith('"') ? `"${commandLine}"` : commandLine;
}
export function resolveTrustedWindowsCmdExe(platform: NodeJS.Platform = process.platform): string {
if (platform !== "win32") {
return "cmd.exe";
}
return getWindowsCmdExePath();
}
/**
* Resolve package-manager commands that Windows exposes through .cmd shims.
* Explicit extensions are preserved so callers can pass already-resolved tools.
*/
export function resolveWindowsCommandShim(params: {
command: string;
cmdCommands: readonly string[];
platform?: NodeJS.Platform;
}): string {
if ((params.platform ?? process.platform) !== "win32") {
return params.command;
}
const basename = normalizeLowercaseStringOrEmpty(path.basename(params.command));
if (path.extname(basename)) {
return params.command;
}
if (params.cmdCommands.includes(basename)) {
return `${params.command}.cmd`;
}
return params.command;
}