Skip to content

Commit afd2575

Browse files
authored
fix(plugins): run npm update checks without the npm.cmd shim on Windows (#837)
On Windows `npm` on PATH is actually `npm.cmd`, which `execFile` refuses to spawn (CVE-2024-27980), so every npm plugin update check failed with `spawn npm ENOENT` while Git sources kept working. The plugin page could therefore never offer an update for npm-installed plugins. Resolve `npm` and `npx` through the CLI script bundled with the running Node.js (`npm-cli.js` / `npx-cli.js`) and spawn it with `node` directly: no shell is involved and the behavior is identical on every platform. The lookup the skills routes already used for npx now lives in `lib/node-cli.ts` and is reused for npm. Co-authored-by: userliwq <22310579+userliwq@users.noreply.github.com>
1 parent c844973 commit afd2575

4 files changed

Lines changed: 112 additions & 36 deletions

File tree

‎lib/node-cli.test.mjs‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import assert from "node:assert/strict";
2+
import { join } from "node:path";
3+
import { execPath } from "node:process";
4+
import test from "node:test";
5+
import { createJiti } from "jiti";
6+
7+
const jiti = createJiti(import.meta.url);
8+
const { findNodeCliScript, nodeCliInvocation } = await jiti.import("./node-cli.ts");
9+
10+
const nodeDir = join("opt", "node", "bin");
11+
const windowsScript = join(nodeDir, "node_modules", "npm", "bin", "npm-cli.js");
12+
const unixScript = join(nodeDir, "..", "lib", "node_modules", "npm", "bin", "npm-cli.js");
13+
14+
test("both bundled install layouts are probed", () => {
15+
assert.equal(findNodeCliScript("npm", { nodeDir, fileExists: (p) => p === windowsScript }), windowsScript);
16+
assert.equal(findNodeCliScript("npm", { nodeDir, fileExists: (p) => p === unixScript }), unixScript);
17+
assert.equal(findNodeCliScript("npx", { nodeDir, fileExists: () => false }), null);
18+
});
19+
20+
test("the Windows layout wins when both candidates exist", () => {
21+
assert.equal(findNodeCliScript("npx", { nodeDir, fileExists: () => true }), join(nodeDir, "node_modules", "npm", "bin", "npx-cli.js"));
22+
});
23+
24+
test("npm and npx run through node instead of a .cmd shim", () => {
25+
const options = { nodeDir, fileExists: (p) => p === windowsScript };
26+
assert.deepEqual(nodeCliInvocation("npm", ["view", "some-pkg", "version", "--json"], options), {
27+
command: execPath,
28+
args: [windowsScript, "view", "some-pkg", "version", "--json"],
29+
});
30+
});
31+
32+
test("a missing CLI script keeps the bare command and its args", () => {
33+
assert.deepEqual(
34+
nodeCliInvocation("npx", ["skills", "find", "pdf"], { nodeDir, fileExists: () => false }),
35+
{ command: "npx", args: ["skills", "find", "pdf"] },
36+
);
37+
});

‎lib/node-cli.ts‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { existsSync } from "fs";
2+
import { dirname, join } from "path";
3+
import { execPath } from "process";
4+
5+
export type NodeCliName = "npm" | "npx";
6+
7+
export interface NodeCliLookupOptions {
8+
/** Directory holding the running `node` binary. Defaults to the real one. */
9+
nodeDir?: string;
10+
/** Probe used to test a candidate path, injectable for tests. */
11+
fileExists?: (path: string) => boolean;
12+
}
13+
14+
/**
15+
* Locate the `<name>-cli.js` shipped with the running Node.js installation.
16+
*
17+
* On Windows the `npm`/`npx` on PATH are actually `npm.cmd`/`npx.cmd`, which
18+
* Node.js (since 20.12, due to CVE-2024-27980) refuses to spawn from
19+
* `execFile`/`spawn` without `shell: true` — the failure mode is a bare
20+
* `spawn npm ENOENT` on every call. Going through a shell reintroduces quoting
21+
* bugs for user-supplied args. Instead we find the real CLI script and invoke
22+
* it directly through the current `node` binary, which works identically on
23+
* every platform and needs no shell.
24+
*/
25+
export function findNodeCliScript(
26+
name: NodeCliName,
27+
options: NodeCliLookupOptions = {},
28+
): string | null {
29+
const nodeDir = options.nodeDir ?? dirname(execPath);
30+
const fileExists = options.fileExists ?? existsSync;
31+
const candidates = [
32+
// Windows MSI installer layout: node.exe and node_modules share a dir
33+
join(nodeDir, "node_modules", "npm", "bin", `${name}-cli.js`),
34+
// Unix layout: .../bin/node + .../lib/node_modules/npm/bin/<name>-cli.js
35+
join(nodeDir, "..", "lib", "node_modules", "npm", "bin", `${name}-cli.js`),
36+
];
37+
for (const candidate of candidates) {
38+
try {
39+
if (fileExists(candidate)) return candidate;
40+
} catch {
41+
// ignore
42+
}
43+
}
44+
return null;
45+
}
46+
47+
export interface NodeCliInvocation {
48+
command: string;
49+
args: string[];
50+
}
51+
52+
/**
53+
* `execFile`-spawnable invocation of a Node.js CLI, never routed through a
54+
* shell. Falls back to the bare command name when no bundled CLI script is
55+
* found, so behavior is unchanged on installs that ship none.
56+
*/
57+
export function nodeCliInvocation(
58+
name: NodeCliName,
59+
args: string[],
60+
options: NodeCliLookupOptions = {},
61+
): NodeCliInvocation {
62+
const script = findNodeCliScript(name, options);
63+
return script
64+
? { command: execPath, args: [script, ...args] }
65+
: { command: name, args };
66+
}

‎lib/npx.ts‎

Lines changed: 4 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,9 @@
11
import { execFile } from "child_process";
22
import { promisify } from "util";
3-
import { existsSync } from "fs";
4-
import { dirname, join } from "path";
5-
import { execPath } from "process";
3+
import { nodeCliInvocation } from "./node-cli";
64

75
const execFileAsync = promisify(execFile);
86

9-
/**
10-
* Locate `npx-cli.js` shipped with the running Node.js installation.
11-
*
12-
* On Windows the `npx` on PATH is actually `npx.cmd`, which Node.js (since
13-
* 20.12 due to CVE-2024-27980) refuses to spawn from `execFile`/`spawn`
14-
* without `shell: true`. Going through a shell reintroduces quoting bugs for
15-
* user-supplied args. Instead we find the real `npx-cli.js` and invoke it
16-
* directly via the current `node` binary, which works identically on every
17-
* platform and needs no shell.
18-
*/
19-
function findNpxCli(): string | null {
20-
const nodeDir = dirname(execPath);
21-
const candidates = [
22-
// Windows MSI installer layout: node.exe and node_modules share a dir
23-
join(nodeDir, "node_modules", "npm", "bin", "npx-cli.js"),
24-
// Unix layout: .../bin/node + .../lib/node_modules/npm/bin/npx-cli.js
25-
join(nodeDir, "..", "lib", "node_modules", "npm", "bin", "npx-cli.js"),
26-
];
27-
for (const p of candidates) {
28-
try {
29-
if (existsSync(p)) return p;
30-
} catch {
31-
// ignore
32-
}
33-
}
34-
return null;
35-
}
36-
377
export interface RunNpxOptions {
388
timeout?: number;
399
cwd?: string;
@@ -48,12 +18,11 @@ export interface RunNpxResult {
4818
/**
4919
* Cross-platform wrapper for invoking `npx <args>` without ever using a
5020
* shell, so user-controlled arguments are never interpreted as shell syntax.
21+
* See `lib/node-cli.ts` for why the bundled `npx-cli.js` is preferred over the
22+
* `npx` (or `npx.cmd`) on PATH.
5123
*/
5224
export async function runNpx(args: string[], opts: RunNpxOptions = {}): Promise<RunNpxResult> {
53-
const npxCli = findNpxCli();
54-
const { command, commandArgs } = npxCli
55-
? { command: execPath, commandArgs: [npxCli, ...args] }
56-
: { command: "npx", commandArgs: args };
25+
const { command, args: commandArgs } = nodeCliInvocation("npx", args);
5726
return execFileAsync(command, commandArgs, {
5827
timeout: opts.timeout,
5928
cwd: opts.cwd,

‎lib/plugin-updates.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
} from "@earendil-works/pi-coding-agent";
1010
import { gt, maxSatisfying, rcompare, valid, validRange } from "semver";
1111
import type { PluginScope, PluginUpdateResult } from "@/lib/api-types";
12+
import { nodeCliInvocation } from "./node-cli";
1213
import { getProjectTrustStatus } from "./project-trust";
1314

1415
const execFileAsync = promisify(execFile);
@@ -97,7 +98,10 @@ async function runCommand(
9798
args: string[],
9899
options: { cwd: string; env?: NodeJS.ProcessEnv },
99100
): Promise<string> {
100-
const { stdout } = await execFileAsync(command, args, {
101+
// A bare `npm` resolves to `npm.cmd` on Windows, which `execFile` cannot
102+
// spawn (CVE-2024-27980); run the bundled `npm-cli.js` through `node`.
103+
const invocation = command === "npm" ? nodeCliInvocation("npm", args) : { command, args };
104+
const { stdout } = await execFileAsync(invocation.command, invocation.args, {
101105
cwd: options.cwd,
102106
env: options.env ? { ...process.env, ...options.env } : process.env,
103107
encoding: "utf8",

0 commit comments

Comments
 (0)