From a1a8ec68709b3cb85799c50be8015572f1572f11 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 2 Mar 2026 02:23:42 +0000 Subject: [PATCH] fix(windows): land #31147 plugin install spawn EINVAL (@codertony) Landed from contributor PR #31147 by @codertony. Co-authored-by: codertony --- CHANGELOG.md | 1 + src/process/exec.test.ts | 9 +++++++ src/process/exec.ts | 57 +++++++++++++++++++++++++++++++++++----- 3 files changed, 60 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96421c8d6c9..47081ae8eac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/process/exec.test.ts b/src/process/exec.test.ts index 901a6e6cd46..831cd4925fc 100644 --- a/src/process/exec.test.ts +++ b/src/process/exec.test.ts @@ -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", () => { diff --git a/src/process/exec.ts b/src/process/exec.ts index 9b42dfbf59c..f27889985a3 100644 --- a/src/process/exec.ts +++ b/src/process/exec.ts @@ -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,