/** * @file Route tests for /api/ai-tools: list shape, enable/disable, default * 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ĩ */ const { describe, it, before, 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 http = require("node:http"); const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ai-tools-route-")); process.env.DASHBOARD_DB_PATH = path.join(ROOT, "t.db"); process.env.DASHBOARD_DATA_DIR = path.join(ROOT, "data"); 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", "npm"]) { fs.writeFileSync(path.join(BIN, name), "#!/bin/sh\n"); fs.chmodSync(path.join(BIN, name), 0o755); } const origPath = process.env.PATH; process.env.PATH = BIN; const { createApp } = require("../index"); require("../lib/ai-tools").refresh(); let app; let base; const request = (method, p, body, headers = {}) => fetch(`${base}${p}`, { method, headers: { "Content-Type": "application/json", ...headers }, body: body === undefined ? undefined : JSON.stringify(body), }).then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) })); before(async () => { app = http.createServer(createApp()); await new Promise((r) => app.listen(0, "127.0.0.1", r)); base = `http://127.0.0.1:${app.address().port}`; }); after(async () => { process.env.PATH = origPath; await new Promise((r) => app.close(r)); fs.rmSync(ROOT, { recursive: true, force: true }); }); describe("/api/ai-tools", () => { it("lists the catalog with detection and the default", async () => { const { status, body } = await request("GET", "/api/ai-tools"); assert.equal(status, 200); assert.equal(body.default, "claude"); const agy = body.tools.find((t) => t.id === "antigravity"); assert.equal(agy.installed, true); assert.equal(agy.path, path.join(BIN, "agy")); assert.equal(agy.resume, true); assert.equal(agy.bypass, true); assert.equal(body.tools.find((t) => t.id === "opencode").installed, false); assert.equal("args" in agy, false); }); it("disables and re-enables a tool, and sets the default", async () => { let r = await request("PUT", "/api/ai-tools/claude", { enabled: false }); assert.equal(r.status, 200); assert.equal(r.body.tools.find((t) => t.id === "claude").enabled, false); assert.equal(r.body.default, "antigravity"); r = await request("PUT", "/api/ai-tools/claude", { enabled: true }); assert.equal(r.body.default, "claude"); r = await request("PUT", "/api/ai-tools/antigravity", { default: true }); assert.equal(r.body.default, "antigravity"); }); it("answers ENOTOOL / EBADDEFAULT / EBADFIELD", async () => { assert.equal((await request("PUT", "/api/ai-tools/nope", { enabled: false })).status, 404); const bad = await request("PUT", "/api/ai-tools/opencode", { default: true }); assert.equal(bad.status, 400); assert.equal(bad.body.error.code, "EBADDEFAULT"); assert.equal((await request("PUT", "/api/ai-tools/claude", { enabled: "no" })).status, 400); }); it("answers a tool's model choices and keeps its default model", async () => { let r = await request("GET", "/api/ai-tools/antigravity/models"); assert.equal(r.status, 200); assert.equal(r.body.flag, true); assert.equal(r.body.defaultModel, null); r = await request("PUT", "/api/ai-tools/antigravity", { defaultModel: "gemini-3.1-pro" }); assert.equal(r.status, 200); assert.equal(r.body.tools.find((t) => t.id === "antigravity").defaultModel, "gemini-3.1-pro"); r = await request("GET", "/api/ai-tools/antigravity/models"); assert.equal(r.body.defaultModel, "gemini-3.1-pro"); assert.equal( (await request("PUT", "/api/ai-tools/antigravity", { defaultModel: "-x" })).status, 400 ); r = await request("PUT", "/api/ai-tools/antigravity", { defaultModel: "" }); assert.equal(r.body.tools.find((t) => t.id === "antigravity").defaultModel, null); assert.equal((await request("GET", "/api/ai-tools/nope/models")).status, 404); }); it("refuses a cross-origin write", async () => { const r = await request( "PUT", "/api/ai-tools/claude", { enabled: false }, { Origin: "https://evil.test" } ); assert.equal(r.status, 403); }); it("refresh re-detects", async () => { fs.writeFileSync(path.join(BIN, "opencode"), "#!/bin/sh\n"); fs.chmodSync(path.join(BIN, "opencode"), 0o755); const r = await request("POST", "/api/ai-tools/refresh"); assert.equal(r.body.tools.find((t) => t.id === "opencode").installed, true); }); it("/api/run/binary resolves through the catalog", async () => { const r = await request("GET", "/api/run/binary?tool=antigravity"); assert.deepEqual(r.body, { found: true, path: path.join(BIN, "agy") }); assert.equal((await request("GET", "/api/run/binary?tool=rm")).status, 400); }); }); describe("lanes on catalog tools", () => { it("creates a lane for an installed or not-installed tool, refuses unknown/disabled", async () => { const dir = (n) => { const d = path.join(ROOT, n); fs.mkdirSync(d, { recursive: true }); return d; }; let r = await request("POST", "/api/lanes", { cwd: dir("a"), title: "a", tool: "opencode" }); assert.equal(r.status, 201); assert.equal(r.body.lane.tool, "opencode"); r = await request("POST", "/api/lanes", { cwd: dir("b"), title: "b", tool: "omp" }); assert.equal(r.status, 201, "a tool that is not installed may still get a lane"); r = await request("POST", "/api/lanes", { cwd: dir("c"), title: "c", tool: "nope" }); assert.equal(r.body.error.code, "EBADTOOL"); await request("PUT", "/api/ai-tools/omp", { enabled: false }); r = await request("POST", "/api/lanes", { cwd: dir("d"), title: "d", tool: "omp" }); assert.equal(r.status, 400); assert.equal(r.body.error.code, "EDISABLED"); r = await request("POST", "/api/lanes/ensure", { cwd: dir("e"), tool: "omp" }); assert.equal(r.body.error.code, "EDISABLED"); r = await request("POST", "/api/lanes/ensure", { cwd: dir("b"), tool: "omp" }); assert.equal(r.status, 200, "an existing lane on a disabled tool is still found"); r = await request("POST", "/api/lanes/worktree", { sourceRepo: dir("f"), tool: "omp" }); assert.equal(r.body.error.code, "EDISABLED"); await request("PUT", "/api/ai-tools/omp", { 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: "self", args: ["update"] }); // standalone or 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/copilot/install"); assert.equal(r.status, 202); assert.equal(r.body.job.tool, "copilot"); const done = await settle(); assert.equal(done.status, "ok"); assert.deepEqual(spawned.at(-1), [ path.join(BIN, "npm"), "install", "-g", "@github/copilot@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/antigravity/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"); // opencode was put on PATH by the refresh test, outside any npm prefix. const notNpm = await request("POST", "/api/ai-tools/opencode/update"); assert.equal(notNpm.status, 409); assert.equal(notNpm.body.error.code, "ENOTNPM"); const cross = await request("POST", "/api/ai-tools/copilot/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.opencode.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; } }); });