fix(plugins): harden Windows npm package staging

This commit is contained in:
Vincent Koc
2026-05-24 09:32:13 +02:00
parent c14a0c6d63
commit abdd8a40cc
3 changed files with 63 additions and 21 deletions

View File

@@ -116,6 +116,7 @@ Docs: https://docs.openclaw.ai
- Install/Windows: run Git hook setup through a Node prepare helper so native Windows installs no longer print POSIX shell errors.
- Checks/Windows: chunk and serialize extension oxlint shards on native Windows so changed gates avoid Go-backed linter memory spikes.
- Release/Windows: run installed `openclaw.cmd` verification through explicit `cmd.exe` wrapping so npm prepublish/postpublish checks avoid Node shell-argv warnings.
- Plugins/Windows: run plugin npm package staging through the shared npm runner so native Windows release checks avoid bare `npm` lookup and `.cmd` shell-argv handling.
- Checks/Windows: route full `pnpm check` stage commands through the managed child runner so Windows avoids Node shell-argv deprecation warnings there too.
- Agents/fs: allow workspace-only host write/edit tools to write through in-workspace symlink directory parents while preserving outside-workspace symlink rejection. Fixes #84696. Thanks @garbagenetwork.
- Checks/Windows: run managed child commands through explicit `cmd.exe` wrapping instead of Node shell mode with argv, avoiding Node 24 subprocess deprecation warnings during changed checks.

View File

@@ -8,6 +8,7 @@ import {
listPluginNpmRuntimeBuildOutputs,
resolvePluginNpmRuntimeBuildPlan,
} from "./plugin-npm-runtime-build.mjs";
import { resolveNpmRunner } from "../npm-runner.mjs";
const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA_PATH =
"src/config/bundled-channel-config-metadata.generated.ts";
@@ -136,28 +137,26 @@ function listConfiguredBundledDependencyNames(packageJson) {
return [];
}
function npmInvocation() {
if (process.platform !== "win32") {
return { args: [], command: "npm" };
}
const npmCliPath = path.join(
path.dirname(process.execPath),
"node_modules",
"npm",
"bin",
"npm-cli.js",
);
if (fs.existsSync(npmCliPath)) {
return { args: [npmCliPath], command: process.execPath };
}
return { args: [], command: "npm.cmd", shell: true };
export function resolvePluginNpmCommand(args, params = {}) {
return resolveNpmRunner({
comSpec: params.comSpec,
env: params.env,
execPath: params.execPath,
existsSync: params.existsSync,
npmArgs: args,
platform: params.platform,
});
}
function spawnNpmSync(args, options) {
const invocation = npmInvocation();
return spawnSync(invocation.command, [...invocation.args, ...args], {
function spawnNpmSync(args, options = {}) {
const invocation = resolvePluginNpmCommand(args, { env: options.env ?? process.env });
return spawnSync(invocation.command, invocation.args, {
...options,
...(invocation.shell ? { shell: invocation.shell } : {}),
...(invocation.env ? { env: invocation.env } : {}),
...(invocation.shell !== undefined ? { shell: invocation.shell } : {}),
...(invocation.windowsVerbatimArguments !== undefined
? { windowsVerbatimArguments: invocation.windowsVerbatimArguments }
: {}),
});
}

View File

@@ -1,10 +1,11 @@
import { spawnSync } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { dirname, join, win32 } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
resolveAugmentedPluginNpmPackageJson,
resolveAugmentedPluginNpmManifest,
resolvePluginNpmCommand,
withAugmentedPluginNpmManifestForPackage,
} from "../scripts/lib/plugin-npm-package-manifest.mjs";
import { cleanupTempDirs, makeTempRepoRoot, writeJsonFile } from "./helpers/temp-repo.js";
@@ -51,10 +52,16 @@ function writeFileText(filePath: string, text: string): void {
}
function listNpmPackDryRunFiles(packageDir: string): string[] {
const result = spawnSync("npm", ["pack", "--dry-run", "--json", "--ignore-scripts"], {
const invocation = resolvePluginNpmCommand(["pack", "--dry-run", "--json", "--ignore-scripts"]);
const result = spawnSync(invocation.command, invocation.args, {
cwd: packageDir,
encoding: "utf8",
...(invocation.env ? { env: invocation.env } : {}),
...(invocation.shell !== undefined ? { shell: invocation.shell } : {}),
stdio: ["ignore", "pipe", "pipe"],
...(invocation.windowsVerbatimArguments !== undefined
? { windowsVerbatimArguments: invocation.windowsVerbatimArguments }
: {}),
});
if (result.error) {
throw result.error;
@@ -132,6 +139,41 @@ function writeOptionalPlatformDependencyPackage(packageDir: string): string {
}
describe("plugin npm package manifest staging", () => {
it("wraps Windows npm.cmd staging through cmd.exe without shell mode", () => {
const nodeDir = "C:\\Program Files\\nodejs";
const npmCmdPath = win32.resolve(nodeDir, "npm.cmd");
expect(
resolvePluginNpmCommand(["install", "--package-lock-only"], {
comSpec: "C:\\Windows\\System32\\cmd.exe",
env: { PATH: "C:\\bin" },
execPath: win32.join(nodeDir, "node.exe"),
existsSync: (candidate: string) => candidate === npmCmdPath,
platform: "win32",
}),
).toEqual({
command: "C:\\Windows\\System32\\cmd.exe",
args: [
"/d",
"/s",
"/c",
'""C:\\Program Files\\nodejs\\npm.cmd" install --package-lock-only"',
],
shell: false,
windowsVerbatimArguments: true,
});
});
it("rejects bare npm fallback on Windows plugin package staging", () => {
expect(() =>
resolvePluginNpmCommand(["install"], {
execPath: "C:\\nodejs\\node.exe",
existsSync: () => false,
platform: "win32",
}),
).toThrow("OpenClaw refuses to shell out to bare npm on Windows");
});
it("overlays generated channel configs while packing and restores source manifest", () => {
const repoDir = makeTempRepoRoot(tempDirs, "openclaw-plugin-npm-package-manifest-");
const packageDir = join(repoDir, "extensions", "twitch");