feat(ai-tools): install/update AI CLIs, remote catalog sync, cached favicons (#40 A2)
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* @file Tests for server/lib/ai-tool-icons.js — which domain a tool's icon is
|
||||
* fetched from, the raster-only magic-byte sniff (no SVG), the size cap, the
|
||||
* off-site redirect refusal, and the hit/miss cache.
|
||||
* @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-icons-"));
|
||||
const cat = path.join(root, "cat");
|
||||
const empty = path.join(root, "empty");
|
||||
const data = path.join(root, "data");
|
||||
for (const d of [cat, empty, data]) fs.mkdirSync(d);
|
||||
const origData = process.env.DASHBOARD_DATA_DIR;
|
||||
process.env.DASHBOARD_DATA_DIR = data;
|
||||
|
||||
const aiTools = require("../lib/ai-tools");
|
||||
const { iconFor, iconDomain } = require("../lib/ai-tool-icons");
|
||||
|
||||
const tool = (id, extra) => ({ id, label: id, detect: [id], prompt: { mode: "argv" }, ...extra });
|
||||
const write = (doc) => fs.writeFileSync(path.join(cat, `${doc.id}.json`), JSON.stringify(doc));
|
||||
|
||||
let calls;
|
||||
const PNG = Buffer.from("89504e470d0a1a0a", "hex");
|
||||
function serve({ body = Buffer.concat([PNG, Buffer.alloc(2)]), url, status = 200 } = {}) {
|
||||
calls = [];
|
||||
globalThis.fetch = async (u) => {
|
||||
calls.push(u);
|
||||
return {
|
||||
ok: status === 200,
|
||||
status,
|
||||
url: url || u,
|
||||
headers: { get: () => "image/png" }, // ignored: the bytes decide
|
||||
arrayBuffer: async () => body,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
let origFetch;
|
||||
before(() => {
|
||||
origFetch = globalThis.fetch;
|
||||
write(tool("fav", { faviconDomain: "example.com" }));
|
||||
write(tool("home", { homepageUrl: "https://docs.example.org/cli" }));
|
||||
write(tool("ip", { faviconDomain: "127.0.0.1" }));
|
||||
write(tool("port", { homepageUrl: "https://example.com:8443/x" }));
|
||||
write(tool("none"));
|
||||
aiTools.__setCatalogDirs([cat, empty]);
|
||||
});
|
||||
beforeEach(() => fs.rmSync(path.join(data, "runtime"), { recursive: true, force: true }));
|
||||
after(() => {
|
||||
globalThis.fetch = origFetch;
|
||||
aiTools.__setCatalogDirs(null);
|
||||
if (origData === undefined) delete process.env.DASHBOARD_DATA_DIR;
|
||||
else process.env.DASHBOARD_DATA_DIR = origData;
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("iconDomain", () => {
|
||||
it("uses faviconDomain, else the homepage host, and refuses IPs, ports and nothing", () => {
|
||||
assert.equal(iconDomain(aiTools.get("fav")), "example.com");
|
||||
assert.equal(iconDomain(aiTools.get("home")), "docs.example.org");
|
||||
assert.equal(iconDomain(aiTools.get("ip")), null);
|
||||
assert.equal(iconDomain(aiTools.get("port")), null);
|
||||
assert.equal(iconDomain(aiTools.get("none")), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("iconFor", () => {
|
||||
it("fetches once and serves the cache after", async () => {
|
||||
serve();
|
||||
const a = await iconFor("fav");
|
||||
assert.equal(a.type, "image/png");
|
||||
assert.equal(a.body.length, 10);
|
||||
assert.deepEqual(calls, ["https://example.com/favicon.ico"]);
|
||||
await iconFor("fav");
|
||||
assert.equal(calls.length, 1);
|
||||
});
|
||||
|
||||
it("recognises an ico with no content type at all", async () => {
|
||||
serve({ body: Buffer.from("0000010001002020", "hex") });
|
||||
assert.equal((await iconFor("fav")).type, "image/x-icon");
|
||||
});
|
||||
|
||||
it("refuses SVG even when labelled image/png, and remembers the miss", async () => {
|
||||
serve({ body: Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"><script/></svg>') });
|
||||
assert.equal(await iconFor("fav"), null);
|
||||
serve();
|
||||
assert.equal(await iconFor("fav"), null);
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
|
||||
it("refuses an oversized body and an off-site redirect", async () => {
|
||||
serve({ body: Buffer.concat([PNG, Buffer.alloc(256 * 1024)]) });
|
||||
assert.equal(await iconFor("fav"), null);
|
||||
fs.rmSync(path.join(data, "runtime"), { recursive: true, force: true });
|
||||
serve({ url: "https://evil.example.net/favicon.ico" });
|
||||
assert.equal(await iconFor("fav"), null);
|
||||
});
|
||||
|
||||
it("never fetches for a tool without an allowed domain", async () => {
|
||||
serve();
|
||||
assert.equal(await iconFor("ip"), null);
|
||||
assert.equal(await iconFor("nope"), null);
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,383 @@
|
||||
/**
|
||||
* @file Tests for server/lib/ai-tool-jobs.js and ai-tool-versions.js — the
|
||||
* argv each install/update runs (npm from the login shell's PATH first), the
|
||||
* script download guards, one job at a time, the log cap, timeout, EACCES
|
||||
* hint, enable-after-install, update-all, and npm-managed detection by realpath.
|
||||
* @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 { EventEmitter } = require("node:events");
|
||||
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "ai-jobs-"));
|
||||
const cat = path.join(root, "cat");
|
||||
const empty = path.join(root, "empty");
|
||||
const shellBin = path.join(root, "shellbin"); // the login shell's npm
|
||||
const serverBin = path.join(root, "serverbin"); // the pinned Node's npm
|
||||
const prefix = path.join(root, "prefix");
|
||||
const standalone = path.join(root, "standalone");
|
||||
const data = path.join(root, "data");
|
||||
for (const d of [cat, empty, shellBin, serverBin, prefix, standalone, data]) {
|
||||
fs.mkdirSync(d, { recursive: true });
|
||||
}
|
||||
|
||||
const saved = {
|
||||
PATH: process.env.PATH,
|
||||
DATA: process.env.DASHBOARD_DATA_DIR,
|
||||
SP: process.env.AGENTHUB_SHELL_PATH,
|
||||
};
|
||||
process.env.DASHBOARD_DATA_DIR = data;
|
||||
|
||||
const aiTools = require("../lib/ai-tools");
|
||||
const shell = require("../lib/shell-path");
|
||||
const versions = require("../lib/ai-tool-versions");
|
||||
const jobs = require("../lib/ai-tool-jobs");
|
||||
|
||||
const exe = (p) => {
|
||||
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||
fs.writeFileSync(p, "#!/bin/sh\n");
|
||||
fs.chmodSync(p, 0o755);
|
||||
return p;
|
||||
};
|
||||
const write = (name, doc) => fs.writeFileSync(path.join(cat, name), JSON.stringify(doc));
|
||||
const tool = (id, extra) => ({
|
||||
id,
|
||||
label: id,
|
||||
detect: [id],
|
||||
prompt: { mode: "argv" },
|
||||
homepageUrl: "https://docs.example.com/cli",
|
||||
...extra,
|
||||
});
|
||||
|
||||
/** A fake spawn: records argv/env, prints `out`, exits with `code` (or never, for "hang"). */
|
||||
let spawned = [];
|
||||
function fakeSpawn({ code = 0, out = "", hang = false } = {}) {
|
||||
return (file, args, opts) => {
|
||||
spawned.push({ argv: [file, ...args], opts });
|
||||
const child = new EventEmitter();
|
||||
child.stdout = new EventEmitter();
|
||||
child.stderr = new EventEmitter();
|
||||
child.kill = () => setImmediate(() => child.emit("close", null));
|
||||
if (!hang) {
|
||||
setImmediate(() => {
|
||||
if (out) child.stdout.emit("data", Buffer.from(out));
|
||||
child.emit("close", code);
|
||||
});
|
||||
}
|
||||
return child;
|
||||
};
|
||||
}
|
||||
|
||||
async function settle() {
|
||||
for (let i = 0; i < 400 && jobs.current()?.status === "running"; i++) {
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
}
|
||||
return jobs.current();
|
||||
}
|
||||
|
||||
let origFetch;
|
||||
before(async () => {
|
||||
origFetch = globalThis.fetch;
|
||||
write(
|
||||
"npmtool.json",
|
||||
tool("npmtool", { install: { kind: "npm", package: "npm-tool" }, update: { kind: "npm" } })
|
||||
);
|
||||
write(
|
||||
"solo.json",
|
||||
tool("solo", { install: { kind: "npm", package: "solo-pkg" }, update: { kind: "npm" } })
|
||||
);
|
||||
write(
|
||||
"fresh.json",
|
||||
tool("fresh", { install: { kind: "npm", package: "fresh-pkg" }, update: { kind: "npm" } })
|
||||
);
|
||||
write(
|
||||
"scripted.json",
|
||||
tool("scripted", { install: { kind: "script", url: "https://example.com/install" } })
|
||||
);
|
||||
write("selfy.json", tool("selfy", { update: { kind: "self", args: ["update", "--yes"] } }));
|
||||
write("plain.json", tool("plain"));
|
||||
exe(path.join(shellBin, "npm"));
|
||||
exe(path.join(serverBin, "npm"));
|
||||
// npmtool: npm-managed (bin symlink into <prefix>/lib/node_modules)
|
||||
exe(path.join(prefix, "lib", "node_modules", "npm-tool", "cli.js"));
|
||||
fs.mkdirSync(path.join(prefix, "bin"));
|
||||
fs.symlinkSync(
|
||||
path.join(prefix, "lib", "node_modules", "npm-tool", "cli.js"),
|
||||
path.join(prefix, "bin", "npmtool")
|
||||
);
|
||||
// solo: installed outside npm
|
||||
exe(path.join(standalone, "solo"));
|
||||
fs.symlinkSync(path.join(standalone, "solo"), path.join(serverBin, "solo"));
|
||||
exe(path.join(serverBin, "selfy"));
|
||||
process.env.PATH = [serverBin, path.join(prefix, "bin")].join(path.delimiter);
|
||||
delete process.env.AGENTHUB_SHELL_PATH;
|
||||
// The login shell prints its own PATH: its npm first.
|
||||
await shell.hydrateShellPath({
|
||||
spawn: (_c, _a, _o, cb) => {
|
||||
setImmediate(() => cb(null, `__AGENTHUB_PATH__${shellBin}__AGENTHUB_PATH__`));
|
||||
return { stdin: { end() {} } };
|
||||
},
|
||||
});
|
||||
// Later hydrations (at job end) must not spawn a real shell.
|
||||
process.env.AGENTHUB_SHELL_PATH = "0";
|
||||
aiTools.__setCatalogDirs([cat, empty]);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
spawned = [];
|
||||
jobs.__reset();
|
||||
jobs.__setSpawn(fakeSpawn());
|
||||
versions.__setExec(async (file, args) => {
|
||||
if (args[0] === "prefix") return { stdout: `${prefix}\n` };
|
||||
if (args[0] === "view") return { stdout: args[1] === "npm-tool" ? "2.0.0\n" : "1.0.0\n" };
|
||||
if (args[0] === "--version") return { stdout: "npmtool v1.4.2 (build x)\n" };
|
||||
throw new Error("unexpected " + file);
|
||||
});
|
||||
fs.rmSync(path.join(data, "ai-tools.json"), { force: true });
|
||||
aiTools.refresh();
|
||||
globalThis.fetch = origFetch;
|
||||
});
|
||||
|
||||
after(() => {
|
||||
globalThis.fetch = origFetch;
|
||||
process.env.PATH = saved.PATH;
|
||||
for (const [k, v] of [
|
||||
["DASHBOARD_DATA_DIR", saved.DATA],
|
||||
["AGENTHUB_SHELL_PATH", saved.SP],
|
||||
]) {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
jobs.__setSpawn(null);
|
||||
versions.__setExec(null);
|
||||
aiTools.__setCatalogDirs(null);
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("stepsFor", () => {
|
||||
it("installs with the login shell's npm, not the server's", async () => {
|
||||
assert.deepEqual(await jobs.stepsFor("fresh", "install"), [
|
||||
{ argv: [path.join(shellBin, "npm"), "install", "-g", "fresh-pkg@latest"] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("runs a self updater by its detected path", async () => {
|
||||
assert.deepEqual(await jobs.stepsFor("selfy", "update"), [
|
||||
{ argv: [path.join(serverBin, "selfy"), "update", "--yes"] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("refuses what the catalog or the machine does not allow", async () => {
|
||||
await assert.rejects(jobs.stepsFor("nope", "install"), { code: "ENOTOOL" });
|
||||
await assert.rejects(jobs.stepsFor("plain", "install"), { code: "ENOINSTALL" });
|
||||
await assert.rejects(jobs.stepsFor("npmtool", "install"), { code: "EINSTALLED" });
|
||||
await assert.rejects(jobs.stepsFor("plain", "update"), { code: "ENOUPDATE" });
|
||||
await assert.rejects(jobs.stepsFor("fresh", "update"), { code: "ENOTINSTALLED" });
|
||||
await assert.rejects(jobs.stepsFor("solo", "update"), { code: "ENOTNPM" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("versions", () => {
|
||||
it("marks npm-managed by realpath and badges a newer version", async () => {
|
||||
const v = await versions.versions({ fresh: true });
|
||||
assert.deepEqual(v.npmtool, {
|
||||
npmManaged: true,
|
||||
installed: "1.4.2",
|
||||
latest: "2.0.0",
|
||||
updateAvailable: true,
|
||||
});
|
||||
assert.equal(v.solo.npmManaged, false);
|
||||
assert.equal(v.solo.updateAvailable, false);
|
||||
assert.equal(v.fresh, undefined); // not installed
|
||||
});
|
||||
|
||||
it("shows no badge when npm view fails (offline)", async () => {
|
||||
versions.__setExec(async (_f, args) => {
|
||||
if (args[0] === "prefix") return { stdout: prefix };
|
||||
if (args[0] === "--version") return { stdout: "1.4.2" };
|
||||
throw new Error("ENOTFOUND");
|
||||
});
|
||||
const v = await versions.versions({ fresh: true });
|
||||
assert.equal(v.npmtool.latest, null);
|
||||
assert.equal(v.npmtool.updateAvailable, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("jobs", () => {
|
||||
it("runs the argv with stdin closed and the shell PATH first, then enables the tool", async () => {
|
||||
aiTools.setEnabled("fresh", false);
|
||||
const started = await jobs.start("fresh", "install");
|
||||
assert.equal(started.status, "running");
|
||||
assert.equal(started.child, undefined);
|
||||
// The fake install does not create a binary; pretend it did before the end.
|
||||
exe(path.join(serverBin, "fresh"));
|
||||
const done = await settle();
|
||||
assert.equal(done.status, "ok");
|
||||
assert.equal(spawned[0].opts.stdio[0], "ignore");
|
||||
assert.ok(spawned[0].opts.env.PATH.startsWith(shellBin + path.delimiter));
|
||||
assert.equal(aiTools.isEnabled("fresh"), true);
|
||||
fs.rmSync(path.join(serverBin, "fresh"));
|
||||
});
|
||||
|
||||
it("allows one job at a time", async () => {
|
||||
jobs.__setSpawn(fakeSpawn({ hang: true }));
|
||||
jobs.__reset({ timeoutMs: 50 });
|
||||
await jobs.start("fresh", "install");
|
||||
await assert.rejects(jobs.start("selfy", "update"), { code: "EBUSY" });
|
||||
const done = await settle();
|
||||
assert.equal(done.status, "failed");
|
||||
assert.match(done.log, /ETIMEDOUT/);
|
||||
});
|
||||
|
||||
it("caps the log, keeping the tail", async () => {
|
||||
jobs.__setSpawn(fakeSpawn({ out: "x".repeat(300 * 1024) + "END" }));
|
||||
await jobs.start("fresh", "install");
|
||||
const done = await settle();
|
||||
assert.ok(done.log.length <= 256 * 1024);
|
||||
assert.ok(done.log.endsWith("END"));
|
||||
});
|
||||
|
||||
it("adds a hint when npm fails with EACCES", async () => {
|
||||
jobs.__setSpawn(fakeSpawn({ code: 243, out: "npm error code EACCES\n" }));
|
||||
await jobs.start("fresh", "install");
|
||||
const done = await settle();
|
||||
assert.equal(done.status, "failed");
|
||||
assert.equal(done.exitCode, 243);
|
||||
assert.match(done.log, /user-level Node/);
|
||||
});
|
||||
|
||||
it("downloads a script to a temp file, runs it with bash, then deletes it", async () => {
|
||||
globalThis.fetch = async (url) => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
url: "https://downloads.example.com/install.sh",
|
||||
arrayBuffer: async () => Buffer.from("echo hi\n"),
|
||||
_url: url,
|
||||
});
|
||||
await jobs.start("scripted", "install");
|
||||
const done = await settle();
|
||||
assert.equal(done.status, "ok");
|
||||
const [bash, file] = spawned[0].argv;
|
||||
assert.equal(bash, "bash");
|
||||
assert.equal(fs.existsSync(file), false);
|
||||
});
|
||||
|
||||
it("refuses a script that redirects off its site or is too big", async () => {
|
||||
globalThis.fetch = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
url: "https://evil.example.net/x.sh",
|
||||
arrayBuffer: async () => Buffer.from("x"),
|
||||
});
|
||||
await jobs.start("scripted", "install");
|
||||
let done = await settle();
|
||||
assert.equal(done.status, "failed");
|
||||
assert.match(done.log, /redirected off example\.com/);
|
||||
assert.equal(spawned.length, 0);
|
||||
|
||||
globalThis.fetch = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
url: "https://example.com/install",
|
||||
arrayBuffer: async () => Buffer.alloc(1024 * 1024 + 1),
|
||||
});
|
||||
await jobs.start("scripted", "install");
|
||||
done = await settle();
|
||||
assert.match(done.log, /larger than 1 MB/);
|
||||
});
|
||||
|
||||
it("reports the end only after re-detecting, so the first poll sees the installed tool", async () => {
|
||||
// A slow login-shell read at the end: the job must not look finished before it.
|
||||
jobs.__setHydrate(() => new Promise((r) => setTimeout(() => r(false), 40)));
|
||||
jobs.__setSpawn((file, args) => {
|
||||
spawned.push({ argv: [file, ...args] });
|
||||
const child = new EventEmitter();
|
||||
child.stdout = new EventEmitter();
|
||||
child.stderr = new EventEmitter();
|
||||
setImmediate(() => {
|
||||
exe(path.join(serverBin, "fresh")); // what npm install -g would leave behind
|
||||
child.emit("close", 0);
|
||||
});
|
||||
return child;
|
||||
});
|
||||
aiTools.setEnabled("fresh", false);
|
||||
try {
|
||||
await jobs.start("fresh", "install");
|
||||
const done = await settle();
|
||||
assert.equal(done.status, "ok");
|
||||
assert.equal(aiTools.detect("fresh").installed, true);
|
||||
assert.equal(aiTools.isEnabled("fresh"), true);
|
||||
} finally {
|
||||
jobs.__setHydrate(null);
|
||||
fs.rmSync(path.join(serverBin, "fresh"), { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("ends a timed-out job even when a grandchild keeps the pipes open", async () => {
|
||||
const signals = [];
|
||||
jobs.__setSpawn((file, args) => {
|
||||
spawned.push({ argv: [file, ...args] });
|
||||
const child = new EventEmitter();
|
||||
child.stdout = new EventEmitter();
|
||||
child.stderr = new EventEmitter();
|
||||
// The direct child dies, but 'close' never comes: the pipes are still held.
|
||||
child.kill = (sig) => {
|
||||
signals.push(sig);
|
||||
setImmediate(() => child.emit("exit", null, sig));
|
||||
};
|
||||
return child;
|
||||
});
|
||||
jobs.__reset({ timeoutMs: 30 });
|
||||
await jobs.start("fresh", "install");
|
||||
const done = await settle();
|
||||
assert.equal(done.status, "failed");
|
||||
assert.match(done.log, /ETIMEDOUT/);
|
||||
assert.equal(signals[0], "SIGTERM");
|
||||
await assert.doesNotReject(jobs.start("selfy", "update"), "the slot is free again");
|
||||
await settle();
|
||||
});
|
||||
|
||||
it("survives a failure while finishing and still frees the slot", async (t) => {
|
||||
const unhandled = [];
|
||||
const onUnhandled = (e) => unhandled.push(e);
|
||||
process.on("unhandledRejection", onUnhandled);
|
||||
t.mock.method(aiTools, "setEnabled", () => {
|
||||
throw new Error("EROFS: read-only data dir");
|
||||
});
|
||||
try {
|
||||
jobs.__setSpawn(() => {
|
||||
const child = new EventEmitter();
|
||||
child.stdout = new EventEmitter();
|
||||
child.stderr = new EventEmitter();
|
||||
setImmediate(() => {
|
||||
exe(path.join(serverBin, "fresh"));
|
||||
child.emit("close", 0);
|
||||
});
|
||||
return child;
|
||||
});
|
||||
await jobs.start("fresh", "install");
|
||||
const done = await settle();
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
assert.deepEqual(unhandled, []);
|
||||
assert.notEqual(done.status, "running");
|
||||
assert.match(done.log, /EROFS/);
|
||||
} finally {
|
||||
process.off("unhandledRejection", onUnhandled);
|
||||
fs.rmSync(path.join(serverBin, "fresh"), { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("update-all runs the badged npm tools and every self updater", async () => {
|
||||
await jobs.startUpdateAll();
|
||||
const done = await settle();
|
||||
assert.equal(done.status, "ok");
|
||||
assert.deepEqual(
|
||||
spawned.map((s) => s.argv.slice(1).join(" ")),
|
||||
["install -g npm-tool@latest", "update --yes"]
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* @file Route tests for /api/ai-tools: list shape, enable/disable, default
|
||||
* errors, the same-origin guard, and /api/run/binary resolving through the
|
||||
* catalog.
|
||||
* errors, the same-origin guard, /api/run/binary resolving through the
|
||||
* catalog, and the A2 install/update jobs, versions and icon routes.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -19,7 +19,7 @@ process.env.CLAUDE_HOME = path.join(ROOT, ".claude");
|
||||
process.env.AGENTHUB_SHELL_PATH = "0";
|
||||
const BIN = path.join(ROOT, "bin");
|
||||
fs.mkdirSync(BIN);
|
||||
for (const name of ["claude", "agy"]) {
|
||||
for (const name of ["claude", "agy", "npm"]) {
|
||||
fs.writeFileSync(path.join(BIN, name), "#!/bin/sh\n");
|
||||
fs.chmodSync(path.join(BIN, name), 0o755);
|
||||
}
|
||||
@@ -133,3 +133,144 @@ describe("lanes on catalog tools", () => {
|
||||
await request("PUT", "/api/ai-tools/aider", { enabled: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("install / update / icons", () => {
|
||||
const { EventEmitter } = require("node:events");
|
||||
const jobs = require("../lib/ai-tool-jobs");
|
||||
let spawned = [];
|
||||
let hang = false;
|
||||
before(() => {
|
||||
jobs.__setSpawn((file, args) => {
|
||||
spawned.push([file, ...args]);
|
||||
const child = new EventEmitter();
|
||||
child.stdout = new EventEmitter();
|
||||
child.stderr = new EventEmitter();
|
||||
child.kill = () => setImmediate(() => child.emit("close", null));
|
||||
if (!hang) setImmediate(() => child.emit("close", 0));
|
||||
return child;
|
||||
});
|
||||
});
|
||||
after(() => jobs.__setSpawn(null));
|
||||
|
||||
const settle = async () => {
|
||||
for (let i = 0; i < 200; i++) {
|
||||
const r = await request("GET", "/api/ai-tools/job");
|
||||
if (r.body?.status !== "running") return r.body;
|
||||
await new Promise((res) => setTimeout(res, 5));
|
||||
}
|
||||
};
|
||||
|
||||
it("lists install/update and the catalog source", async () => {
|
||||
const { body } = await request("GET", "/api/ai-tools");
|
||||
const codex = body.tools.find((t) => t.id === "codex");
|
||||
assert.deepEqual(codex.install, { kind: "npm", package: "@openai/codex" });
|
||||
assert.deepEqual(codex.update, { kind: "npm" });
|
||||
assert.equal(body.catalog.source, "builtin");
|
||||
});
|
||||
|
||||
it("starts an install job and reports it", async () => {
|
||||
jobs.__reset();
|
||||
assert.equal((await request("GET", "/api/ai-tools/job")).body, null);
|
||||
const r = await request("POST", "/api/ai-tools/qwen-code/install");
|
||||
assert.equal(r.status, 202);
|
||||
assert.equal(r.body.job.tool, "qwen-code");
|
||||
const done = await settle();
|
||||
assert.equal(done.status, "ok");
|
||||
assert.deepEqual(spawned.at(-1), [
|
||||
path.join(BIN, "npm"),
|
||||
"install",
|
||||
"-g",
|
||||
"@qwen-code/qwen-code@latest",
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps refusals to statuses", async () => {
|
||||
jobs.__reset();
|
||||
assert.equal((await request("POST", "/api/ai-tools/nope/install")).status, 404);
|
||||
const noInstall = await request("POST", "/api/ai-tools/aider/install");
|
||||
assert.equal(noInstall.status, 400);
|
||||
assert.equal(noInstall.body.error.code, "ENOINSTALL");
|
||||
const notInstalled = await request("POST", "/api/ai-tools/codex/update");
|
||||
assert.equal(notInstalled.status, 409);
|
||||
assert.equal(notInstalled.body.error.code, "ENOTINSTALLED");
|
||||
// gemini was put on PATH by the refresh test, outside any npm prefix.
|
||||
const notNpm = await request("POST", "/api/ai-tools/gemini/update");
|
||||
assert.equal(notNpm.status, 409);
|
||||
assert.equal(notNpm.body.error.code, "ENOTNPM");
|
||||
const cross = await request("POST", "/api/ai-tools/qwen-code/install", undefined, {
|
||||
Origin: "https://evil.test",
|
||||
});
|
||||
assert.equal(cross.status, 403);
|
||||
});
|
||||
|
||||
it("answers EBUSY while a job runs", async () => {
|
||||
jobs.__reset({ timeoutMs: 50 });
|
||||
hang = true;
|
||||
assert.equal((await request("POST", "/api/ai-tools/copilot/install")).status, 202);
|
||||
const busy = await request("POST", "/api/ai-tools/amp/install");
|
||||
assert.equal(busy.status, 409);
|
||||
assert.equal(busy.body.error.code, "EBUSY");
|
||||
await settle();
|
||||
hang = false;
|
||||
});
|
||||
|
||||
it("update-all runs claude's own updater; versions lists npm tools", async () => {
|
||||
jobs.__reset();
|
||||
const r = await request("POST", "/api/ai-tools/update-all");
|
||||
assert.equal(r.status, 202);
|
||||
await settle();
|
||||
assert.deepEqual(spawned.at(-1), [path.join(BIN, "claude"), "update"]);
|
||||
const v = await request("GET", "/api/ai-tools/versions");
|
||||
assert.equal(v.status, 200);
|
||||
assert.equal(v.body.gemini.npmManaged, false);
|
||||
});
|
||||
|
||||
it("update-all answers ENOTHING when nothing has an update", async () => {
|
||||
jobs.__reset();
|
||||
await request("PUT", "/api/ai-tools/claude", { enabled: true });
|
||||
fs.renameSync(path.join(BIN, "claude"), path.join(BIN, "claude.off"));
|
||||
try {
|
||||
await request("POST", "/api/ai-tools/refresh");
|
||||
const r = await request("POST", "/api/ai-tools/update-all");
|
||||
assert.equal(r.status, 400);
|
||||
assert.equal(r.body.error.code, "ENOTHING");
|
||||
} finally {
|
||||
fs.renameSync(path.join(BIN, "claude.off"), path.join(BIN, "claude"));
|
||||
await request("POST", "/api/ai-tools/refresh");
|
||||
}
|
||||
});
|
||||
|
||||
it("refresh drops the cached versions", async () => {
|
||||
const versions = require("../lib/ai-tool-versions");
|
||||
let calls = 0;
|
||||
versions.__setExec(async () => {
|
||||
calls++;
|
||||
return { stdout: "" };
|
||||
});
|
||||
try {
|
||||
await request("GET", "/api/ai-tools/versions");
|
||||
const before = calls;
|
||||
await request("GET", "/api/ai-tools/versions");
|
||||
assert.equal(calls, before, "second read is cached");
|
||||
await request("POST", "/api/ai-tools/refresh");
|
||||
await request("GET", "/api/ai-tools/versions");
|
||||
assert.ok(calls > before, "a read after Refresh recomputes");
|
||||
} finally {
|
||||
versions.__setExec(null);
|
||||
}
|
||||
});
|
||||
|
||||
it("answers 204 for a tool without an icon and 404 for an unknown id", async () => {
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () => {
|
||||
throw new Error("offline");
|
||||
};
|
||||
try {
|
||||
const r = await origFetch(`${base}/api/ai-tools/claude/icon`);
|
||||
assert.equal(r.status, 204);
|
||||
assert.equal((await origFetch(`${base}/api/ai-tools/nope/icon`)).status, 404);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* @file Tests for server/lib/ai-tools-sync.js — the daily copy of the newest
|
||||
* release's AI Tools catalog: newer-only, validated per file, replaced whole,
|
||||
* kept on failure, and ignored once the running build catches up.
|
||||
* @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 data = fs.mkdtempSync(path.join(os.tmpdir(), "ai-sync-"));
|
||||
const origData = process.env.DASHBOARD_DATA_DIR;
|
||||
process.env.DASHBOARD_DATA_DIR = data;
|
||||
|
||||
const sync = require("../lib/ai-tools-sync");
|
||||
|
||||
const REPO = "https://forge.example.com/Owner/Repo.git";
|
||||
const API = "https://forge.example.com/api/v1/repos/Owner/Repo";
|
||||
const tool = (id, extra = {}) =>
|
||||
JSON.stringify({ id, label: id, detect: [id], prompt: { mode: "argv" }, ...extra });
|
||||
|
||||
/** A fake fetch over a map of URL → body (string or object) or a status number. */
|
||||
function fakeFetch(routes) {
|
||||
const calls = [];
|
||||
const fn = async (url) => {
|
||||
calls.push(String(url));
|
||||
const r = routes[String(url)];
|
||||
if (r === undefined || typeof r === "number") {
|
||||
return { ok: false, status: r || 404, json: async () => ({}), text: async () => "" };
|
||||
}
|
||||
const text = typeof r === "string" ? r : JSON.stringify(r);
|
||||
return { ok: true, status: 200, json: async () => JSON.parse(text), text: async () => text };
|
||||
};
|
||||
fn.calls = calls;
|
||||
return fn;
|
||||
}
|
||||
|
||||
const listing = (names) =>
|
||||
names.map((n) => ({ type: "file", name: n, download_url: `https://forge.example.com/raw/${n}` }));
|
||||
|
||||
function routesFor(tag, files) {
|
||||
const r = {
|
||||
[`${API}/releases/latest`]: { tag_name: tag },
|
||||
[`${API}/contents/server/data/ai-tools?ref=${tag}`]: listing(Object.keys(files)),
|
||||
};
|
||||
for (const [n, body] of Object.entries(files)) r[`https://forge.example.com/raw/${n}`] = body;
|
||||
return r;
|
||||
}
|
||||
|
||||
let origFetch;
|
||||
before(() => {
|
||||
origFetch = globalThis.fetch;
|
||||
});
|
||||
beforeEach(() => {
|
||||
fs.rmSync(path.join(data, "runtime"), { recursive: true, force: true });
|
||||
delete process.env.AGENTHUB_AI_TOOLS_SYNC;
|
||||
sync.__setVersion("1.8.11");
|
||||
});
|
||||
after(() => {
|
||||
globalThis.fetch = origFetch;
|
||||
sync.__setVersion(null);
|
||||
if (origData === undefined) delete process.env.DASHBOARD_DATA_DIR;
|
||||
else process.env.DASHBOARD_DATA_DIR = origData;
|
||||
fs.rmSync(data, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("syncOnce", () => {
|
||||
it("writes nothing when the newest release is not newer", async () => {
|
||||
globalThis.fetch = fakeFetch(routesFor("v1.8.11", { "a.json": tool("a") }));
|
||||
const r = await sync.syncOnce({ repoUrl: REPO });
|
||||
assert.deepEqual(r, { ok: false, reason: "not_newer" });
|
||||
assert.equal(sync.activeRemoteDir(), null);
|
||||
});
|
||||
|
||||
it("copies valid files of a newer release and drops invalid ones", async () => {
|
||||
globalThis.fetch = fakeFetch(
|
||||
routesFor("v1.9.0", {
|
||||
"a.json": tool("a"),
|
||||
"bad.json": "{nope",
|
||||
"codex.json": JSON.stringify({
|
||||
id: "codex",
|
||||
label: "Codex",
|
||||
detect: ["codex"],
|
||||
launcher: "codex",
|
||||
}),
|
||||
})
|
||||
);
|
||||
const r = await sync.syncOnce({ repoUrl: REPO });
|
||||
assert.equal(r.ok, true);
|
||||
assert.equal(r.tag, "v1.9.0");
|
||||
const dir = sync.activeRemoteDir();
|
||||
assert.ok(dir);
|
||||
assert.deepEqual(fs.readdirSync(dir).sort(), ["a.json", "codex.json", "meta.json"]);
|
||||
assert.deepEqual(sync.catalogMeta().source, "remote");
|
||||
assert.equal(sync.catalogMeta().tag, "v1.9.0");
|
||||
});
|
||||
|
||||
it("keeps the previous set when a download fails", async () => {
|
||||
globalThis.fetch = fakeFetch(routesFor("v1.9.0", { "a.json": tool("a") }));
|
||||
await sync.syncOnce({ repoUrl: REPO });
|
||||
const routes = routesFor("v1.10.0", { "b.json": tool("b") });
|
||||
routes["https://forge.example.com/raw/b.json"] = 500;
|
||||
globalThis.fetch = fakeFetch(routes);
|
||||
const r = await sync.syncOnce({ repoUrl: REPO });
|
||||
assert.equal(r.ok, false);
|
||||
assert.equal(sync.catalogMeta().tag, "v1.9.0");
|
||||
assert.ok(fs.existsSync(path.join(sync.activeRemoteDir(), "a.json")));
|
||||
});
|
||||
|
||||
it("refuses an oversized file, an off-origin download and a huge listing", async () => {
|
||||
const routes = routesFor("v1.9.0", { "big.json": tool("big", { label: "x".repeat(70_000) }) });
|
||||
routes[`${API}/contents/server/data/ai-tools?ref=v1.9.0`].push({
|
||||
type: "file",
|
||||
name: "evil.json",
|
||||
download_url: "https://evil.example.net/evil.json",
|
||||
});
|
||||
routes["https://evil.example.net/evil.json"] = tool("evil");
|
||||
globalThis.fetch = fakeFetch(routes);
|
||||
const r = await sync.syncOnce({ repoUrl: REPO });
|
||||
assert.equal(r.ok, true);
|
||||
assert.deepEqual(fs.readdirSync(sync.activeRemoteDir()), ["meta.json"]);
|
||||
assert.ok(!globalThis.fetch.calls.includes("https://evil.example.net/evil.json"));
|
||||
|
||||
const many = {};
|
||||
for (let i = 0; i < 201; i++) many[`t${i}.json`] = tool(`t${i}`);
|
||||
globalThis.fetch = fakeFetch(routesFor("v1.9.1", many));
|
||||
assert.deepEqual(await sync.syncOnce({ repoUrl: REPO }), { ok: false, reason: "bad_listing" });
|
||||
});
|
||||
|
||||
it("does nothing with AGENTHUB_AI_TOOLS_SYNC=0", async () => {
|
||||
process.env.AGENTHUB_AI_TOOLS_SYNC = "0";
|
||||
globalThis.fetch = fakeFetch({});
|
||||
assert.deepEqual(await sync.syncOnce({ repoUrl: REPO }), { ok: false, reason: "disabled" });
|
||||
assert.equal(globalThis.fetch.calls.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("activeRemoteDir", () => {
|
||||
it("ignores a synced set once the running build is as new", async () => {
|
||||
globalThis.fetch = fakeFetch(routesFor("v1.9.0", { "a.json": tool("a") }));
|
||||
await sync.syncOnce({ repoUrl: REPO });
|
||||
assert.ok(sync.activeRemoteDir());
|
||||
sync.__setVersion("1.9.0");
|
||||
assert.equal(sync.activeRemoteDir(), null);
|
||||
assert.equal(sync.catalogMeta().source, "builtin");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* @file Tests for server/lib/ai-tools.js — catalog validation and override,
|
||||
* PATH detection over fake executables, and the enable/default state rules.
|
||||
* 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>
|
||||
*/
|
||||
|
||||
@@ -195,6 +196,8 @@ describe("enable / default", () => {
|
||||
resume: false,
|
||||
bypass: false,
|
||||
routerCapable: false,
|
||||
install: null,
|
||||
update: null,
|
||||
});
|
||||
assert.equal(v.tools.find((t) => t.id === "codex").routerCapable, true);
|
||||
});
|
||||
@@ -213,3 +216,113 @@ describe("built-in catalog", () => {
|
||||
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"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
const { describe, it, beforeEach, afterEach } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { hydrateShellPath } = require("../lib/shell-path");
|
||||
const { hydrateShellPath, shellPath } = require("../lib/shell-path");
|
||||
|
||||
const M = "__AGENTHUB_PATH__";
|
||||
const stub =
|
||||
@@ -38,6 +38,11 @@ describe("hydrateShellPath", () => {
|
||||
assert.equal(process.env.PATH, "/usr/bin:/bin:/home/u/.local/bin:/opt/x");
|
||||
});
|
||||
|
||||
it("remembers the shell's own PATH in the shell's order", async () => {
|
||||
await hydrateShellPath({ spawn: stub(`${M}/home/u/.nvm/bin:/usr/bin${M}`) });
|
||||
assert.equal(shellPath(), "/home/u/.nvm/bin:/usr/bin");
|
||||
});
|
||||
|
||||
it("keeps PATH on error or missing delimiters", async () => {
|
||||
assert.equal(await hydrateShellPath({ spawn: stub("", new Error("timeout")) }), false);
|
||||
assert.equal(await hydrateShellPath({ spawn: stub("no markers") }), false);
|
||||
|
||||
@@ -29,6 +29,7 @@ const {
|
||||
pluginMarketplaceUrl,
|
||||
fetchReleaseFromUrl,
|
||||
detectPluginInstall,
|
||||
repoUrlFor,
|
||||
} = require("../lib/update-check");
|
||||
|
||||
let tmpDir;
|
||||
@@ -103,6 +104,17 @@ function release(tag) {
|
||||
};
|
||||
}
|
||||
|
||||
describe("repoUrlFor", () => {
|
||||
it("answers a plugin-cache root from the marketplace manifest, no git", async () => {
|
||||
const { root } = makePluginInstall("repourl");
|
||||
assert.equal(await repoUrlFor(root), "https://forge.example.com/Owner/Repo.git");
|
||||
});
|
||||
|
||||
it("answers null for a directory with neither a manifest nor a remote", async () => {
|
||||
assert.equal(await repoUrlFor(tmpDir), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pluginMarketplaceUrl", () => {
|
||||
it("reads the plugin's repository URL out of the marketplace manifest", () => {
|
||||
makePluginInstall("ok");
|
||||
|
||||
Reference in New Issue
Block a user