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:
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user