b0cf2132d1
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.
395 lines
13 KiB
JavaScript
395 lines
13 KiB
JavaScript
/**
|
|
* @file Tests for server/lib/ai-tools.js — catalog validation and override,
|
|
* PATH detection over fake executables, the enable/default state rules, and
|
|
* the A2 install/update fields with their three trust tiers.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const { describe, it, before, beforeEach, after } = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
const fs = require("node:fs");
|
|
const os = require("node:os");
|
|
const path = require("node:path");
|
|
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "ai-tools-"));
|
|
const builtin = path.join(root, "builtin");
|
|
const custom = path.join(root, "custom");
|
|
const bin = path.join(root, "bin");
|
|
const data = path.join(root, "data");
|
|
for (const d of [builtin, custom, bin, data]) fs.mkdirSync(d);
|
|
|
|
const origPath = process.env.PATH;
|
|
const origData = process.env.DASHBOARD_DATA_DIR;
|
|
process.env.DASHBOARD_DATA_DIR = data;
|
|
|
|
const aiTools = require("../lib/ai-tools");
|
|
|
|
const write = (dir, name, doc) =>
|
|
fs.writeFileSync(path.join(dir, name), typeof doc === "string" ? doc : JSON.stringify(doc));
|
|
const exe = (name, mode = 0o755) => {
|
|
const p = path.join(bin, name);
|
|
fs.writeFileSync(p, "#!/bin/sh\n");
|
|
fs.chmodSync(p, mode);
|
|
return p;
|
|
};
|
|
|
|
before(() => {
|
|
write(builtin, "claude.json", {
|
|
id: "claude",
|
|
label: "Claude Code",
|
|
detect: ["claude"],
|
|
launcher: "claude",
|
|
});
|
|
write(builtin, "codex.json", {
|
|
id: "codex",
|
|
label: "Codex",
|
|
detect: ["codex"],
|
|
launcher: "codex",
|
|
});
|
|
write(builtin, "zed.json", {
|
|
id: "zed",
|
|
label: "Aaa Zed",
|
|
detect: ["zed"],
|
|
prompt: { mode: "argv" },
|
|
});
|
|
write(builtin, "pi.json", {
|
|
id: "pi",
|
|
label: "Pi",
|
|
detect: ["pi"],
|
|
prompt: { mode: "argv" },
|
|
resume: ["--continue"],
|
|
});
|
|
write(builtin, "needs.json", {
|
|
id: "needs",
|
|
label: "Needs",
|
|
detect: ["needs"],
|
|
requires: ["helper"],
|
|
prompt: { mode: "argv" },
|
|
});
|
|
write(builtin, "bad-id.json", {
|
|
id: "Bad_Id",
|
|
label: "x",
|
|
detect: ["x"],
|
|
prompt: { mode: "argv" },
|
|
});
|
|
write(builtin, "slash.json", {
|
|
id: "slash",
|
|
label: "x",
|
|
detect: ["/bin/sh"],
|
|
prompt: { mode: "argv" },
|
|
});
|
|
write(builtin, "args.json", {
|
|
id: "args",
|
|
label: "x",
|
|
detect: ["x"],
|
|
args: "--oops",
|
|
prompt: { mode: "argv" },
|
|
});
|
|
write(builtin, "mode.json", { id: "mode", label: "x", detect: ["x"], prompt: { mode: "shell" } });
|
|
write(builtin, "broken.json", "{not json");
|
|
write(custom, "pi.json", {
|
|
id: "pi",
|
|
label: "Pi Custom",
|
|
detect: ["pi"],
|
|
prompt: { mode: "flag", flag: "-p" },
|
|
});
|
|
write(custom, "evil.json", { id: "evil", label: "x", detect: ["x"], launcher: "claude" });
|
|
exe("claude");
|
|
exe("pi");
|
|
exe("needs");
|
|
exe("zed", 0o644); // present but not executable
|
|
process.env.PATH = bin;
|
|
aiTools.__setCatalogDirs([builtin, custom]);
|
|
});
|
|
|
|
after(() => {
|
|
process.env.PATH = origPath;
|
|
if (origData === undefined) delete process.env.DASHBOARD_DATA_DIR;
|
|
else process.env.DASHBOARD_DATA_DIR = origData;
|
|
aiTools.__setCatalogDirs(null);
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
});
|
|
|
|
beforeEach(() => {
|
|
fs.rmSync(path.join(data, "ai-tools.json"), { force: true });
|
|
aiTools.refresh();
|
|
});
|
|
|
|
describe("catalog", () => {
|
|
it("skips invalid files and keeps valid ones in catalog order", () => {
|
|
assert.deepEqual(
|
|
aiTools.list().map((t) => t.id),
|
|
["claude", "codex", "zed", "needs", "pi"]
|
|
);
|
|
});
|
|
|
|
it("lets a custom file replace a built-in one, but never set launcher", () => {
|
|
assert.equal(aiTools.get("pi").label, "Pi Custom");
|
|
assert.equal(aiTools.get("pi").source, "custom");
|
|
assert.equal(aiTools.get("pi").resume, null);
|
|
assert.equal(aiTools.has("evil"), false);
|
|
assert.equal(aiTools.get("claude").source, "builtin");
|
|
});
|
|
});
|
|
|
|
describe("detection", () => {
|
|
it("finds executables on PATH and ignores non-executable files", () => {
|
|
assert.deepEqual(aiTools.detect("claude"), {
|
|
installed: true,
|
|
path: path.join(bin, "claude"),
|
|
command: "claude",
|
|
});
|
|
assert.equal(aiTools.detect("zed").installed, false);
|
|
assert.equal(aiTools.detect("codex").installed, false);
|
|
});
|
|
|
|
it("needs every `requires` command too", () => {
|
|
assert.equal(aiTools.detect("needs").installed, false);
|
|
exe("helper");
|
|
aiTools.refresh();
|
|
assert.equal(aiTools.detect("needs").installed, true);
|
|
fs.rmSync(path.join(bin, "helper"));
|
|
});
|
|
});
|
|
|
|
describe("enable / default", () => {
|
|
it("defaults to claude, then the first installed enabled tool", () => {
|
|
assert.equal(aiTools.effectiveDefault(), "claude");
|
|
aiTools.setEnabled("claude", false);
|
|
assert.equal(aiTools.effectiveDefault(), "pi");
|
|
});
|
|
|
|
it("stores a default and clears it when that tool is disabled", () => {
|
|
aiTools.setDefault("pi");
|
|
assert.equal(aiTools.readState().default, "pi");
|
|
assert.equal(aiTools.effectiveDefault(), "pi");
|
|
aiTools.setEnabled("pi", false);
|
|
assert.equal(aiTools.readState().default, null);
|
|
assert.equal(aiTools.isEnabled("pi"), false);
|
|
aiTools.setEnabled("pi", true);
|
|
assert.equal(aiTools.isEnabled("pi"), true);
|
|
});
|
|
|
|
it("refuses a default that is unknown, not installed or disabled", () => {
|
|
assert.throws(() => aiTools.setDefault("nope"), { code: "ENOTOOL" });
|
|
assert.throws(() => aiTools.setDefault("codex"), { code: "EBADDEFAULT" });
|
|
aiTools.setEnabled("pi", false);
|
|
assert.throws(() => aiTools.setDefault("pi"), { code: "EBADDEFAULT" });
|
|
assert.throws(() => aiTools.setEnabled("nope", false), { code: "ENOTOOL" });
|
|
});
|
|
|
|
it("view() carries flags, never arg arrays", () => {
|
|
const v = aiTools.view();
|
|
assert.equal(v.default, "claude");
|
|
const pi = v.tools.find((t) => t.id === "pi");
|
|
assert.deepEqual(pi, {
|
|
id: "pi",
|
|
label: "Pi Custom",
|
|
command: "pi",
|
|
homepageUrl: null,
|
|
installed: true,
|
|
path: path.join(bin, "pi"),
|
|
enabled: true,
|
|
source: "custom",
|
|
launcher: null,
|
|
prompt: { mode: "flag" },
|
|
resume: false,
|
|
bypass: false,
|
|
routerCapable: false,
|
|
modelFlag: false,
|
|
defaultModel: null,
|
|
modelVendor: null,
|
|
configFiles: 0,
|
|
install: null,
|
|
update: null,
|
|
});
|
|
assert.equal(v.tools.find((t) => t.id === "codex").routerCapable, true);
|
|
});
|
|
});
|
|
|
|
describe("built-in catalog", () => {
|
|
it("loads every shipped file", () => {
|
|
aiTools.__setCatalogDirs(null);
|
|
const ids = aiTools.list().map((t) => t.id);
|
|
const files = fs
|
|
.readdirSync(path.join(__dirname, "..", "data", "ai-tools"))
|
|
.filter((f) => f.endsWith(".json"));
|
|
assert.equal(ids.length, files.length);
|
|
assert.deepEqual(ids.slice(0, 2), ["claude", "codex"]);
|
|
assert.equal(aiTools.get("antigravity").prompt.flag, "-i");
|
|
aiTools.__setCatalogDirs([builtin, custom]);
|
|
});
|
|
});
|
|
|
|
describe("install/update fields and trust tiers", () => {
|
|
const tiers = path.join(root, "tiers");
|
|
const tb = path.join(tiers, "builtin");
|
|
const tr = path.join(tiers, "remote");
|
|
const tc = path.join(tiers, "custom");
|
|
const base = (id, extra = {}) => ({
|
|
id,
|
|
label: id,
|
|
detect: [id],
|
|
prompt: { mode: "argv" },
|
|
homepageUrl: "https://docs.example.com/cli",
|
|
...extra,
|
|
});
|
|
|
|
before(() => {
|
|
for (const d of [tb, tr, tc]) fs.mkdirSync(d, { recursive: true });
|
|
write(tb, "claude.json", {
|
|
id: "claude",
|
|
label: "Claude",
|
|
detect: ["claude"],
|
|
launcher: "claude",
|
|
update: { kind: "self", args: ["update"] },
|
|
});
|
|
write(
|
|
tb,
|
|
"npmok.json",
|
|
base("npmok", {
|
|
install: { kind: "npm", package: "@scope/np-m.ok" },
|
|
update: { kind: "npm" },
|
|
})
|
|
);
|
|
write(
|
|
tb,
|
|
"scriptok.json",
|
|
base("scriptok", {
|
|
install: { kind: "script", url: "https://example.com/install" },
|
|
update: { kind: "script" },
|
|
})
|
|
);
|
|
write(tb, "badpkg.json", base("badpkg", { install: { kind: "npm", package: "Bad Pkg;rm" } }));
|
|
write(
|
|
tb,
|
|
"httpurl.json",
|
|
base("httpurl", { install: { kind: "script", url: "http://example.com/i" } })
|
|
);
|
|
write(
|
|
tb,
|
|
"offhost.json",
|
|
base("offhost", { install: { kind: "script", url: "https://evil.io/i" } })
|
|
);
|
|
write(tb, "npmnoinst.json", base("npmnoinst", { update: { kind: "npm" } }));
|
|
write(tb, "selfbad.json", base("selfbad", { update: { kind: "self", args: "update" } }));
|
|
write(tb, "shared.json", base("shared", { label: "Shared builtin" }));
|
|
write(tr, "shared.json", base("shared", { label: "Shared remote" }));
|
|
write(tr, "codex.json", {
|
|
id: "codex",
|
|
label: "Codex R",
|
|
detect: ["codex"],
|
|
launcher: "codex",
|
|
});
|
|
write(
|
|
tr,
|
|
"remoteonly.json",
|
|
base("remoteonly", { install: { kind: "npm", package: "remote-only" } })
|
|
);
|
|
write(tc, "shared.json", base("shared", { label: "Shared custom" }));
|
|
write(tc, "custinst.json", base("custinst", { install: { kind: "npm", package: "x" } }));
|
|
write(tc, "custupd.json", base("custupd", { update: { kind: "self", args: ["up"] } }));
|
|
aiTools.__setCatalogDirs([tb, tr, tc]);
|
|
});
|
|
after(() => aiTools.__setCatalogDirs([builtin, custom]));
|
|
|
|
it("accepts well-formed install/update and rejects the rest", () => {
|
|
assert.deepEqual(aiTools.get("npmok").install, { kind: "npm", package: "@scope/np-m.ok" });
|
|
assert.deepEqual(aiTools.get("npmok").update, { kind: "npm" });
|
|
assert.deepEqual(aiTools.get("scriptok").install, {
|
|
kind: "script",
|
|
url: "https://example.com/install",
|
|
});
|
|
assert.deepEqual(aiTools.get("claude").update, { kind: "self", args: ["update"] });
|
|
for (const id of ["badpkg", "httpurl", "offhost", "npmnoinst", "selfbad"]) {
|
|
assert.equal(aiTools.has(id), false, id);
|
|
}
|
|
});
|
|
|
|
it("orders custom over remote over builtin and lets a remote file keep launcher", () => {
|
|
assert.equal(aiTools.get("shared").label, "Shared custom");
|
|
assert.equal(aiTools.get("codex").source, "remote");
|
|
assert.equal(aiTools.get("codex").launcher, "codex");
|
|
assert.equal(aiTools.get("remoteonly").install.package, "remote-only");
|
|
});
|
|
|
|
it("refuses install/update in a custom file", () => {
|
|
assert.equal(aiTools.has("custinst"), false);
|
|
assert.equal(aiTools.has("custupd"), false);
|
|
});
|
|
|
|
it("view() shows install/update so the confirm dialog can name the command", () => {
|
|
const rows = aiTools.view().tools;
|
|
assert.deepEqual(rows.find((t) => t.id === "npmok").install, {
|
|
kind: "npm",
|
|
package: "@scope/np-m.ok",
|
|
});
|
|
assert.deepEqual(rows.find((t) => t.id === "claude").update, {
|
|
kind: "self",
|
|
args: ["update"],
|
|
});
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|