fix(windows): land #31147 plugin install spawn EINVAL (@codertony)

Landed from contributor PR #31147 by @codertony.

Co-authored-by: codertony <codertony@users.noreply.github.com>
This commit is contained in:
Peter Steinberger
2026-03-02 02:23:42 +00:00
parent 00d2df46c7
commit a1a8ec6870
3 changed files with 60 additions and 7 deletions

View File

@@ -20,6 +20,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Windows/Plugin install: avoid `spawn EINVAL` on Windows npm/npx invocations by resolving to `node` + npm CLI scripts instead of spawning `.cmd` directly. Landed from contributor PR #31147 by @codertony. Thanks @codertony.
- Agents/Thinking fallback: when providers reject unsupported thinking levels without enumerating alternatives, retry with `think=off` to avoid hard failure during model/provider fallback chains. Landed from contributor PR #31002 by @yfge. Thanks @yfge.
- Agents/Failover reason classification: avoid false rate-limit classification from incidental `tpm` substrings by matching TPM as a standalone token/phrase and keeping auth-context errors on the auth path. Landed from contributor PR #31007 by @HOYALIM. Thanks @HOYALIM.
- Slack/Announce target account routing: enable session-backed announce-target lookup for Slack so multi-account announces resolve the correct `accountId` instead of defaulting to bot-token context. Landed from contributor PR #31028 by @taw0002. Thanks @taw0002.

View File

@@ -133,6 +133,15 @@ describe("runCommandWithTimeout", () => {
expect(result.noOutputTimedOut).toBe(false);
expect(result.code).not.toBe(0);
});
it.runIf(process.platform === "win32")(
"on Windows spawns node + npm-cli.js for npm argv to avoid spawn EINVAL",
async () => {
const result = await runCommandWithTimeout(["npm", "--version"], { timeoutMs: 10_000 });
expect(result.code).toBe(0);
expect(result.stdout.trim()).toMatch(/^\d+\.\d+\.\d+$/);
},
);
});
describe("attachChildProcessBridge", () => {

View File

@@ -1,5 +1,7 @@
import { execFile, spawn } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { promisify } from "node:util";
import { danger, shouldLogVerbose } from "../globals.js";
import { logDebug, logError } from "../logger.js";
@@ -7,22 +9,46 @@ import { resolveCommandStdio } from "./spawn-utils.js";
const execFileAsync = promisify(execFile);
/**
* 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
* spawn node.exe instead of npm.cmd.
*/
function resolveNpmArgvForWindows(argv: string[]): string[] | null {
if (process.platform !== "win32" || argv.length === 0) {
return null;
}
const basename = path
.basename(argv[0])
.toLowerCase()
.replace(/\.(cmd|exe|bat)$/, "");
const cliName = basename === "npx" ? "npx-cli.js" : basename === "npm" ? "npm-cli.js" : null;
if (!cliName) {
return null;
}
const nodeDir = path.dirname(process.execPath);
const cliPath = path.join(nodeDir, "node_modules", "npm", "bin", cliName);
if (!fs.existsSync(cliPath)) {
return null;
}
return [process.execPath, cliPath, ...argv.slice(1)];
}
/**
* Resolves a command for Windows compatibility.
* On Windows, non-.exe commands (like npm, pnpm) require their .cmd extension.
* On Windows, non-.exe commands (like pnpm, yarn) are resolved to .cmd; npm/npx
* are handled by resolveNpmArgvForWindows to avoid spawn EINVAL (no direct .cmd).
*/
function resolveCommand(command: string): string {
if (process.platform !== "win32") {
return command;
}
const basename = path.basename(command).toLowerCase();
// Skip if already has an extension (.cmd, .exe, .bat, etc.)
const ext = path.extname(basename);
if (ext) {
return command;
}
// Common npm-related commands that need .cmd extension on Windows
const cmdCommands = ["npm", "pnpm", "yarn", "npx"];
const cmdCommands = ["pnpm", "yarn"];
if (cmdCommands.includes(basename)) {
return `${command}.cmd`;
}
@@ -58,7 +84,23 @@ export async function runExec(
encoding: "utf8" as const,
};
try {
const { stdout, stderr } = await execFileAsync(resolveCommand(command), args, options);
const argv = [command, ...args];
let execCommand: string;
let execArgs: string[];
if (process.platform === "win32") {
const resolved = resolveNpmArgvForWindows(argv);
if (resolved) {
execCommand = resolved[0] ?? "";
execArgs = resolved.slice(1);
} else {
execCommand = resolveCommand(command);
execArgs = args;
}
} else {
execCommand = resolveCommand(command);
execArgs = args;
}
const { stdout, stderr } = await execFileAsync(execCommand, execArgs, options);
if (shouldLogVerbose()) {
if (stdout.trim()) {
logDebug(stdout.trim());
@@ -134,8 +176,9 @@ export async function runCommandWithTimeout(
}
const stdio = resolveCommandStdio({ hasInput, preferInherit: true });
const resolvedCommand = resolveCommand(argv[0] ?? "");
const child = spawn(resolvedCommand, argv.slice(1), {
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,