feat(ai-tools): install/update AI CLIs, remote catalog sync, cached favicons (#40 A2)

This commit is contained in:
2026-09-29 08:25:37 +07:00
parent fcfebe44f1
commit 7ec5a7cd85
40 changed files with 3145 additions and 92 deletions
+111
View File
@@ -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);
});
});
+383
View File
@@ -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"]
);
});
});
+144 -3
View File
@@ -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;
}
});
});
+150
View File
@@ -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");
});
});
+114 -1
View File
@@ -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"],
});
});
});
+6 -1
View File
@@ -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");