Node has refused shell-free `.cmd`/`.bat` spawns since CVE-2024-27980, and an npm-installed `claude` is a `.cmd` shim on Windows — so every shell-free spawn of it reported ENOENT there, each in a quiet way: `verify:skills` skipped its authoritative hand-off, `verify:skills:cli` downloaded the pinned package it already had (and failed offline), and `skills:eval` said "not on PATH". - New `scripts/lib/claude-cli.mjs` is the one place that decides how `claude` is spawned: `shell` on win32, arguments through the existing `winShellArgs` quoting so `cmd.exe` cannot re-parse them as syntax (#1939). Unit-tested with the platform injected, since a win32-only bug no runner here executes is exactly the kind that ships. - `verify-skills`, `verify-skills-cli` and `skill-eval` all route their probes and spawns through it. - skill-eval sends the prompt on **stdin** rather than in argv. `claude -p` reads a piped prompt (verified end to end), which sidesteps `cmd.exe` metacharacter re-parsing entirely rather than relying on quoting a model-visible string, and keeps the prompt out of the process table. Signed-off-by: cliffhall <cliff@futurescale.com>
76 lines
2.5 KiB
JavaScript
76 lines
2.5 KiB
JavaScript
// Tests for the one place that decides how `claude` is spawned (#2163, Copilot).
|
|
//
|
|
// The behavior under test only differs ON Windows, and these run on whatever
|
|
// the contributor has — so the platform is injected rather than sniffed. A
|
|
// win32-only bug that no CI runner here executes is precisely the kind that
|
|
// ships.
|
|
|
|
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { claudeSpawnArgs, probeClaudeVersion } from "./claude-cli.mjs";
|
|
|
|
test("spawns without a shell off Windows", () => {
|
|
const { command, args, options } = claudeSpawnArgs(
|
|
["plugin", "validate", ".claude/skills"],
|
|
{ cwd: "/repo" },
|
|
"linux",
|
|
);
|
|
assert.equal(command, "claude");
|
|
assert.deepEqual(args, ["plugin", "validate", ".claude/skills"]);
|
|
assert.equal(options.shell, false);
|
|
assert.equal(options.cwd, "/repo");
|
|
});
|
|
|
|
test("uses a shell on Windows, where the CLI is a .cmd shim", () => {
|
|
// Node has refused shell-free `.cmd` spawns since CVE-2024-27980, so without
|
|
// this the CLI reads as absent even when it is installed.
|
|
const { options } = claudeSpawnArgs(["--version"], {}, "win32");
|
|
assert.equal(options.shell, true);
|
|
});
|
|
|
|
test("quotes arguments that cmd.exe would otherwise re-parse", () => {
|
|
const { args } = claudeSpawnArgs(["-p", "does it? (yes & no)"], {}, "win32");
|
|
assert.deepEqual(args, ["-p", '"does it? (yes & no)"']);
|
|
// The same arguments are untouched off Windows.
|
|
assert.deepEqual(
|
|
claudeSpawnArgs(["-p", "does it? (yes & no)"], {}, "linux").args,
|
|
["-p", "does it? (yes & no)"],
|
|
);
|
|
});
|
|
|
|
test("probeClaudeVersion returns the parsed version on success", () => {
|
|
const version = probeClaudeVersion(
|
|
(t) => (t.trim() === "2.1.250" ? [2, 1, 250] : null),
|
|
{
|
|
spawn: () => ({ status: 0, stdout: "2.1.250" }),
|
|
platform: "linux",
|
|
},
|
|
);
|
|
assert.deepEqual(version, [2, 1, 250]);
|
|
});
|
|
|
|
test("probeClaudeVersion returns null for every unusable outcome", () => {
|
|
for (const res of [
|
|
{ error: new Error("ENOENT") },
|
|
{ status: 1, stdout: "" },
|
|
{ status: 0, stdout: "something else" },
|
|
]) {
|
|
assert.equal(
|
|
probeClaudeVersion(() => null, { spawn: () => res, platform: "linux" }),
|
|
null,
|
|
);
|
|
}
|
|
});
|
|
|
|
test("probeClaudeVersion asks for the shell on Windows", () => {
|
|
let seen;
|
|
probeClaudeVersion(() => [2, 1, 250], {
|
|
spawn: (_c, _a, options) => {
|
|
seen = options;
|
|
return { status: 0, stdout: "2.1.250" };
|
|
},
|
|
platform: "win32",
|
|
});
|
|
assert.equal(seen.shell, true);
|
|
assert.equal(seen.encoding, "utf8");
|
|
});
|