feat: model lists and prices that follow each AI tool, a Settings tab per tool, live lane status (#40)

Pricing: the LiteLLM sync now adds every current Claude / GPT (>= 5) /
Gemini (>= 3) chat model before it is used and keeps its own rows
(model_pricing.source = 'sync', the Auto badge) at the published rate. A
user's row (any PUT) and a deleted pattern (model_pricing_dismissed) are
never touched; a deleted built-in row is not re-seeded at the next start; a
reset re-applies the list at once; retired Claude models are priced only
once used.

Models: GET /api/ai-tools/:id/models reads each tool's own list (Codex's
models_cache.json, Claude's aliases named after the newest version plus
priced versions, a catalog tool's vendor rows plus the models its sessions
used). A catalog `model: {flag, vendor?}` field passes `<flag> <id>`
(validated: never a flag or shell text); a per-tool default model starts
each run, and a resume keeps the session's own model.

Settings: every installed, enabled catalog tool without its own tab gets
one (AiToolConfig.tsx): path, homepage, Update, sessions/cost/lanes,
default model, vendor pricing, and the config files its release catalog
lists, edited over the opened sha, syntax-checked, backed up, written
atomically; credential-looking names refused. Antigravity keeps its hooks
card there.

Lanes: a just-started run no longer reads "dead" (age counts the run's
start while the old session is still bound); a second dashboard on the
same database re-broadcasts the lanes the other changed (PRAGMA
data_version, every 2 s); a lane start with a bad model answers 400.

DB: every migration runs under a cross-process lock (<db>-migrate.lock), so
two dashboards started at once on a fresh database both come up.
This commit is contained in:
2026-10-01 13:39:17 +07:00
parent c5aba094bd
commit f887c16309
51 changed files with 2343 additions and 203 deletions
+201
View File
@@ -0,0 +1,201 @@
/**
* @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ĩ <vinnt@smartgift.vn>
*/
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");
});
});