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
+7
View File
@@ -538,6 +538,12 @@ Request body shape:
| `GET` | `/api/ai-tools` | The AI Tools catalog (`server/lib/ai-tools.js`: `server/data/ai-tools/*.json` + `DASHBOARD_AI_TOOLS_DIR`) with detection, enabled flag and the effective default. Flags only, never arg arrays. |
| `POST` | `/api/ai-tools/refresh` | Same-origin guarded: merge the login shell's PATH (`server/lib/shell-path.js`), re-detect. |
| `PUT` | `/api/ai-tools/:id` | Same-origin guarded `{enabled?, default?: true}`. `404 ENOTOOL`, `400 EBADDEFAULT`/`EBADFIELD`. |
| `POST` | `/api/ai-tools/:id/install` | Same-origin guarded: start the catalog's install job (`server/lib/ai-tool-jobs.js` — `npm install -g <pkg>@latest` with the login shell's npm, or a vendor script downloaded and run with `bash`). `202 {job}`; `404 ENOTOOL`, `400 ENOINSTALL`, `409 EINSTALLED`/`ENONPM`/`EBUSY`. |
| `POST` | `/api/ai-tools/:id/update` | Same-origin guarded: start the update job; npm only for an npm-managed tool (`server/lib/ai-tool-versions.js`), else `409 ENOTNPM`. `400 ENOUPDATE`, `409 ENOTINSTALLED`/`EBUSY`. |
| `POST` | `/api/ai-tools/update-all` | Same-origin guarded: one job over every npm-managed tool with a newer version plus every self updater. `400 ENOTHING`. |
| `GET` | `/api/ai-tools/job` | The current or last job (in memory), or `null`. |
| `GET` | `/api/ai-tools/versions` | npm version badges, cached 24 h. |
| `GET` | `/api/ai-tools/:id/icon` | The server-cached favicon (`server/lib/ai-tool-icons.js`), raster only; `204` when there is none (a normal state — no console error per letter avatar), `404` for an unknown id. |
| `POST` | `/api/lanes/:id/git/:action` | Same-origin guarded git write, serialized under the lane lock: `fetch`, `pull` (always `--ff-only`), `push` (`-u origin HEAD`), `stage`/`unstage`/`discard` (per file; a path must be one git reports changed, and `discard` needs `confirm:true`), `stage-hunk`/`discard-hunk` (one hunk by its `@@` line, re-read fresh — `409 EHUNKGONE` when it moved; `discard-hunk` needs `confirm:true`), `commit` (`add -A` + `-m`, or `only:"staged"`), `switch` (refuses a dirty tree), `branch-create` (never switches) `branch-delete` (`-d`, refuses protected/current/busy branches) and `publish` (`{url}`: `remote add origin` + `push -u origin HEAD`, only when the repo has no remote; URL allowlisted to https-without-userinfo/ssh/scp-like; remote rolled back on a failed push). `merge` (clean tree, repo-root lane, a ref that exists; conflicts come back as `conflicts[]`), `resolve` (`side: ours|theirs|done`, conflicted paths only, `done` refuses leftover markers), `merge-commit`, `merge-abort` (`confirm:true`); `EMERGING` blocks pull/switch/merge/hunk actions mid-merge. No rebase/reset/stash by design. A remote wanting a login is `409 EGITAUTH` with `auth: {protocol, host, username, rejected}`; resend with `credentials: {protocol, host, username, password, remember}` (a repo remote's host only, else `400 EBADHOST`; saved only after git succeeds and `remember` is set). |
The route accepts `{ sourceRepo, title, base?, slug? }`. `sourceRepo` must be
@@ -1522,6 +1528,7 @@ DASHBOARD_DB_PATH=./data/dashboard.db # SQLite database path
# AI Tools
DASHBOARD_AI_TOOLS_DIR= # Extra AI Tools catalog JSON files; an id here replaces the built-in one
AGENTHUB_SHELL_PATH=1 # 0 = never run the login shell to read its PATH (startup + Refresh)
AGENTHUB_AI_TOOLS_SYNC=1 # 0 = never copy the newest release's AI Tools catalog (server/lib/ai-tools-sync.js, daily)
# Background services
DASHBOARD_SESSION_SYNC_MS=30000 # Continuous project-sync poll interval (ms); 0 disables the poll (watcher stays)
+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");
+18
View File
@@ -6,6 +6,24 @@ One JSON file per AI coding CLI AgentHub can run in a lane. Loaded by
`DASHBOARD_AI_TOOLS_DIR` with the same `id` replaces the built-in one (it may
not set `launcher`).
## Install / update (A2)
Two optional fields, honoured only in built-in files and in the copy of the
newest release's catalog that `server/lib/ai-tools-sync.js` keeps in
`<data dir>/runtime/ai-tools-remote/` — a file in `DASHBOARD_AI_TOOLS_DIR`
carrying either is rejected, because an Install click would run what it names:
- `install`: `{ "kind": "npm", "package": "<npm name>" }` (runs
`npm install -g <package>@latest` with the login shell's npm) or
`{ "kind": "script", "url": "https://…" }` (https, same site — last two host
labels — as `homepageUrl`; downloaded by the server, run as `bash <file>`).
- `update`: `{ "kind": "npm" }` or `{ "kind": "script" }` (reuse `install`),
or `{ "kind": "self", "args": ["update"] }` (the tool's own updater).
Before adding an npm package, check its owner (`npm view <pkg> maintainers
repository.url`) and that its `bin` matches `detect`: a squatted name would
install a stranger's code on every machine that clicks Install.
## Attribution
The commands, prompt modes, permission-bypass flags and homepages of the tools
+8 -1
View File
@@ -12,5 +12,12 @@
"--dangerously-allow-all"
],
"homepageUrl": "https://ampcode.com/manual#install",
"faviconDomain": "ampcode.com"
"faviconDomain": "ampcode.com",
"install": {
"kind": "npm",
"package": "@sourcegraph/amp"
},
"update": {
"kind": "npm"
}
}
+7 -1
View File
@@ -5,5 +5,11 @@
"claude"
],
"launcher": "claude",
"homepageUrl": "https://code.claude.com/docs"
"homepageUrl": "https://code.claude.com/docs",
"update": {
"kind": "self",
"args": [
"update"
]
}
}
+8 -1
View File
@@ -8,5 +8,12 @@
"bypass": [
"--dangerously-bypass-approvals-and-sandbox"
],
"homepageUrl": "https://github.com/openai/codex"
"homepageUrl": "https://github.com/openai/codex",
"install": {
"kind": "npm",
"package": "@openai/codex"
},
"update": {
"kind": "npm"
}
}
+8 -1
View File
@@ -11,5 +11,12 @@
"bypass": [
"--yolo"
],
"homepageUrl": "https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"
"homepageUrl": "https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli",
"install": {
"kind": "npm",
"package": "@github/copilot"
},
"update": {
"kind": "npm"
}
}
+8 -1
View File
@@ -11,5 +11,12 @@
"--yolo"
],
"homepageUrl": "https://cursor.com/cli",
"faviconDomain": "cursor.com"
"faviconDomain": "cursor.com",
"install": {
"kind": "script",
"url": "https://cursor.com/install"
},
"update": {
"kind": "script"
}
}
+8 -1
View File
@@ -12,5 +12,12 @@
"--yolo"
],
"homepageUrl": "https://github.com/google-gemini/gemini-cli",
"faviconDomain": "gemini.google.com"
"faviconDomain": "gemini.google.com",
"install": {
"kind": "npm",
"package": "@google/gemini-cli"
},
"update": {
"kind": "npm"
}
}
+8 -1
View File
@@ -8,5 +8,12 @@
"mode": "flag",
"flag": "--prompt"
},
"homepageUrl": "https://opencode.ai/docs/cli/"
"homepageUrl": "https://opencode.ai/docs/cli/",
"install": {
"kind": "npm",
"package": "opencode-ai"
},
"update": {
"kind": "npm"
}
}
+8 -1
View File
@@ -13,5 +13,12 @@
"yolo"
],
"homepageUrl": "https://github.com/QwenLM/qwen-code",
"faviconDomain": "qwenlm.github.io"
"faviconDomain": "qwenlm.github.io",
"install": {
"kind": "npm",
"package": "@qwen-code/qwen-code"
},
"update": {
"kind": "npm"
}
}
+2
View File
@@ -351,6 +351,8 @@ function startBackgroundServices() {
// Rates for models seen in token_usage with no pricing row of their own.
require("./lib/pricing-sync").startPricingSync(require("./db").db);
// The AI Tools catalog of the newest release, when it is newer than this build.
require("./lib/ai-tools-sync").startAiToolsSync();
// A provisioning job belongs to the previous Node process and cannot survive
// a restart. Recover those rows now so the dashboard never shows a permanent
+109
View File
@@ -0,0 +1,109 @@
/**
* @file Server-side favicon cache for the AI Tools list, so the browser never
* calls a third-party site. One `https://<domain>/favicon.ico` fetch per tool
* — the domain is the catalog's `faviconDomain` or the homepage host, a dotted
* hostname only (no IP literal, no port) — kept in `<data dir>/runtime/
* ai-tool-icons/`. Raster types only, recognised by their MAGIC BYTES — the
* header is ignored, since a site may label anything image/png (or send no
* type at all) — because an SVG or HTML page served from the dashboard's own
* origin would run script if its URL were opened directly. A miss is
* remembered for a day.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
const { getDataDir } = require("./claude-home");
const aiTools = require("./ai-tools");
// Multi-resolution .ico files are routinely 100 KB+ (cursor.com's is 115 KB).
const MAX_BYTES = 256 * 1024;
const MISS_MS = 24 * 60 * 60 * 1000;
const DOMAIN_RE = /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;
/** The raster type of `body` by its signature, or null (SVG, HTML, anything else). */
function sniff(body) {
const hex = body.subarray(0, 4).toString("hex");
if (hex === "89504e47") return "image/png";
if (hex === "00000100") return "image/x-icon";
if (hex === "47494638") return "image/gif";
if (hex.startsWith("ffd8ff")) return "image/jpeg";
if (hex === "52494646" && body.subarray(8, 12).toString("latin1") === "WEBP") {
return "image/webp";
}
return null;
}
const iconDir = () => path.join(getDataDir(), "runtime", "ai-tool-icons");
/** The domain to ask, or null when the tool names none we may fetch. */
function iconDomain(tool) {
let d = tool?.faviconDomain;
if (!d && tool?.homepageUrl) {
try {
const u = new URL(tool.homepageUrl);
d = u.port ? null : u.hostname;
} catch {
d = null;
}
}
d = typeof d === "string" ? d.toLowerCase() : null;
return d && DOMAIN_RE.test(d) ? d : null;
}
function readCached(id) {
const dir = iconDir();
try {
const meta = JSON.parse(fs.readFileSync(path.join(dir, `${id}.json`), "utf8"));
if (meta.miss) return Date.now() - meta.at < MISS_MS ? { miss: true } : null;
return { type: meta.type, body: fs.readFileSync(path.join(dir, `${id}.img`)) };
} catch {
return null;
}
}
function writeCached(id, hit) {
const dir = iconDir();
fs.mkdirSync(dir, { recursive: true });
if (hit) fs.writeFileSync(path.join(dir, `${id}.img`), hit.body);
const meta = hit ? { type: hit.type, at: Date.now() } : { miss: true, at: Date.now() };
fs.writeFileSync(path.join(dir, `${id}.json`), JSON.stringify(meta));
}
// ponytail: only the root /favicon.ico — ~22 of the 38 catalog sites serve a
// raster one there; the rest keep their letter. Parse the homepage's
// <link rel="icon"> if more real icons are wanted (many of those are SVG).
async function fetchIcon(domain) {
try {
const res = await fetch(`https://${domain}/favicon.ico`, {
signal: AbortSignal.timeout(10_000),
redirect: "follow",
});
const final = new URL(res.url || `https://${domain}/`);
const sameSite = aiTools.siteOf(final.hostname) === aiTools.siteOf(domain);
if (!res.ok || final.protocol !== "https:" || !sameSite) return null;
const body = Buffer.from(await res.arrayBuffer());
if (body.length > MAX_BYTES) return null;
const type = sniff(body);
return type ? { type, body } : null;
} catch {
return null;
}
}
/** `{type, body}` for tool `id`, or null (letter avatar). Never throws. */
async function iconFor(id) {
const tool = aiTools.get(id);
const domain = iconDomain(tool);
if (!domain) return null;
const cached = readCached(id);
if (cached) return cached.miss ? null : cached;
const hit = await fetchIcon(domain);
try {
writeCached(id, hit);
} catch {
/* a read-only data dir only costs the cache */
}
return hit;
}
module.exports = { iconFor, iconDomain };
+295
View File
@@ -0,0 +1,295 @@
/**
* @file The one-at-a-time install/update runner behind Settings → AI Tools.
* Every command comes from a catalog file (never a request) and runs as an
* argv with stdin closed and the login shell's PATH first: `npm install -g
* <pkg>@latest`, a tool's own updater, or a vendor script the server
* downloads (https, same site as the tool's homepage, ≤ 1 MB) and runs with
* `bash <file>`. The job lives in memory — a child of the server, so it dies
* with it — and it reports its end only AFTER the PATH is re-read and the tool
* re-detected, so the client's first look at a finished job is already true.
* Each command runs in its own process group: a timeout kills the whole group
* (SIGTERM, then SIGKILL), and a job whose child has exited ends even when a
* leftover grandchild still holds its output pipes.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const crypto = require("node:crypto");
const { spawn } = require("node:child_process");
const aiTools = require("./ai-tools");
const versions = require("./ai-tool-versions");
const { hydrateShellPath } = require("./shell-path");
const LOG_CAP = 256 * 1024;
let jobTimeoutMs = 10 * 60 * 1000;
const SCRIPT_MAX = 1024 * 1024;
const EACCES_HINT =
"\n[agenthub] npm could not write its global directory (EACCES): this Node is installed " +
"system-wide. Use a user-level Node (nvm, fnm) or install the tool by hand.\n";
const EXIT_GRACE_MS = 500; // after 'exit', how long to wait for the pipes to close
const KILL_GRACE_MS = 5000; // SIGTERM → SIGKILL
let spawnImpl = spawn;
let hydrate = hydrateShellPath;
let job = null;
let starting = false;
let seq = 0;
function makeErr(code, message) {
const err = new Error(message);
err.code = code;
return err;
}
const npmArgv = (pkg) => {
const npm = versions.npmBin();
if (!npm) throw makeErr("ENONPM", "npm was not found on the login shell's PATH");
return [npm, "install", "-g", `${pkg}@latest`];
};
/** The steps one action on one tool would run — `{argv}` or `{script: url}`. */
async function stepsFor(id, action) {
const tool = aiTools.get(id);
if (!tool) throw makeErr("ENOTOOL", `unknown AI tool: ${id}`);
const d = aiTools.detect(id);
if (action === "install") {
if (!tool.install) throw makeErr("ENOINSTALL", `${tool.label} has no install command`);
if (d.installed) throw makeErr("EINSTALLED", `${tool.label} is already installed`);
return tool.install.kind === "npm"
? [{ argv: npmArgv(tool.install.package) }]
: [{ script: tool.install.url }];
}
if (!tool.update) throw makeErr("ENOUPDATE", `${tool.label} has no update command`);
if (!d.installed) throw makeErr("ENOTINSTALLED", `${tool.label} is not installed`);
if (tool.update.kind === "self") return [{ argv: [d.path, ...tool.update.args] }];
if (tool.update.kind === "script") return [{ script: tool.install.url }];
if (!(await versions.npmManaged(id))) {
throw makeErr(
"ENOTNPM",
`${tool.label} was installed outside npm; update it the way it was installed`
);
}
return [{ argv: npmArgv(tool.install.package) }];
}
function append(j, text) {
j.log += text;
if (j.log.length > LOG_CAP) j.log = j.log.slice(-LOG_CAP);
}
/** Download a vendor script to a 0600 temp file; the final URL must stay on its site. */
async function download(url) {
const res = await fetch(url, { signal: AbortSignal.timeout(15_000), redirect: "follow" });
const final = new URL(res.url || url);
const site = aiTools.siteOf(new URL(url).hostname);
if (final.protocol !== "https:" || aiTools.siteOf(final.hostname) !== site) {
throw new Error(`script redirected off ${site}: ${final.href}`);
}
if (!res.ok) throw new Error(`script download failed: HTTP ${res.status}`);
const body = Buffer.from(await res.arrayBuffer());
if (body.length > SCRIPT_MAX) throw new Error("script is larger than 1 MB");
const file = path.join(
os.tmpdir(),
`agenthub-install-${crypto.randomBytes(6).toString("hex")}.sh`
);
fs.writeFileSync(file, body, { mode: 0o600 });
return file;
}
/** Signal the child's whole process group; the child alone when it has none. */
function killTree(child, sig) {
try {
if (child.pid) return void process.kill(-child.pid, sig);
} catch {
/* group already gone — try the child itself */
}
try {
child.kill?.(sig);
} catch {
/* already dead */
}
}
function runArgv(j, argv) {
return new Promise((resolve) => {
append(j, `$ ${argv.join(" ")}\n`);
let child;
let done = false;
const end = (code) => {
if (done) return;
done = true;
j.child = null;
resolve(code == null ? 1 : code);
};
try {
child = spawnImpl(argv[0], argv.slice(1), {
stdio: ["ignore", "pipe", "pipe"],
env: { ...process.env, PATH: versions.jobPath() },
detached: true, // its own process group, so a timeout reaches grandchildren
});
} catch (err) {
append(j, `${err.message}\n`);
return resolve(127);
}
j.child = child;
child.stdout?.on("data", (b) => append(j, String(b)));
child.stderr?.on("data", (b) => append(j, String(b)));
child.on("error", (err) => append(j, `${err.message}\n`));
child.on("close", (code) => end(code));
// 'close' waits for every holder of the pipes; a daemon an installer left
// behind would hold them forever and keep the job — and EBUSY — alive.
child.on("exit", (code) => setTimeout(() => end(code), EXIT_GRACE_MS).unref?.());
});
}
async function runSteps(j, steps, onStepOk) {
const timer = setTimeout(() => {
j.timedOut = true;
append(j, "\n[agenthub] ETIMEDOUT: the job ran past 10 minutes and was stopped.\n");
const child = j.child;
if (!child) return;
killTree(child, "SIGTERM");
setTimeout(() => killTree(child, "SIGKILL"), KILL_GRACE_MS).unref?.();
}, jobTimeoutMs);
timer.unref?.();
let code = 0;
try {
for (const step of steps) {
let argv = step.argv;
let tmp = null;
if (step.script) {
append(j, `$ download ${step.script}\n`);
tmp = await download(step.script);
argv = ["bash", tmp];
}
try {
code = await runArgv(j, argv);
} finally {
if (tmp) fs.rmSync(tmp, { force: true });
}
if (j.timedOut) code = code || 124;
if (code !== 0) {
if (/EACCES/.test(j.log) && /npm/.test(path.basename(argv[0]))) append(j, EACCES_HINT);
break;
}
onStepOk?.(step);
}
} catch (err) {
append(j, `${err.message}\n`);
code = code || 1;
} finally {
clearTimeout(timer);
}
return code;
}
function newJob(tool, action) {
return {
id: ++seq,
tool,
action,
status: "running",
startedAt: new Date().toISOString(),
endedAt: null,
exitCode: null,
log: "",
};
}
/**
* Re-detect, THEN report the end: the client refetches the list once, on the
* first poll that sees a non-running status. Never throws — a failure here
* (a read-only data dir, a tool gone from the catalog mid-job) goes to the log.
*/
async function finish(j, code) {
try {
await hydrate().catch(() => {});
aiTools.refresh();
versions.invalidate();
if (code === 0 && j.action === "install" && aiTools.detect(j.tool).installed) {
aiTools.setEnabled(j.tool, true);
}
} catch (err) {
append(j, `\n[agenthub] ${err.message}\n`);
} finally {
j.exitCode = code;
j.endedAt = new Date().toISOString();
j.status = code === 0 ? "ok" : "failed";
}
}
function claim() {
if (starting || job?.status === "running")
throw makeErr("EBUSY", "another install or update is running");
starting = true;
}
/**
* Start `install` or `update` on one tool. Resolves once it has STARTED with
* the public job; the job itself runs on and is read through current().
*/
async function start(id, action) {
claim();
let steps;
try {
steps = await stepsFor(id, action);
} finally {
starting = false;
}
job = newJob(id, action);
const j = job;
void runSteps(j, steps)
.then((code) => finish(j, code))
.catch(() => finish(j, 1));
return view(j);
}
/** One job that updates every npm-managed tool with a newer version and every self-updater. */
async function startUpdateAll() {
claim();
let steps = [];
try {
const v = await versions.versions();
for (const t of aiTools.list()) {
if (!aiTools.detect(t.id).installed || !t.update) continue;
if (t.update.kind === "self" || (t.update.kind === "npm" && v[t.id]?.updateAvailable)) {
steps.push(...(await stepsFor(t.id, "update")));
}
}
if (!steps.length) throw makeErr("ENOTHING", "no AI tool has an update to run");
} finally {
starting = false;
}
job = newJob(null, "update-all");
const j = job;
void runSteps(j, steps)
.then((code) => finish(j, code))
.catch(() => finish(j, 1));
return view(j);
}
function view(j) {
if (!j) return null;
const { child: _c, timedOut: _t, ...rest } = j;
return rest;
}
const current = () => view(job);
/** Test seams. */
function __setSpawn(fn) {
spawnImpl = fn || spawn;
}
function __setHydrate(fn) {
hydrate = fn || hydrateShellPath;
}
function __reset({ timeoutMs = 10 * 60 * 1000 } = {}) {
job = null;
starting = false;
jobTimeoutMs = timeoutMs;
}
module.exports = { start, startUpdateAll, current, stepsFor, __setSpawn, __setHydrate, __reset };
+121
View File
@@ -0,0 +1,121 @@
/**
* @file Which npm-distributed AI tools are npm-MANAGED (their binary resolves
* under `npm prefix -g`, so an npm update actually replaces what runs) and
* whether a newer version is published. A tool installed another way — Codex's
* standalone build, say — is never offered an npm update: it would install a
* second copy that the PATH order shadows. npm is the LOGIN SHELL's (its PATH
* first), not the pinned Node's beside the server. Cached for a day.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
const { execFile } = require("node:child_process");
const aiTools = require("./ai-tools");
const { shellPath } = require("./shell-path");
const { compareVersions } = require("./update-check");
const CACHE_MS = 24 * 60 * 60 * 1000;
const TIMEOUT_MS = 5000;
let cache = null; // { at, data }
const defaultExec = (file, args, opts) =>
new Promise((resolve, reject) =>
execFile(file, args, { ...opts, encoding: "utf8" }, (err, stdout) =>
err ? reject(err) : resolve({ stdout })
)
);
let exec = defaultExec;
/** The PATH a job runs with: the shell's own order first, then the server's. */
const jobPath = () => [shellPath(), process.env.PATH].filter(Boolean).join(path.delimiter);
/** The login shell's npm, else the server's; null when there is none. */
function npmBin() {
const sp = shellPath();
return (sp && aiTools.which("npm", sp)) || aiTools.which("npm");
}
const run = (file, args) =>
exec(file, args, { timeout: TIMEOUT_MS, env: { ...process.env, PATH: jobPath() } });
const parseVersion = (out) => (String(out || "").match(/\d+\.\d+\.\d+/) || [null])[0];
async function npmPrefix(npm) {
try {
return (await run(npm, ["prefix", "-g"])).stdout.trim() || null;
} catch {
return null;
}
}
function under(prefix, file) {
try {
const real = fs.realpathSync(file);
const base = fs.realpathSync(prefix);
return real.startsWith(base + path.sep);
} catch {
return false;
}
}
/** Whether tool `id` is installed AND lives under npm's global prefix. */
async function npmManaged(id, prefix) {
const d = aiTools.detect(id);
if (!d.installed) return false;
if (prefix === undefined) {
const npm = npmBin();
prefix = npm ? await npmPrefix(npm) : null;
}
return !!prefix && under(prefix, d.path);
}
async function compute() {
const npm = npmBin();
const prefix = npm ? await npmPrefix(npm) : null;
const tools = aiTools
.list()
.filter((t) => t.update?.kind === "npm" && aiTools.detect(t.id).installed);
const rows = await Promise.all(
tools.map(async (t) => {
const managed = await npmManaged(t.id, prefix);
if (!managed) {
return [t.id, { npmManaged: false, installed: null, latest: null, updateAvailable: false }];
}
const [inst, latest] = await Promise.all([
run(aiTools.detect(t.id).path, ["--version"]).then(
(r) => parseVersion(r.stdout),
() => null
),
run(npm, ["view", t.install.package, "version"]).then(
(r) => parseVersion(r.stdout),
() => null
),
]);
const updateAvailable = !!(inst && latest && compareVersions(latest, inst) > 0);
return [t.id, { npmManaged: true, installed: inst, latest, updateAvailable }];
})
);
return Object.fromEntries(rows);
}
/** `{[id]: {npmManaged, installed, latest, updateAvailable}}`, cached a day. */
async function versions({ fresh = false } = {}) {
if (!fresh && cache && Date.now() - cache.at < CACHE_MS) return cache.data;
const data = await compute();
cache = { at: Date.now(), data };
return data;
}
function invalidate() {
cache = null;
}
/** Test seam: `fn(file, args, opts) → Promise<{stdout}>`, or null for execFile. */
function __setExec(fn) {
exec = fn || defaultExec;
cache = null;
}
module.exports = { versions, invalidate, npmManaged, npmBin, jobPath, parseVersion, __setExec };
+158
View File
@@ -0,0 +1,158 @@
/**
* @file Daily copy of the AI Tools catalog from the newest AgentHub release,
* so a new tool or a fixed flag reaches a user without a plugin update. Only a
* release NEWER than the running build is copied (a dev checkout ahead of the
* newest release is never rolled back), each file is validated at release
* trust by ai-tools.js, and the set is replaced whole or not at all. Stored
* in `<data dir>/runtime/ai-tools-remote/`. Off with AGENTHUB_AI_TOOLS_SYNC=0.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
const { getDataDir } = require("./claude-home");
const {
compareVersions,
releaseApiUrl,
fetchReleaseFromUrl,
repoUrlFor,
DEFAULT_ROOT,
} = require("./update-check");
const DAY_MS = 24 * 60 * 60 * 1000;
const FIRST_MS = 60 * 1000;
const MAX_FILES = 200;
const MAX_BYTES = 64 * 1024;
const NAME_RE = /^[a-z0-9][a-z0-9-]{0,39}\.json$/;
let versionOverride = null;
const disabled = () => /^(0|false|off)$/i.test(process.env.AGENTHUB_AI_TOOLS_SYNC || "");
const remoteDir = () => path.join(getDataDir(), "runtime", "ai-tools-remote");
function runningVersion() {
if (versionOverride) return versionOverride;
try {
return JSON.parse(fs.readFileSync(path.join(DEFAULT_ROOT, "package.json"), "utf8")).version;
} catch {
return null;
}
}
function readMeta() {
try {
const m = JSON.parse(fs.readFileSync(path.join(remoteDir(), "meta.json"), "utf8"));
return typeof m.tag === "string" ? m : null;
} catch {
return null;
}
}
/** The synced dir when it holds a release newer than this build; else null. */
function activeRemoteDir() {
if (disabled()) return null;
const meta = readMeta();
const running = runningVersion();
if (!meta || !running || compareVersions(meta.tag, running) <= 0) return null;
return remoteDir();
}
function catalogMeta() {
if (!activeRemoteDir()) return { source: "builtin", tag: null, fetchedAt: null };
const { tag, fetchedAt } = readMeta();
return { source: "remote", tag, fetchedAt: fetchedAt || null };
}
async function getText(url) {
const res = await fetch(url, {
signal: AbortSignal.timeout(15_000),
headers: { accept: "application/json" },
redirect: "follow",
});
if (!res.ok) throw new Error(`http_${res.status}`);
// ponytail: reads the body before checking its size; stream with a byte cap
// if a forge ever answers with something large enough to matter.
return res.text();
}
/** One pass. Never throws; `{ok:false, reason}` on every miss. */
async function syncOnce({ repoUrl, root = DEFAULT_ROOT } = {}) {
if (disabled()) return { ok: false, reason: "disabled" };
const repo = repoUrl || (await repoUrlFor(root));
const api = repo && releaseApiUrl(repo);
if (!api) return { ok: false, reason: "no_remote" };
const rel = await fetchReleaseFromUrl(repo);
if (!rel.ok) return { ok: false, reason: rel.reason };
const running = runningVersion();
if (!running || compareVersions(rel.tag, running) <= 0) return { ok: false, reason: "not_newer" };
const { validate, refresh } = require("./ai-tools");
const origin = new URL(api).origin;
const tmp = `${remoteDir()}.tmp-${process.pid}`;
try {
const listUrl = api.replace(
/\/releases\/latest$/,
`/contents/server/data/ai-tools?ref=${encodeURIComponent(rel.tag)}`
);
const entries = JSON.parse(await getText(listUrl));
if (!Array.isArray(entries) || entries.length > MAX_FILES) {
return { ok: false, reason: "bad_listing" };
}
fs.rmSync(tmp, { recursive: true, force: true });
fs.mkdirSync(tmp, { recursive: true });
for (const e of entries) {
if (e?.type !== "file" || !NAME_RE.test(e.name || "")) continue;
let url;
try {
url = new URL(e.download_url);
} catch {
continue;
}
if (url.origin !== origin) continue; // only the forge the release came from
const text = await getText(url.href); // a failed download aborts the pass
if (Buffer.byteLength(text) > MAX_BYTES) continue;
let doc;
try {
doc = JSON.parse(text);
} catch {
continue;
}
if (validate(doc, "remote")) fs.writeFileSync(path.join(tmp, e.name), text);
}
fs.writeFileSync(
path.join(tmp, "meta.json"),
JSON.stringify({ tag: rel.tag, fetchedAt: new Date().toISOString() }) + "\n"
);
const old = `${remoteDir()}.old-${process.pid}`;
if (fs.existsSync(remoteDir())) fs.renameSync(remoteDir(), old);
fs.renameSync(tmp, remoteDir());
fs.rmSync(old, { recursive: true, force: true });
refresh();
return { ok: true, tag: rel.tag };
} catch (err) {
fs.rmSync(tmp, { recursive: true, force: true });
return { ok: false, reason: "unreachable", error: err.message };
}
}
function startAiToolsSync() {
if (disabled()) return;
setTimeout(() => {
void syncOnce();
setInterval(() => void syncOnce(), DAY_MS).unref();
}, FIRST_MS).unref();
}
/** Test seam: pin the running version, or null for package.json. */
function __setVersion(v) {
versionOverride = v;
}
module.exports = {
syncOnce,
startAiToolsSync,
activeRemoteDir,
catalogMeta,
remoteDir,
__setVersion,
};
+89 -11
View File
@@ -1,10 +1,13 @@
/**
* @file The AI Tools catalog: which AI coding CLIs AgentHub can run in a lane
* (`server/data/ai-tools/*.json` plus overrides in `DASHBOARD_AI_TOOLS_DIR`),
* (`server/data/ai-tools/*.json`, the newer release's copy synced by
* ai-tools-sync.js, and overrides in `DASHBOARD_AI_TOOLS_DIR`, in rising
* precedence), how each is installed/updated (built-in and release files only),
* which of them are installed (a walk of the PATH directories — no process per
* tool), and the user's enable/default choice (`<data dir>/ai-tools.json`).
* A catalog file is data only: its args and env reach an argv in pty-run.js,
* never a shell, and a custom file may not claim a hand-written `launcher`.
* never a shell, and a custom file may not claim a hand-written `launcher` nor
* an `install`/`update` — an Install click would run whatever it named.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -19,6 +22,10 @@ const ENV_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
const LAUNCHERS = new Set(["claude", "codex"]);
const PROMPT_MODES = new Set(["argv", "flag", "after-start"]);
const ROUTER_CAPABLE = new Set(["claude", "codex"]);
const PKG_RE = /^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
// Files shipped in a release (built-in, or synced from the newest release) are
// trusted to name a launcher and an install/update; a custom file is not.
const TRUSTED = new Set(["builtin", "remote"]);
let dirsOverride = null;
let catalog = null; // Map id -> tool, in catalog order
@@ -33,6 +40,47 @@ function makeErr(code, message) {
const isStrArray = (v, re) =>
Array.isArray(v) && v.every((s) => typeof s === "string" && s && (!re || re.test(s)));
// ponytail: compares the last two host labels, not a public suffix list —
// wrong for co.uk-style hosts (none in the catalog); swap in a PSL if one comes.
const siteOf = (host) => host.toLowerCase().split(".").slice(-2).join(".");
/** The script URL, when it is https on the homepage's site; else null. */
function scriptUrl(url, homepageUrl) {
try {
const u = new URL(url);
const home = new URL(homepageUrl);
if (u.protocol !== "https:" || u.username || u.password || u.port) return null;
return siteOf(u.hostname) === siteOf(home.hostname) ? u.href : null;
} catch {
return null;
}
}
/** `{install, update}` normalised, or null when either is malformed. */
function installFields(doc) {
let install = null;
if (doc.install !== undefined) {
const i = doc.install;
if (i?.kind === "npm" && typeof i.package === "string" && PKG_RE.test(i.package)) {
install = { kind: "npm", package: i.package };
} else if (i?.kind === "script" && typeof i.url === "string") {
const url = scriptUrl(i.url, doc.homepageUrl);
if (!url) return null;
install = { kind: "script", url };
} else return null;
}
let update = null;
if (doc.update !== undefined) {
const u = doc.update;
if ((u?.kind === "npm" || u?.kind === "script") && install?.kind === u.kind) {
update = { kind: u.kind };
} else if (u?.kind === "self" && isStrArray(u.args) && u.args.length) {
update = { kind: "self", args: u.args };
} else return null;
}
return { install, update };
}
/** A normalised tool, or null when the file breaks any rule. */
function validate(doc, source) {
if (!doc || typeof doc !== "object") return null;
@@ -50,10 +98,13 @@ function validate(doc, source) {
}
let launcher = null;
if (doc.launcher !== undefined) {
if (source !== "builtin" || !LAUNCHERS.has(doc.launcher) || doc.launcher !== doc.id)
if (!TRUSTED.has(source) || !LAUNCHERS.has(doc.launcher) || doc.launcher !== doc.id)
return null;
launcher = doc.launcher;
}
if ((doc.install !== undefined || doc.update !== undefined) && !TRUSTED.has(source)) return null;
const fields = installFields(doc);
if (!fields) return null;
let prompt = null;
if (!launcher) {
const p = doc.prompt;
@@ -84,15 +135,33 @@ function validate(doc, source) {
bypass: doc.bypass || null,
bypassEnv: doc.bypassEnv || null,
launcher,
install: fields.install,
update: fields.update,
source,
};
}
const RANK = { builtin: 0, remote: 1, custom: 2 };
/** `[dir, source]` pairs, lowest precedence first. */
function catalogDirs() {
if (dirsOverride) {
const names =
dirsOverride.length === 2 ? ["builtin", "custom"] : ["builtin", "remote", "custom"];
return dirsOverride.map((d, i) => [d, names[i]]);
}
// Lazy: ai-tools-sync.js requires this module for validate().
const remote = require("./ai-tools-sync").activeRemoteDir();
return [
[BUILTIN_DIR, "builtin"],
[remote, "remote"],
[process.env.DASHBOARD_AI_TOOLS_DIR, "custom"],
].filter(([d]) => d);
}
function load() {
const dirs = dirsOverride || [BUILTIN_DIR, process.env.DASHBOARD_AI_TOOLS_DIR].filter(Boolean);
const byId = new Map();
dirs.forEach((dir, i) => {
const source = i === 0 ? "builtin" : "custom";
catalogDirs().forEach(([dir, source]) => {
let files = [];
try {
files = fs
@@ -113,8 +182,8 @@ function load() {
console.warn(`ai-tools: skipping invalid catalog file ${path.join(dir, f)}`);
continue;
}
// Custom beats built-in; within one dir the first file wins.
if (!byId.has(tool.id) || (source === "custom" && byId.get(tool.id).source === "builtin")) {
// A later tier beats an earlier one; within one dir the first file wins.
if (!byId.has(tool.id) || RANK[source] > RANK[byId.get(tool.id).source]) {
byId.set(tool.id, tool);
}
}
@@ -135,8 +204,9 @@ const list = () => [...cat().values()];
const get = (id) => cat().get(id) || null;
const has = (id) => typeof id === "string" && cat().has(id);
function which(name) {
for (const dir of (process.env.PATH || "").split(path.delimiter)) {
/** First executable `name` on `pathStr` (default: the server's PATH). */
function which(name, pathStr = process.env.PATH || "") {
for (const dir of pathStr.split(path.delimiter)) {
if (!dir) continue;
const p = path.join(dir, name);
try {
@@ -248,13 +318,18 @@ function view() {
resume: !!t.resume,
bypass: !!(t.bypass || t.bypassEnv),
routerCapable: ROUTER_CAPABLE.has(t.id),
install: t.install,
update: t.update,
};
}),
default: effectiveDefault(),
catalog: dirsOverride
? { source: "builtin", tag: null, fetchedAt: null }
: require("./ai-tools-sync").catalogMeta(),
};
}
/** Test seam: `[builtinDir, customDir]`, or null for the real ones. */
/** Test seam: `[builtin, custom]` or `[builtin, remote, custom]`, or null for the real ones. */
function __setCatalogDirs(dirs) {
dirsOverride = dirs;
refresh();
@@ -272,6 +347,9 @@ module.exports = {
setDefault,
effectiveDefault,
view,
validate,
which,
siteOf,
ROUTER_CAPABLE,
__setCatalogDirs,
};
+10 -2
View File
@@ -5,7 +5,9 @@
* invisible to AI Tools detection and to every lane pane. `$SHELL -ilc` runs
* the rc files where those installers add to PATH; delimiters discard
* whatever the rc files print, stdin is closed so a prompt gets EOF, and a
* timeout bounds a hang. Off with `AGENTHUB_SHELL_PATH=0`. Adapted from Orca's
* timeout bounds a hang. Off with `AGENTHUB_SHELL_PATH=0`. The shell's own
* PATH is also kept in ITS order (`shellPath()`): the merge appends, so an
* install job that must use the shell's npm puts this first. Adapted from Orca's
* `hydrate-shell-path.ts` (MIT).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -15,6 +17,11 @@ const path = require("node:path");
const MARK = "__AGENTHUB_PATH__";
let lastShellPath = null;
/** The PATH the login shell last printed, in its order; null before/without one. */
const shellPath = () => lastShellPath;
/**
* @returns {Promise<boolean>} whether a shell PATH was read and merged.
*/
@@ -34,6 +41,7 @@ function hydrateShellPath({
(err, stdout) => {
const m = String(stdout || "").match(new RegExp(`${MARK}(.*?)${MARK}`, "s"));
if (!m) return resolve(false); // timeout, spawn error, no output
lastShellPath = m[1];
const have = (process.env.PATH || "").split(path.delimiter).filter(Boolean);
const add = m[1].split(path.delimiter).filter((d) => d && !have.includes(d));
process.env.PATH = [...have, ...new Set(add)].join(path.delimiter);
@@ -48,4 +56,4 @@ function hydrateShellPath({
});
}
module.exports = { hydrateShellPath };
module.exports = { hydrateShellPath, shellPath };
+19
View File
@@ -256,6 +256,24 @@ async function fetchLatestRelease(root, timeoutMs = 10_000) {
return fetchReleaseFromUrl(remoteUrl, timeoutMs);
}
/**
* The repository URL releases are read from: the marketplace manifest for a
* plugin-cache install (no git there), else the canonical git remote. Null
* when neither answers. Never throws. Used by ai-tools-sync.js, which needs
* the repo itself (not just the newest release) to read files at a tag.
*/
async function repoUrlFor(root) {
const plugin = detectPluginInstall(root);
if (plugin) return pluginMarketplaceUrl(plugin.marketplace, plugin.plugin);
try {
const remote = await pickCanonicalRemote(root);
if (!remote) return null;
return (await execGit(root, ["remote", "get-url", remote], { timeout: 10_000 })) || null;
} catch {
return null;
}
}
/**
* The HTTP half of the release lookup, split out because a **plugin-cache
* install has no git remote to derive the URL from** — it is unpacked from an
@@ -821,5 +839,6 @@ module.exports = {
pluginMarketplaceUrl,
fetchReleaseFromUrl,
fetchLatestRelease,
repoUrlFor,
isShallowClone,
};
+167 -3
View File
@@ -1,8 +1,9 @@
/**
* @file Supplementary OpenAPI 3.0 fragments for the AI Tools routes mounted at
* `/api/ai-tools` (see server/routes/ai-tools.js + server/lib/ai-tools.js):
* list the catalog with detection, re-detect after hydrating PATH, and
* enable/disable a tool or make it the default. Exports `{ tags, schemas,
* list the catalog with detection, re-detect after hydrating PATH,
* enable/disable a tool or make it the default, run an install/update job,
* read npm version badges, and fetch a tool's server-cached icon. Exports `{ tags, schemas,
* paths }` for server/openapi-extra.js; schemas are prefixed `AiTools`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -29,7 +30,7 @@ const schemas = {
installed: { type: "boolean" },
path: { type: "string", nullable: true, example: "/home/u/.local/bin/agy" },
enabled: { type: "boolean" },
source: { type: "string", enum: ["builtin", "custom"] },
source: { type: "string", enum: ["builtin", "remote", "custom"] },
launcher: { type: "string", nullable: true, enum: ["claude", "codex", null] },
prompt: {
type: "object",
@@ -39,6 +40,37 @@ const schemas = {
resume: { type: "boolean", description: "Offers Resume last session" },
bypass: { type: "boolean", description: "Offers the per-run Bypass permissions switch" },
routerCapable: { type: "boolean", description: "Can use an AI Router profile" },
install: {
type: "object",
nullable: true,
description: "How Install runs: npm install -g <package>@latest, or a vendor script",
properties: {
kind: { type: "string", enum: ["npm", "script"] },
package: { type: "string", example: "@openai/codex" },
url: { type: "string", example: "https://cursor.com/install" },
},
},
update: {
type: "object",
nullable: true,
properties: {
kind: { type: "string", enum: ["npm", "script", "self"] },
args: { type: "array", items: { type: "string" }, description: "self only" },
},
},
},
},
AiToolsJob: {
type: "object",
properties: {
id: { type: "integer" },
tool: { type: "string", nullable: true, description: "null for update-all" },
action: { type: "string", enum: ["install", "update", "update-all"] },
status: { type: "string", enum: ["running", "ok", "failed"] },
startedAt: { type: "string" },
endedAt: { type: "string", nullable: true },
exitCode: { type: "integer", nullable: true },
log: { type: "string", description: "stdout+stderr, last 256 KB" },
},
},
AiToolsView: {
@@ -47,6 +79,14 @@ const schemas = {
properties: {
tools: { type: "array", items: { $ref: "#/components/schemas/AiToolsTool" } },
default: { type: "string", nullable: true, description: "Effective default tool id" },
catalog: {
type: "object",
properties: {
source: { type: "string", enum: ["builtin", "remote"] },
tag: { type: "string", nullable: true },
fetchedAt: { type: "string", nullable: true },
},
},
},
},
AiToolsError: {
@@ -125,4 +165,128 @@ const paths = {
},
};
const idParam = [{ name: "id", in: "path", required: true, schema: { type: "string" } }];
const jobStarted = {
202: {
description: "The job started; poll GET /api/ai-tools/job",
content: {
"application/json": {
schema: {
type: "object",
properties: { job: { $ref: "#/components/schemas/AiToolsJob" } },
},
},
},
},
};
const jobErr = (description) => ({
description,
content: { "application/json": { schema: errorRef } },
});
Object.assign(paths, {
"/api/ai-tools/{id}/install": {
post: {
tags: ["AI Tools"],
summary: "Install a tool",
description:
"Runs the catalog's install (npm install -g <package>@latest with the login shell's npm, or the vendor script downloaded by the server and run with bash). One job at a time. Same-origin only.",
operationId: "aiToolsInstall",
parameters: idParam,
responses: {
...jobStarted,
400: jobErr("ENOINSTALL"),
404: jobErr("ENOTOOL"),
409: jobErr("EINSTALLED, ENONPM or EBUSY"),
},
},
},
"/api/ai-tools/{id}/update": {
post: {
tags: ["AI Tools"],
summary: "Update a tool",
description:
"npm update only for a tool whose binary lives under npm's global prefix (else ENOTNPM); a self updater runs the tool's own command. Same-origin only.",
operationId: "aiToolsUpdateTool",
parameters: idParam,
responses: {
...jobStarted,
400: jobErr("ENOUPDATE"),
404: jobErr("ENOTOOL"),
409: jobErr("ENOTINSTALLED, ENOTNPM, ENONPM or EBUSY"),
},
},
},
"/api/ai-tools/update-all": {
post: {
tags: ["AI Tools"],
summary: "Update every tool with an update",
description:
"One job: every npm-managed tool with a newer version, then every installed self updater. Same-origin only.",
operationId: "aiToolsUpdateAll",
responses: { ...jobStarted, 400: jobErr("ENOTHING"), 409: jobErr("EBUSY") },
},
},
"/api/ai-tools/job": {
get: {
tags: ["AI Tools"],
summary: "The current or last install/update job",
operationId: "aiToolsJob",
responses: {
200: {
description: "The job, or null",
content: {
"application/json": { schema: { $ref: "#/components/schemas/AiToolsJob" } },
},
},
},
},
},
"/api/ai-tools/versions": {
get: {
tags: ["AI Tools"],
summary: "npm version badges",
description:
"For installed tools with an npm update: whether npm manages them, the installed and latest version, and updateAvailable. Cached 24 h.",
operationId: "aiToolsVersions",
responses: {
200: {
description: "Map of tool id to its versions",
content: {
"application/json": {
schema: {
type: "object",
additionalProperties: {
type: "object",
properties: {
npmManaged: { type: "boolean" },
installed: { type: "string", nullable: true },
latest: { type: "string", nullable: true },
updateAvailable: { type: "boolean" },
},
},
},
},
},
},
},
},
},
"/api/ai-tools/{id}/icon": {
get: {
tags: ["AI Tools"],
summary: "A tool's icon",
description:
"The favicon of the tool's faviconDomain (or homepage host), fetched once by the server and cached. Raster only, never SVG; served with nosniff and CSP sandbox.",
operationId: "aiToolsIcon",
parameters: idParam,
responses: {
200: { description: "The image", content: { "image/*": {} } },
204: { description: "No icon (the client shows a letter avatar)" },
404: { description: "Unknown tool id" },
},
},
},
});
module.exports = { tags, schemas, paths };
+62 -1
View File
@@ -3,13 +3,18 @@
* what is known and installed, re-detect after hydrating PATH from the login
* shell, and enable/disable a tool or make it the default. Nothing here takes
* a command or an argument from the request — the catalog files are the only
* source of what a lane may run. Writes sit behind the same-origin guard.
* source of what a lane may run, or an install/update may execute. Also: start
* an install/update job and read it back, npm version badges, and the
* server-cached icons. Writes sit behind the same-origin guard.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const express = require("express");
const aiTools = require("../lib/ai-tools");
const jobs = require("../lib/ai-tool-jobs");
const versions = require("../lib/ai-tool-versions");
const { iconFor } = require("../lib/ai-tool-icons");
const { hydrateShellPath } = require("../lib/shell-path");
const { sameOriginGuard } = require("../lib/same-origin");
@@ -22,6 +27,7 @@ router.get("/", (_req, res) => {
router.post("/refresh", sameOriginGuard, async (_req, res) => {
await hydrateShellPath();
aiTools.refresh();
versions.invalidate(); // a CLI updated by hand shows its new version after Refresh
res.json(aiTools.view());
});
@@ -47,4 +53,59 @@ router.put("/:id", sameOriginGuard, (req, res) => {
}
});
const JOB_STATUS = {
ENOTOOL: 404,
ENOINSTALL: 400,
ENOUPDATE: 400,
ENOTHING: 400,
ENONPM: 409,
EINSTALLED: 409,
ENOTINSTALLED: 409,
ENOTNPM: 409,
EBUSY: 409,
};
async function startJob(res, fn) {
try {
res.status(202).json({ job: await fn() });
} catch (e) {
res
.status(JOB_STATUS[e.code] || 500)
.json({ error: { code: e.code || "EINTERNAL", message: e.message } });
}
}
router.post("/update-all", sameOriginGuard, (_req, res) => startJob(res, jobs.startUpdateAll));
router.post("/:id/install", sameOriginGuard, (req, res) =>
startJob(res, () => jobs.start(req.params.id, "install"))
);
router.post("/:id/update", sameOriginGuard, (req, res) =>
startJob(res, () => jobs.start(req.params.id, "update"))
);
router.get("/job", (_req, res) => {
res.json(jobs.current());
});
router.get("/versions", async (_req, res) => {
res.json(await versions.versions());
});
router.get("/:id/icon", async (req, res) => {
if (!aiTools.has(req.params.id)) return res.status(404).end();
const icon = await iconFor(req.params.id);
// 204, not 404: a miss is a normal state, and a 404 per letter-avatar would
// log a console error for every such tool on every visit to Settings.
if (!icon) return res.status(204).end();
res.set({
"Content-Type": icon.type,
"X-Content-Type-Options": "nosniff",
"Content-Security-Policy": "sandbox",
"Cache-Control": "max-age=86400",
});
res.end(icon.body);
});
module.exports = router;