5fe5d51b1b
AI Proxy (was AI Router): - server/lib/ai-proxies.js, proxy-apply.js, routes/ai-proxies.js mounted at /api/ai-proxies and the old /api/ai-routers; `router` still accepted on the start routes; profiles move to <CLAUDE_HOME>/agenthub-proxies/ - dashboard_runs.router renamed in place to proxy: an older build on the same DB fails on that column, so dev and prod sharing one DB update together - a proxy's baseUrl stored as its root (a typed /v1 made /v1/v1/messages); Test probes Claude /v1/messages and Codex /v1/responses - Settings → AI Proxy (/settings/ai-router redirects), ProxyPicker Updates: - every tab reloads onto the new UI build when its WebSocket reconnects to a server of another version (lib/newBuildReload.ts, once per version) - Update now stops waiting on a plugin chain that failed or pulled nothing (the old server never goes down), and reloads as soon as a new version answers Also: - a pane whose command exits at once says so (EPANEEXITED) instead of "Command failed: tmux set-option … mouse on" - the pane drag preview draws where the pane actually lands
465 lines
16 KiB
JavaScript
465 lines
16 KiB
JavaScript
/**
|
|
* @file The AI Tools catalog: which AI coding CLIs AgentHub can run in a lane
|
|
* (`server/data/ai-tools/*.json`, the newer release's copy synced by
|
|
* ai-tools-sync.js, and overrides in `DASHBOARD_AI_TOOLS_DIR`, in rising
|
|
* precedence), how each is installed/updated (built-in and release files only),
|
|
* which of them are installed (a walk of the PATH directories — no process per
|
|
* tool), and the user's enable/default choice (`<data dir>/ai-tools.json`).
|
|
* A catalog file is data only: its args and env reach an argv in pty-run.js,
|
|
* never a shell, and a custom file may not claim a hand-written `launcher` nor
|
|
* an `install`/`update` — an Install click would run whatever it named.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
const { getDataDir } = require("./claude-home");
|
|
|
|
const BUILTIN_DIR = path.join(__dirname, "..", "data", "ai-tools");
|
|
const ID_RE = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
const CMD_RE = /^[A-Za-z0-9._+-]+$/;
|
|
const ENV_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
const LAUNCHERS = new Set(["claude", "codex"]);
|
|
const PROMPT_MODES = new Set(["argv", "flag", "after-start"]);
|
|
const PROXY_CAPABLE = new Set(["claude", "codex"]);
|
|
const PKG_RE = /^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
|
|
// Files shipped in a release (built-in, or synced from the newest release) are
|
|
// trusted to name a launcher and an install/update; a custom file is not.
|
|
const TRUSTED = new Set(["builtin", "remote"]);
|
|
|
|
let dirsOverride = null;
|
|
let catalog = null; // Map id -> tool, in catalog order
|
|
const detected = new Map();
|
|
|
|
function makeErr(code, message) {
|
|
const err = new Error(message);
|
|
err.code = code;
|
|
return err;
|
|
}
|
|
|
|
const isStrArray = (v, re) =>
|
|
Array.isArray(v) && v.every((s) => typeof s === "string" && s && (!re || re.test(s)));
|
|
|
|
// ponytail: compares the last two host labels, not a public suffix list —
|
|
// wrong for co.uk-style hosts (none in the catalog); swap in a PSL if one comes.
|
|
const siteOf = (host) => host.toLowerCase().split(".").slice(-2).join(".");
|
|
|
|
/** The script URL, when it is https on the homepage's site; else null. */
|
|
function scriptUrl(url, homepageUrl) {
|
|
try {
|
|
const u = new URL(url);
|
|
const home = new URL(homepageUrl);
|
|
if (u.protocol !== "https:" || u.username || u.password || u.port) return null;
|
|
return siteOf(u.hostname) === siteOf(home.hostname) ? u.href : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** `{install, update}` normalised, or null when either is malformed. */
|
|
function installFields(doc) {
|
|
let install = null;
|
|
if (doc.install !== undefined) {
|
|
const i = doc.install;
|
|
if (i?.kind === "npm" && typeof i.package === "string" && PKG_RE.test(i.package)) {
|
|
install = { kind: "npm", package: i.package };
|
|
} else if (i?.kind === "script" && typeof i.url === "string") {
|
|
const url = scriptUrl(i.url, doc.homepageUrl);
|
|
if (!url) return null;
|
|
install = { kind: "script", url };
|
|
} else return null;
|
|
}
|
|
let update = null;
|
|
if (doc.update !== undefined) {
|
|
const u = doc.update;
|
|
if ((u?.kind === "npm" || u?.kind === "script") && install?.kind === u.kind) {
|
|
update = { kind: u.kind };
|
|
} else if (u?.kind === "self" && isStrArray(u.args) && u.args.length) {
|
|
update = { kind: "self", args: u.args };
|
|
} else return null;
|
|
}
|
|
return { install, update };
|
|
}
|
|
|
|
/** A normalised tool, or null when the file breaks any rule. */
|
|
function validate(doc, source) {
|
|
if (!doc || typeof doc !== "object") return null;
|
|
if (typeof doc.id !== "string" || !ID_RE.test(doc.id)) return null;
|
|
if (typeof doc.label !== "string" || !doc.label.trim()) return null;
|
|
if (!isStrArray(doc.detect, CMD_RE) || !doc.detect.length) return null;
|
|
if (doc.requires !== undefined && !isStrArray(doc.requires, CMD_RE)) return null;
|
|
for (const k of ["args", "resume", "bypass"]) {
|
|
if (doc[k] !== undefined && !isStrArray(doc[k])) return null;
|
|
}
|
|
if (doc.bypassEnv !== undefined) {
|
|
const e = doc.bypassEnv;
|
|
if (!e || typeof e !== "object" || Array.isArray(e)) return null;
|
|
if (!Object.entries(e).every(([k, v]) => ENV_RE.test(k) && typeof v === "string")) return null;
|
|
}
|
|
let launcher = null;
|
|
if (doc.launcher !== undefined) {
|
|
if (!TRUSTED.has(source) || !LAUNCHERS.has(doc.launcher) || doc.launcher !== doc.id)
|
|
return null;
|
|
launcher = doc.launcher;
|
|
}
|
|
if ((doc.install !== undefined || doc.update !== undefined) && !TRUSTED.has(source)) return null;
|
|
const config = configFiles(doc.config, source);
|
|
if (!config) return null;
|
|
const fields = installFields(doc);
|
|
if (!fields) return null;
|
|
let prompt = null;
|
|
if (!launcher) {
|
|
const p = doc.prompt;
|
|
if (!p || !PROMPT_MODES.has(p.mode)) return null;
|
|
if (p.mode === "flag" && (typeof p.flag !== "string" || !p.flag.startsWith("-"))) return null;
|
|
if (p.mode === "argv" && p.separator !== undefined && p.separator !== "--") return null;
|
|
prompt = { mode: p.mode };
|
|
if (p.mode === "flag") prompt.flag = p.flag;
|
|
if (p.mode === "argv" && p.separator) prompt.separator = p.separator;
|
|
if (p.mode === "after-start") {
|
|
const d = Number(p.delayMs);
|
|
prompt.delayMs = Number.isFinite(d) && d >= 0 && d <= 60000 ? d : 3000;
|
|
}
|
|
}
|
|
// `model`: how a run picks a model on this CLI. Only a flag name and a
|
|
// vendor (which pricing rows to offer as choices) — the value itself is
|
|
// the user's, validated by validModel() at spawn time.
|
|
let model = null;
|
|
if (doc.model !== undefined) {
|
|
const m = doc.model;
|
|
if (!m || typeof m.flag !== "string" || !/^--?[a-z][a-z0-9-]*$/.test(m.flag)) return null;
|
|
if (m.vendor !== undefined && !MODEL_VENDORS.has(m.vendor)) return null;
|
|
model = { flag: m.flag, vendor: m.vendor || null };
|
|
}
|
|
return {
|
|
id: doc.id,
|
|
label: doc.label.trim(),
|
|
model,
|
|
detect: doc.detect,
|
|
requires: doc.requires || [],
|
|
args: doc.args || [],
|
|
homepageUrl:
|
|
typeof doc.homepageUrl === "string" && /^https:\/\//.test(doc.homepageUrl)
|
|
? doc.homepageUrl
|
|
: null,
|
|
faviconDomain: typeof doc.faviconDomain === "string" ? doc.faviconDomain : null,
|
|
prompt,
|
|
resume: doc.resume || null,
|
|
bypass: doc.bypass || null,
|
|
bypassEnv: doc.bypassEnv || null,
|
|
launcher,
|
|
install: fields.install,
|
|
update: fields.update,
|
|
config,
|
|
source,
|
|
};
|
|
}
|
|
|
|
/** A name no config editor may open: logins, tokens, keys. */
|
|
const CONFIG_SECRET_RE =
|
|
/(auth|credential|token|secret|oauth|cookie|passw|api[-_]?key|\.env$|\.pem$|\.key$|id_rsa)/i;
|
|
const CONFIG_FORMATS = new Set(["json", "jsonc", "text"]);
|
|
|
|
/**
|
|
* `config`: the tool's own config files its Settings tab may edit — like
|
|
* `install`, only from a trusted file. Each is `~/`-relative, never climbs
|
|
* out with `..`, and never names a credential. Answers [] when absent, null
|
|
* when invalid.
|
|
*/
|
|
function configFiles(raw, source) {
|
|
if (raw === undefined) return [];
|
|
if (!TRUSTED.has(source) || !Array.isArray(raw) || raw.length > 8) return null;
|
|
const out = [];
|
|
for (const c of raw) {
|
|
const p = c && c.path;
|
|
if (typeof p !== "string" || !p.startsWith("~/") || p.includes("\0")) return null;
|
|
if (p.split("/").some((seg) => seg === "..") || CONFIG_SECRET_RE.test(path.basename(p)))
|
|
return null;
|
|
const format = c.format ?? (/\.jsonc$/.test(p) ? "jsonc" : /\.json$/.test(p) ? "json" : "text");
|
|
if (!CONFIG_FORMATS.has(format)) return null;
|
|
out.push({ path: p, format });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
const MODEL_VENDORS = new Set(["claude", "openai", "google"]);
|
|
|
|
/**
|
|
* A model id that may follow a model flag as one argv element: a letter or
|
|
* digit first (never `-`, so it can not become a flag of its own), then the
|
|
* characters real ids use — `opus[1m]`, `openai/gpt-5.5`, `gemini-3.1-pro`.
|
|
*/
|
|
function validModel(m) {
|
|
return typeof m === "string" && /^[A-Za-z0-9][A-Za-z0-9._:/@+[\]-]{0,127}$/.test(m);
|
|
}
|
|
|
|
const RANK = { builtin: 0, remote: 1, custom: 2 };
|
|
|
|
/** `[dir, source]` pairs, lowest precedence first. */
|
|
function catalogDirs() {
|
|
if (dirsOverride) {
|
|
const names =
|
|
dirsOverride.length === 2 ? ["builtin", "custom"] : ["builtin", "remote", "custom"];
|
|
return dirsOverride.map((d, i) => [d, names[i]]);
|
|
}
|
|
// Lazy: ai-tools-sync.js requires this module for validate().
|
|
const remote = require("./ai-tools-sync").activeRemoteDir();
|
|
return [
|
|
[BUILTIN_DIR, "builtin"],
|
|
[remote, "remote"],
|
|
[process.env.DASHBOARD_AI_TOOLS_DIR, "custom"],
|
|
].filter(([d]) => d);
|
|
}
|
|
|
|
function load() {
|
|
const byId = new Map();
|
|
catalogDirs().forEach(([dir, source]) => {
|
|
let files = [];
|
|
try {
|
|
files = fs
|
|
.readdirSync(dir)
|
|
.filter((f) => f.endsWith(".json"))
|
|
.sort();
|
|
} catch {
|
|
return; // dir absent — fine
|
|
}
|
|
for (const f of files) {
|
|
let tool = null;
|
|
try {
|
|
tool = validate(JSON.parse(fs.readFileSync(path.join(dir, f), "utf8")), source);
|
|
} catch {
|
|
/* fall through to the log line */
|
|
}
|
|
if (!tool) {
|
|
console.warn(`ai-tools: skipping invalid catalog file ${path.join(dir, f)}`);
|
|
continue;
|
|
}
|
|
// A later tier beats an earlier one; within one dir the first file wins.
|
|
if (!byId.has(tool.id) || RANK[source] > RANK[byId.get(tool.id).source]) {
|
|
byId.set(tool.id, tool);
|
|
}
|
|
}
|
|
});
|
|
const rank = (t) => (t.id === "claude" ? 0 : t.id === "codex" ? 1 : 2);
|
|
const sorted = [...byId.values()].sort(
|
|
(a, b) => rank(a) - rank(b) || a.label.localeCompare(b.label)
|
|
);
|
|
return new Map(sorted.map((t) => [t.id, t]));
|
|
}
|
|
|
|
function cat() {
|
|
if (!catalog) catalog = load();
|
|
return catalog;
|
|
}
|
|
|
|
const list = () => [...cat().values()];
|
|
const get = (id) => cat().get(id) || null;
|
|
const has = (id) => typeof id === "string" && cat().has(id);
|
|
|
|
/** First executable `name` on `pathStr` (default: the server's PATH). */
|
|
function which(name, pathStr = process.env.PATH || "") {
|
|
for (const dir of pathStr.split(path.delimiter)) {
|
|
if (!dir) continue;
|
|
for (const candidate of executableNames(name)) {
|
|
const p = path.join(dir, candidate);
|
|
try {
|
|
if (fs.statSync(p).isFile()) {
|
|
fs.accessSync(p, fs.constants.X_OK);
|
|
return p;
|
|
}
|
|
} catch {
|
|
/* not here */
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* The file names `name` can run as. On Windows only a `PATHEXT` extension
|
|
* makes a file runnable (`claude.exe`, `codex.cmd`): the bare name is tried
|
|
* only when it already carries one, because npm's extensionless `codex` next
|
|
* to `codex.cmd` is a POSIX sh shim Windows cannot start.
|
|
*/
|
|
function executableNames(name) {
|
|
if (process.platform !== "win32") return [name];
|
|
const exts = (process.env.PATHEXT || ".EXE;.CMD;.BAT").split(";").filter(Boolean);
|
|
const lower = name.toLowerCase();
|
|
if (exts.some((e) => lower.endsWith(e.toLowerCase()))) return [name];
|
|
return exts.map((e) => name + e.toLowerCase());
|
|
}
|
|
|
|
/**
|
|
* How to start `file` with spawn/execFile: on Windows a `.cmd`/`.bat` (npm,
|
|
* an npm-installed CLI) runs only through cmd.exe — Node refuses it otherwise
|
|
* (EINVAL, CVE-2024-27980) — so it gets `shell: true` and a quoted path, as
|
|
* `C:\Program Files\nodejs\npm.cmd` holds a space. Only for argv built from
|
|
* release catalog files, never from a request: cmd.exe re-parses the args.
|
|
*/
|
|
function spawnTarget(file) {
|
|
if (process.platform === "win32" && /\.(cmd|bat)$/i.test(file)) {
|
|
return { file: `"${file}"`, shell: true };
|
|
}
|
|
return { file, shell: false };
|
|
}
|
|
|
|
/** `{installed, path, command}` for one tool, cached until refresh() unless `fresh`. */
|
|
function detect(id, { fresh = false } = {}) {
|
|
if (!fresh && detected.has(id)) return detected.get(id);
|
|
const tool = get(id);
|
|
let out = { installed: false, path: null, command: tool ? tool.detect[0] : null };
|
|
if (tool) {
|
|
for (const name of tool.detect) {
|
|
const p = which(name);
|
|
if (p) {
|
|
out = { installed: tool.requires.every((r) => which(r)), path: p, command: name };
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
detected.set(id, out);
|
|
return out;
|
|
}
|
|
|
|
function refresh() {
|
|
catalog = null;
|
|
detected.clear();
|
|
}
|
|
|
|
const statePath = () => path.join(getDataDir(), "ai-tools.json");
|
|
|
|
function readState() {
|
|
try {
|
|
const s = JSON.parse(fs.readFileSync(statePath(), "utf8"));
|
|
const models = {};
|
|
if (s.models && typeof s.models === "object") {
|
|
for (const [id, m] of Object.entries(s.models)) if (validModel(m)) models[id] = m;
|
|
}
|
|
return {
|
|
disabled: Array.isArray(s.disabled) ? s.disabled.filter((x) => typeof x === "string") : [],
|
|
default: typeof s.default === "string" ? s.default : null,
|
|
models,
|
|
};
|
|
} catch {
|
|
return { disabled: [], default: null, models: {} };
|
|
}
|
|
}
|
|
|
|
function writeState(state) {
|
|
fs.mkdirSync(path.dirname(statePath()), { recursive: true });
|
|
const tmp = `${statePath()}.${process.pid}.tmp`;
|
|
fs.writeFileSync(tmp, JSON.stringify(state, null, 2) + "\n");
|
|
fs.renameSync(tmp, statePath());
|
|
}
|
|
|
|
const isEnabled = (id) => !readState().disabled.includes(id);
|
|
|
|
function requireTool(id) {
|
|
if (!has(id)) throw makeErr("ENOTOOL", `unknown AI tool: ${id}`);
|
|
}
|
|
|
|
function setEnabled(id, enabled) {
|
|
requireTool(id);
|
|
const s = readState();
|
|
s.disabled = s.disabled.filter((x) => x !== id);
|
|
if (!enabled) {
|
|
s.disabled.push(id);
|
|
if (s.default === id) s.default = null;
|
|
}
|
|
writeState(s);
|
|
}
|
|
|
|
function setDefault(id) {
|
|
requireTool(id);
|
|
if (!detect(id).installed || !isEnabled(id)) {
|
|
throw makeErr("EBADDEFAULT", `${id} must be installed and enabled to be the default`);
|
|
}
|
|
writeState({ ...readState(), default: id });
|
|
}
|
|
|
|
/** The model a new run of `id` starts with; null clears it (the CLI's own). */
|
|
function setDefaultModel(id, model) {
|
|
requireTool(id);
|
|
if (model !== null && !validModel(model)) {
|
|
throw makeErr("EBADMODEL", "not a valid model id");
|
|
}
|
|
const s = readState();
|
|
if (model === null) delete s.models[id];
|
|
else s.models[id] = model;
|
|
writeState(s);
|
|
}
|
|
|
|
function effectiveDefault() {
|
|
const s = readState();
|
|
const ok = (id) => has(id) && detect(id).installed && !s.disabled.includes(id);
|
|
if (s.default && ok(s.default)) return s.default;
|
|
if (ok("claude")) return "claude";
|
|
return list().find((t) => ok(t.id))?.id || null;
|
|
}
|
|
|
|
/** The GET /api/ai-tools body — flags only, never the arg arrays. */
|
|
function view() {
|
|
const state = readState();
|
|
const disabled = new Set(state.disabled);
|
|
return {
|
|
tools: list().map((t) => {
|
|
const d = detect(t.id);
|
|
return {
|
|
id: t.id,
|
|
label: t.label,
|
|
command: d.command,
|
|
homepageUrl: t.homepageUrl,
|
|
installed: d.installed,
|
|
path: d.path,
|
|
enabled: !disabled.has(t.id),
|
|
source: t.source,
|
|
launcher: t.launcher,
|
|
prompt: t.prompt ? { mode: t.prompt.mode } : null,
|
|
resume: !!t.resume,
|
|
bypass: !!(t.bypass || t.bypassEnv),
|
|
proxyCapable: PROXY_CAPABLE.has(t.id),
|
|
// claude/codex take a model through their own argv builders.
|
|
modelFlag: Boolean(t.model || t.launcher),
|
|
defaultModel: state.models[t.id] || null,
|
|
modelVendor: t.model?.vendor || null,
|
|
configFiles: t.config.length,
|
|
install: t.install,
|
|
update: t.update,
|
|
};
|
|
}),
|
|
default: effectiveDefault(),
|
|
catalog: dirsOverride
|
|
? { source: "builtin", tag: null, fetchedAt: null }
|
|
: require("./ai-tools-sync").catalogMeta(),
|
|
};
|
|
}
|
|
|
|
/** Test seam: `[builtin, custom]` or `[builtin, remote, custom]`, or null for the real ones. */
|
|
function __setCatalogDirs(dirs) {
|
|
dirsOverride = dirs;
|
|
refresh();
|
|
}
|
|
|
|
module.exports = {
|
|
list,
|
|
get,
|
|
has,
|
|
detect,
|
|
refresh,
|
|
readState,
|
|
isEnabled,
|
|
setEnabled,
|
|
setDefault,
|
|
setDefaultModel,
|
|
validModel,
|
|
effectiveDefault,
|
|
view,
|
|
validate,
|
|
which,
|
|
spawnTarget,
|
|
siteOf,
|
|
PROXY_CAPABLE,
|
|
CONFIG_SECRET_RE,
|
|
__setCatalogDirs,
|
|
};
|