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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user