/** * @file Tests for server/lib/ai-tool-config.js and its routes: a tool's * config files come only from a TRUSTED catalog file's `config` list, only an * existing regular text file is read or written, a credential-looking name is * refused, JSON/JSONC is validated before saving, every save is backed up in * AgentHub's data dir, and a stale `baseSha` is refused. * @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-tool-config-")); const builtin = path.join(root, "builtin"); const custom = path.join(root, "custom"); const home = path.join(root, "home"); const data = path.join(root, "data"); for (const d of [builtin, custom, home, data]) fs.mkdirSync(d, { recursive: true }); const saved = { home: process.env.HOME, data: process.env.DASHBOARD_DATA_DIR }; process.env.HOME = home; process.env.DASHBOARD_DATA_DIR = data; process.env.DASHBOARD_DB_PATH = path.join(root, "test.db"); const aiTools = require("../lib/ai-tools"); const cfg = require("../lib/ai-tool-config"); const write = (dir, doc) => fs.writeFileSync(path.join(dir, `${doc.id}.json`), JSON.stringify(doc)); const tool = (id, config) => ({ id, label: id, detect: [id], prompt: { mode: "argv" }, config, }); let server; let base; before(async () => { write( builtin, tool("gem", [ { path: "~/.gem/settings.json" }, { path: "~/.gem/opencode.jsonc" }, { path: "~/.gem/GEMINI.md" }, { path: "~/.gem/missing.json" }, ]) ); write(builtin, tool("leaky", [{ path: "~/.leaky/oauth_creds.json" }])); write(builtin, tool("escape", [{ path: "~/../etc/passwd" }])); write(custom, tool("untrusted", [{ path: "~/.u/settings.json" }])); aiTools.__setCatalogDirs([builtin, custom]); fs.mkdirSync(path.join(home, ".gem")); fs.writeFileSync(path.join(home, ".gem/settings.json"), '{"theme":"dark"}\n', { mode: 0o600 }); fs.writeFileSync(path.join(home, ".gem/opencode.jsonc"), '{\n // c\n "a": 1,\n}\n'); fs.writeFileSync(path.join(home, ".gem/GEMINI.md"), "# rules\n"); const express = require("express"); const app = express(); app.use(express.json({ limit: "2mb" })); app.use("/api/ai-tools", require("../routes/ai-tools")); server = http.createServer(app); await new Promise((r) => server.listen(0, "127.0.0.1", r)); base = `http://127.0.0.1:${server.address().port}/api/ai-tools`; }); after(() => { server?.close(); aiTools.__setCatalogDirs(null); if (saved.home === undefined) delete process.env.HOME; else process.env.HOME = saved.home; if (saved.data === undefined) delete process.env.DASHBOARD_DATA_DIR; else process.env.DASHBOARD_DATA_DIR = saved.data; fs.rmSync(root, { recursive: true, force: true }); }); describe("catalog `config` field", () => { it("is honoured only in a trusted file, and never for a credential or an escaping path", () => { assert.equal(aiTools.has("gem"), true); assert.equal(aiTools.has("untrusted"), false); assert.equal(aiTools.has("leaky"), false); assert.equal(aiTools.has("escape"), false); }); }); describe("listConfig / readConfig / writeConfig", () => { it("lists the declared files with their format and whether they exist", () => { const files = cfg.listConfig("gem"); assert.deepEqual( files.map((f) => [f.index, f.format, f.exists]), [ [0, "json", true], [1, "jsonc", true], [2, "text", true], [3, "json", false], ] ); assert.equal(files[0].path, path.join(home, ".gem/settings.json")); }); it("reads an existing file with its sha, and refuses a missing one", () => { const r = cfg.readConfig("gem", 0); assert.equal(r.text, '{"theme":"dark"}\n'); assert.match(r.sha, /^[0-9a-f]{40}$/); assert.throws(() => cfg.readConfig("gem", 3), { code: "ENOENT" }); assert.throws(() => cfg.readConfig("gem", 9), { code: "ENOFILE" }); }); it("refuses invalid JSON and JSONC, accepts JSONC comments and trailing commas", () => { const { sha } = cfg.readConfig("gem", 0); assert.throws(() => cfg.writeConfig("gem", 0, "{nope", sha), { code: "EBADSYNTAX" }); const c = cfg.readConfig("gem", 1); cfg.writeConfig("gem", 1, '{\n // keep\n "a": 2, /* x */\n}\n', c.sha); assert.throws(() => cfg.writeConfig("gem", 1, '{"a": }', cfg.readConfig("gem", 1).sha), { code: "EBADSYNTAX", }); }); it("saves over the sha it was opened at, backs up the old text and keeps the mode", () => { const { sha } = cfg.readConfig("gem", 0); const out = cfg.writeConfig("gem", 0, '{"theme":"light"}\n', sha); const file = path.join(home, ".gem/settings.json"); assert.equal(fs.readFileSync(file, "utf8"), '{"theme":"light"}\n'); assert.equal(fs.statSync(file).mode & 0o777, 0o600); assert.equal(fs.readFileSync(out.backup, "utf8"), '{"theme":"dark"}\n'); assert.ok(out.backup.startsWith(data)); assert.throws(() => cfg.writeConfig("gem", 0, "{}", sha), { code: "ESTALE" }); }); it("refuses a file that is a symlink to a credential, and a binary file", () => { const md = path.join(home, ".gem/GEMINI.md"); const secret = path.join(home, ".gem/oauth_creds.json"); fs.writeFileSync(secret, '{"t":1}'); fs.rmSync(md); fs.symlinkSync(secret, md); assert.throws(() => cfg.readConfig("gem", 2), { code: "ESECRET" }); fs.rmSync(md); fs.writeFileSync(md, Buffer.from([0x23, 0x00, 0x01])); assert.throws(() => cfg.readConfig("gem", 2), { code: "EBINARY" }); fs.writeFileSync(md, "# rules\n"); }); }); describe("routes", () => { it("lists, reads and saves through /:id/config, answering structured errors", async () => { const list = await (await fetch(`${base}/gem/config`)).json(); assert.equal(list.files.length, 4); const r = await (await fetch(`${base}/gem/config/2`)).json(); assert.equal(r.text, "# rules\n"); const put = await fetch(`${base}/gem/config/2`, { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ text: "# new\n", baseSha: r.sha }), }); assert.equal(put.status, 200); const stale = await fetch(`${base}/gem/config/2`, { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ text: "# x\n", baseSha: r.sha }), }); assert.equal(stale.status, 409); assert.equal((await fetch(`${base}/nope/config`)).status, 404); assert.equal((await fetch(`${base}/gem/config/3`)).status, 404); }); it("answers a failed write as a JSON 500 and leaves no temp file", async () => { const dir = path.join(home, ".gem"); const r = await (await fetch(`${base}/gem/config/0`)).json(); fs.chmodSync(dir, 0o500); try { const put = await fetch(`${base}/gem/config/0`, { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ text: "{}", baseSha: r.sha }), }); assert.equal(put.status, 500); assert.equal((await put.json()).error.code, "EACCES"); } finally { fs.chmodSync(dir, 0o700); } assert.deepEqual( fs.readdirSync(dir).filter((n) => n.includes(".agenthub-")), [] ); }); it("reports a tool's session count and cost", async () => { const { db } = require("../db"); db.prepare( "INSERT INTO sessions (id, status, started_at, tool) VALUES ('s1', 'completed', ?, 'gem')" ).run(new Date().toISOString()); const u = await (await fetch(`${base}/gem/usage`)).json(); assert.equal(u.sessions, 1); assert.equal(typeof u.cost, "number"); }); });