/** * @file shared-tools.js * @description Server mode with one Linux user per account (S3/S4): the AI * tools every account runs, installed once under `/opt/agenthub/tools` * (owned by the dashboard's account, `r-x` for everyone). A member's pane has * `/opt/agenthub/tools/bin` first on its PATH (`run-as.memberPaneEnv`) and * cannot enter the host's home, where the AI Tools page installs them — so * every installed tool is PUBLISHED there: after an install or update, at * server start, and by `agenthub server tools publish`. * * What a publish copies, from the tool's real (symlink-resolved) path: * - a native executable (ELF): that one file; * - a file inside an npm package (`…/node_modules//…`): the package * folder (its dependencies live inside it for a global install); * - a script in a folder of its own (`…/versions//cursor-agent`): that * folder — never a shared `bin` folder, nor a dot-folder right under the * home (`~/.tool/tool`: its login usually sits beside it), both refused. * A file whose name says it holds a login (`CONFIG_SECRET_RE`) is never * copied: the copy is readable by every account. * Each copy lands in `//-/` (so an unchanged tool is not * copied twice) and `/bin/` is pointed at it. Old copies are * kept (a running pane may still execute one). Self-updates in a member's * pane cannot touch them: the files are not the member's. * * The dashboard's own scripts members need (hook handler, `agenthub` CLI, * MCP server, statusline) are published to `/opt/agenthub/app` the same way, * with an `agenthub` launcher in `/bin`. * @author Nguyễn Ngọc Trí Vĩ */ const fs = require("node:fs"); const path = require("node:path"); const toolsRoot = () => process.env.AGENTHUB_TOOLS_ROOT || "/opt/agenthub/tools"; const appRoot = () => process.env.AGENTHUB_APP_ROOT || "/opt/agenthub/app"; const err = (code, message) => Object.assign(new Error(message), { code }); /** `/bin/` when it exists and runs, else null. */ function sharedPath(command) { if (typeof command !== "string" || !/^[A-Za-z0-9._-]+$/.test(command)) return null; const p = path.join(toolsRoot(), "bin", command); try { fs.accessSync(p, fs.constants.X_OK); return p; } catch { return null; } } function isElf(file) { const fd = fs.openSync(file, "r"); try { const buf = Buffer.alloc(4); fs.readSync(fd, buf, 0, 4, 0); return buf.equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46])); } finally { fs.closeSync(fd); } } /** * What to copy for an executable at `real`: `{src, entry}` — `src` a file or * a folder, `entry` the executable's path relative to the copy ("" = src). */ function planFor(real) { const parts = real.split(path.sep); const nm = parts.lastIndexOf("node_modules"); if (nm !== -1 && nm + 1 < parts.length - 1) { const end = parts[nm + 1].startsWith("@") ? nm + 3 : nm + 2; const src = parts.slice(0, end).join(path.sep); return { src, entry: path.relative(src, real) }; } if (isElf(real)) return { src: real, entry: "" }; const dir = path.dirname(real); const home = require("node:os").homedir(); if (path.basename(dir) === "bin" || dir === path.dirname(dir) || path.dirname(dir) === home) { throw err( "ESHAREDLAYOUT", `${real} is a script in a shared folder — install this tool under ${toolsRoot()} by hand` ); } return { src: dir, entry: path.basename(real) }; } const secretName = (f) => require("./ai-tools").CONFIG_SECRET_RE.test(path.basename(f)); /** Point `link` at `target` (relative), replacing what was there in one rename. */ function relink(link, target) { const tmp = `${link}.agenthub-tmp`; fs.rmSync(tmp, { force: true }); fs.symlinkSync(path.relative(path.dirname(link), target), tmp); fs.renameSync(tmp, link); } /** * Publish one installed catalog tool. Resolves `{id, command, path, copied}`. * @throws ENOTINSTALLED, ESHAREDLAYOUT */ async function publish(id) { const aiTools = require("./ai-tools"); const entry = aiTools.get(id); const found = entry && aiTools.detect(id, { fresh: true }); if (!found?.installed || !found.path) throw err("ENOTINSTALLED", `${id} is not installed`); const command = entry.detect[0]; const real = fs.realpathSync(found.path); if (real.startsWith(toolsRoot() + path.sep)) { return { id, command, path: real, copied: false }; // already a shared copy } const plan = planFor(real); const stat = fs.statSync(real); const dest = path.join(toolsRoot(), id, `${stat.size}-${Math.floor(stat.mtimeMs)}`); const copy = plan.entry ? path.join(dest, path.basename(plan.src)) : path.join(dest, command); let copied = false; if (!fs.existsSync(dest)) { const tmp = `${dest}.agenthub-tmp`; await fs.promises.rm(tmp, { recursive: true, force: true }); await fs.promises.mkdir(tmp, { recursive: true }); const into = plan.entry ? path.join(tmp, path.basename(plan.src)) : path.join(tmp, command); await fs.promises.cp(plan.src, into, { recursive: true, verbatimSymlinks: true, filter: (f) => !secretName(f), }); await chmodReadable(tmp); await fs.promises.rename(tmp, dest); copied = true; } fs.mkdirSync(path.join(toolsRoot(), "bin"), { recursive: true, mode: 0o755 }); const exe = plan.entry ? path.join(copy, plan.entry) : copy; relink(path.join(toolsRoot(), "bin", command), exe); return { id, command, path: exe, copied }; } /** `a+rX` over a tree: every account may read and run it, nobody but us write. */ async function chmodReadable(p) { const st = await fs.promises.lstat(p); if (st.isSymbolicLink()) return; const mode = st.isDirectory() || st.mode & 0o111 ? 0o755 : 0o644; await fs.promises.chmod(p, mode); if (st.isDirectory()) { for (const name of await fs.promises.readdir(p)) await chmodReadable(path.join(p, name)); } } /** Every installed catalog tool; one that cannot be published is reported, not thrown. */ async function publishAll() { const aiTools = require("./ai-tools"); const out = []; for (const t of aiTools.list()) { if (!aiTools.detect(t.id, { fresh: true }).installed) continue; try { out.push(await publish(t.id)); } catch (e) { out.push({ id: t.id, error: e.message }); } } return out; } const APP_DIRS = ["scripts", "server/lib", "bin", "mcp", "statusline"]; /** * The dashboard's own scripts a member's pane runs (hook handler, CLI, MCP * server, statusline), from this install, plus the `agenthub` launcher. */ async function publishApp(root = path.join(__dirname, "..", "..")) { for (const dir of APP_DIRS) { const src = path.join(root, dir); if (!fs.existsSync(src)) continue; const dest = path.join(appRoot(), dir); await fs.promises.mkdir(dest, { recursive: true }); await fs.promises.cp(src, dest, { recursive: true, force: true }); await chmodReadable(dest); } // The CLI reads its version from here. if (fs.existsSync(path.join(root, "package.json"))) { await fs.promises.copyFile( path.join(root, "package.json"), path.join(appRoot(), "package.json") ); await fs.promises.chmod(path.join(appRoot(), "package.json"), 0o644); } const launcher = path.join(toolsRoot(), "bin", "agenthub"); const body = "#!/bin/sh\n# AgentHub CLI for members' panes (published by the dashboard).\n" + `exec ${path.join(toolsRoot(), "bin", "node")} ${path.join(appRoot(), "bin", "agenthub.js")} "$@"\n`; fs.mkdirSync(path.dirname(launcher), { recursive: true }); fs.writeFileSync(`${launcher}.tmp`, body, { mode: 0o755 }); fs.renameSync(`${launcher}.tmp`, launcher); } module.exports = { sharedPath, planFor, publish, publishAll, publishApp, toolsRoot };