/** * @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 `/ai-tool-config-backups//` (newest 10 * kept), and replaces the file atomically keeping its mode. * @author Nguyễn Ngọc Trí Vĩ */ 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 };