fix(ai-tools): detect AI CLIs on Windows through PATHEXT

which() looked for the bare name only, so on Windows every tool (claude.exe,
codex.cmd, ...) read installed: false. It now tries each PATHEXT extension
(default .EXE;.CMD;.BAT), the bare name only when it already carries one -
npm's extensionless shim beside codex.cmd is a sh script Windows cannot run.

spawnTarget() gives a .cmd/.bat shell: true and a quoted path, used by the
install/update job runner and the npm/version probes, which Node otherwise
refuses with EINVAL (CVE-2024-27980). Catalog argv only.
This commit is contained in:
2026-10-05 22:05:33 +07:00
parent 094d9c4418
commit b0cf2132d1
7 changed files with 112 additions and 12 deletions
+62
View File
@@ -330,3 +330,65 @@ describe("install/update fields and trust tiers", () => {
});
});
});
describe("which() on Windows", () => {
const realPlatform = Object.getOwnPropertyDescriptor(process, "platform");
const realPathext = process.env.PATHEXT;
const winDir = fs.mkdtempSync(path.join(os.tmpdir(), "ai-tools-win-"));
// Executable bits only for the Linux host running the suite: Windows'
// X_OK check is plain existence.
for (const f of ["claude.exe", "codex.cmd"])
fs.writeFileSync(path.join(winDir, f), "", { mode: 0o755 });
const setPlatform = (p) => Object.defineProperty(process, "platform", { value: p });
after(() => {
Object.defineProperty(process, "platform", realPlatform);
if (realPathext === undefined) delete process.env.PATHEXT;
else process.env.PATHEXT = realPathext;
});
it("tries each PATHEXT extension after the bare name", () => {
setPlatform("win32");
process.env.PATHEXT = ".EXE;.CMD";
assert.equal(aiTools.which("claude", winDir), path.join(winDir, "claude.exe"));
assert.equal(aiTools.which("codex", winDir), path.join(winDir, "codex.cmd"));
});
it("skips npm's extensionless sh shim, which Windows cannot run", () => {
setPlatform("win32");
process.env.PATHEXT = ".EXE;.CMD";
fs.writeFileSync(path.join(winDir, "codex"), "#!/bin/sh\n", { mode: 0o755 });
try {
assert.equal(aiTools.which("codex", winDir), path.join(winDir, "codex.cmd"));
assert.equal(aiTools.which("claude.exe", winDir), path.join(winDir, "claude.exe"));
} finally {
fs.unlinkSync(path.join(winDir, "codex"));
}
});
it("falls back to .EXE;.CMD;.BAT without PATHEXT", () => {
setPlatform("win32");
delete process.env.PATHEXT;
assert.equal(aiTools.which("codex", winDir), path.join(winDir, "codex.cmd"));
});
it("runs a .cmd/.bat through cmd.exe, its path quoted for Program Files", () => {
setPlatform("win32");
const npm = "C:\\Program Files\\nodejs\\npm.cmd";
assert.deepEqual(aiTools.spawnTarget(npm), { file: `"${npm}"`, shell: true });
assert.deepEqual(aiTools.spawnTarget("C:\\x\\claude.exe"), {
file: "C:\\x\\claude.exe",
shell: false,
});
setPlatform("linux");
assert.deepEqual(aiTools.spawnTarget("/usr/bin/npm.cmd"), {
file: "/usr/bin/npm.cmd",
shell: false,
});
});
it("adds no extension anywhere else", () => {
setPlatform("linux");
process.env.PATHEXT = ".EXE;.CMD";
assert.equal(aiTools.which("claude", winDir), null);
});
});