Files
AgentHub/server/lib/ai-tool-config.js
T
nntrivi2001 f887c16309 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.
2026-10-01 13:39:17 +07:00

152 lines
5.4 KiB
JavaScript

/**
* @file A catalog tool's own config files, viewed and edited from its
* Settings tab. The files are ONLY those a trusted catalog file lists in
* `config` (built-in or the synced release copy — never a request, never
* `DASHBOARD_AI_TOOLS_DIR`), under the user's home. Only an existing regular
* text file ≤ 1 MB is read or written, and never one whose name (or whose
* symlink target's name) looks like a credential. A save must match the sha
* the editor opened (`ESTALE`), is syntax-checked for JSON/JSONC, backs the
* old text up under `<data dir>/ai-tool-config-backups/<id>/` (newest 10
* kept), and replaces the file atomically keeping its mode.
* @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 aiTools = require("./ai-tools");
const { getDataDir } = require("./claude-home");
const MAX_BYTES = 1024 * 1024;
const KEEP_BACKUPS = 10;
const SECRET_RE = aiTools.CONFIG_SECRET_RE;
function fail(code, message) {
return Object.assign(new Error(message), { code });
}
const sha = (buf) => crypto.createHash("sha1").update(buf).digest("hex");
const expand = (p) => path.join(os.homedir(), p.slice(2));
/** Declared files with where they live now and whether they exist. */
function listConfig(id) {
const t = aiTools.get(id);
if (!t) throw fail("ENOTOOL", "unknown AI tool");
return t.config.map((c, index) => {
const full = expand(c.path);
let st = null;
try {
st = fs.statSync(full);
} catch {
/* missing */
}
return {
index,
path: full,
format: c.format,
exists: Boolean(st?.isFile()),
size: st?.isFile() ? st.size : null,
};
});
}
/** The declared file at `index`, resolved through any symlink and checked. */
function target(id, index) {
const files = listConfig(id);
const f = Number.isInteger(index) ? files[index] : undefined;
if (!f) throw fail("ENOFILE", "no such config file for this tool");
let real;
try {
real = fs.realpathSync(f.path);
} catch {
throw fail("ENOENT", "the file does not exist");
}
if (SECRET_RE.test(path.basename(real))) throw fail("ESECRET", "refusing a credential file");
const st = fs.statSync(real);
if (!st.isFile()) throw fail("ENOENT", "not a regular file");
if (st.size > MAX_BYTES) throw fail("ETOOBIG", "the file is larger than 1 MB");
return { ...f, real, mode: st.mode & 0o777 };
}
function readConfig(id, index) {
const f = target(id, index);
const buf = fs.readFileSync(f.real);
if (buf.includes(0)) throw fail("EBINARY", "not a text file");
return { path: f.path, format: f.format, text: buf.toString("utf8"), sha: sha(buf) };
}
/** JSONC → JSON: drop comments and trailing commas outside strings. */
function stripJsonc(s) {
let out = "";
for (let i = 0; i < s.length; i++) {
const c = s[i];
if (c === '"') {
const start = i;
for (i++; i < s.length && s[i] !== '"'; i++) if (s[i] === "\\") i++;
out += s.slice(start, i + 1);
} else if (c === "/" && s[i + 1] === "/") {
while (i < s.length && s[i] !== "\n") i++;
out += "\n";
} else if (c === "/" && s[i + 1] === "*") {
const end = s.indexOf("*/", i + 2);
i = end < 0 ? s.length : end + 1;
} else out += c;
}
return out.replace(/,(\s*[}\]])/g, "$1");
}
function checkSyntax(format, text) {
if (format === "text") return;
try {
JSON.parse(format === "jsonc" ? stripJsonc(text) : text);
} catch (err) {
throw fail("EBADSYNTAX", `not valid ${format.toUpperCase()}: ${err.message}`);
}
}
function backup(id, f, old) {
const dir = path.join(getDataDir(), "ai-tool-config-backups", id);
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
fs.chmodSync(dir, 0o700); // the backups may hold keys; mode only applies on create
const name = path.basename(f.path);
const file = path.join(dir, `${name}.${new Date().toISOString().replace(/[:.]/g, "-")}.bak`);
fs.writeFileSync(file, old, { mode: 0o600 });
const mine = fs
.readdirSync(dir)
.filter((n) => n.startsWith(`${name}.`) && n.endsWith(".bak"))
.sort();
for (const n of mine.slice(0, -KEEP_BACKUPS)) fs.rmSync(path.join(dir, n), { force: true });
return file;
}
function writeConfig(id, index, text, baseSha) {
if (typeof text !== "string") throw fail("EBADFIELD", "text must be a string");
if (Buffer.byteLength(text) > MAX_BYTES) throw fail("ETOOBIG", "the text is larger than 1 MB");
if (text.includes("\0")) throw fail("EBINARY", "not a text file");
const f = target(id, index);
const old = fs.readFileSync(f.real);
if (old.includes(0)) throw fail("EBINARY", "not a text file");
if (baseSha !== sha(old)) throw fail("ESTALE", "the file changed since it was opened");
checkSyntax(f.format, text);
const saved = backup(id, f, old);
// A random name created exclusively (`wx`): an existing file or symlink at
// that path is refused, never written through.
const tmp = path.join(
path.dirname(f.real),
`.${path.basename(f.real)}.agenthub-${crypto.randomBytes(6).toString("hex")}`
);
fs.writeFileSync(tmp, text, { mode: f.mode, flag: "wx" });
try {
fs.chmodSync(tmp, f.mode);
fs.renameSync(tmp, f.real);
} catch (err) {
fs.rmSync(tmp, { force: true });
throw err;
}
return { sha: sha(Buffer.from(text)), backup: saved };
}
module.exports = { listConfig, readConfig, writeConfig };