/** * @file Daily copy of the AI Tools catalog from the newest AgentHub release, * so a new tool or a fixed flag reaches a user without a plugin update. Only a * release NEWER than the running build is copied (a dev checkout ahead of the * newest release is never rolled back), each file is validated at release * trust by ai-tools.js, and the set is replaced whole or not at all. Stored * in `/runtime/ai-tools-remote/`. Off with AGENTHUB_AI_TOOLS_SYNC=0. * @author Nguyễn Ngọc Trí Vĩ */ const fs = require("node:fs"); const path = require("node:path"); const { getDataDir } = require("./claude-home"); const { compareVersions, releaseApiUrl, fetchReleaseFromUrl, repoUrlFor, DEFAULT_ROOT, } = require("./update-check"); const DAY_MS = 24 * 60 * 60 * 1000; const FIRST_MS = 60 * 1000; const MAX_FILES = 200; const MAX_BYTES = 64 * 1024; const NAME_RE = /^[a-z0-9][a-z0-9-]{0,39}\.json$/; let versionOverride = null; const disabled = () => /^(0|false|off)$/i.test(process.env.AGENTHUB_AI_TOOLS_SYNC || ""); const remoteDir = () => path.join(getDataDir(), "runtime", "ai-tools-remote"); function runningVersion() { if (versionOverride) return versionOverride; try { return JSON.parse(fs.readFileSync(path.join(DEFAULT_ROOT, "package.json"), "utf8")).version; } catch { return null; } } function readMeta() { try { const m = JSON.parse(fs.readFileSync(path.join(remoteDir(), "meta.json"), "utf8")); return typeof m.tag === "string" ? m : null; } catch { return null; } } /** The synced dir when it holds a release newer than this build; else null. */ function activeRemoteDir() { if (disabled()) return null; const meta = readMeta(); const running = runningVersion(); if (!meta || !running || compareVersions(meta.tag, running) <= 0) return null; return remoteDir(); } function catalogMeta() { if (!activeRemoteDir()) return { source: "builtin", tag: null, fetchedAt: null }; const { tag, fetchedAt } = readMeta(); return { source: "remote", tag, fetchedAt: fetchedAt || null }; } async function getText(url) { const res = await fetch(url, { signal: AbortSignal.timeout(15_000), headers: { accept: "application/json" }, redirect: "follow", }); if (!res.ok) throw new Error(`http_${res.status}`); // ponytail: reads the body before checking its size; stream with a byte cap // if a forge ever answers with something large enough to matter. return res.text(); } /** One pass. Never throws; `{ok:false, reason}` on every miss. */ async function syncOnce({ repoUrl, root = DEFAULT_ROOT } = {}) { if (disabled()) return { ok: false, reason: "disabled" }; const repo = repoUrl || (await repoUrlFor(root)); const api = repo && releaseApiUrl(repo); if (!api) return { ok: false, reason: "no_remote" }; const rel = await fetchReleaseFromUrl(repo); if (!rel.ok) return { ok: false, reason: rel.reason }; const running = runningVersion(); if (!running || compareVersions(rel.tag, running) <= 0) return { ok: false, reason: "not_newer" }; const { validate, refresh } = require("./ai-tools"); const origin = new URL(api).origin; const tmp = `${remoteDir()}.tmp-${process.pid}`; try { const listUrl = api.replace( /\/releases\/latest$/, `/contents/server/data/ai-tools?ref=${encodeURIComponent(rel.tag)}` ); const entries = JSON.parse(await getText(listUrl)); if (!Array.isArray(entries) || entries.length > MAX_FILES) { return { ok: false, reason: "bad_listing" }; } fs.rmSync(tmp, { recursive: true, force: true }); fs.mkdirSync(tmp, { recursive: true }); for (const e of entries) { if (e?.type !== "file" || !NAME_RE.test(e.name || "")) continue; let url; try { url = new URL(e.download_url); } catch { continue; } if (url.origin !== origin) continue; // only the forge the release came from const text = await getText(url.href); // a failed download aborts the pass if (Buffer.byteLength(text) > MAX_BYTES) continue; let doc; try { doc = JSON.parse(text); } catch { continue; } if (validate(doc, "remote")) fs.writeFileSync(path.join(tmp, e.name), text); } fs.writeFileSync( path.join(tmp, "meta.json"), JSON.stringify({ tag: rel.tag, fetchedAt: new Date().toISOString() }) + "\n" ); const old = `${remoteDir()}.old-${process.pid}`; if (fs.existsSync(remoteDir())) fs.renameSync(remoteDir(), old); fs.renameSync(tmp, remoteDir()); fs.rmSync(old, { recursive: true, force: true }); refresh(); return { ok: true, tag: rel.tag }; } catch (err) { fs.rmSync(tmp, { recursive: true, force: true }); return { ok: false, reason: "unreachable", error: err.message }; } } function startAiToolsSync() { if (disabled()) return; setTimeout(() => { void syncOnce(); setInterval(() => void syncOnce(), DAY_MS).unref(); }, FIRST_MS).unref(); } /** Test seam: pin the running version, or null for package.json. */ function __setVersion(v) { versionOverride = v; } module.exports = { syncOnce, startAiToolsSync, activeRemoteDir, catalogMeta, remoteDir, __setVersion, };