Files
AgentHub/server/lib/shared-tools.js
T
nntrivi2001 84e2a9f0ac feat(server-s3): one Linux user per account (S3) — experimental
Server mode can now run each account as its own Linux user (ah-<name>)
when `agenthub server install-system` has set S3 up. The dashboard
reaches a member only through sudo -u / write-as / pane-launch, reads a
member's files only through owner- and type-checked opens, and hooks
are authenticated by peer uid. Local mode and server mode without S3
are unchanged.

EXPERIMENTAL: tested with fake sudo and fake accounts only. It has not
yet been verified on a real install with real member accounts, sudo or
a real Codex login. Do not offer it to real users yet.

- S3 core: provisioning, per-member tmux servers, the proxy key
  gateway, shared tools in /opt, dropped capabilities for children
  (P0–P5)
- P4: plan and quota per member home
- P6: ~/.claude.json seed; opt-in plugin baseline installed as the
  member; managed-settings spike script (not run)
- P7: Codex account pool (`agenthub accounts add --tool codex`)
- Roles merged with main's admin / analyst / developer: analysts write
  only their own rows, under S3 as well
- Per-account prefs: one implementation (Chat UI + onboarding)
- Security reviews F1–F49 (five rounds), each fix with a regression
  test: symlink/TOCTOU-safe member reads, no host writes in a member
  home, credentials and the data dir never served, owner and folder
  account must agree, pane tokens bound to the pane's uid, bounded
  sudo/tmux/git calls and member reads (no hang or crash from a member),
  async route rejections answer 500 instead of exiting
- Docs: docs/SERVER-MODE.md (S3 model, accepted ceilings, operator
  runbook), docs/plans/server-mode/S3-PLAN.md, CLAUDE.md S3 rules

Tests: server 2838/2841 (3 need 127.0.0.1:3000, held on this box);
client 1162/1162; tsc clean; headers check passes.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-09 14:57:10 +07:00

194 lines
7.6 KiB
JavaScript

/**
* @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/<pkg>/…`): the package
* folder (its dependencies live inside it for a global install);
* - a script in a folder of its own (`…/versions/<v>/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 `<root>/<id>/<size>-<mtime>/` (so an unchanged tool is not
* copied twice) and `<root>/bin/<command>` 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 `<root>/bin`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
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 });
/** `<root>/bin/<command>` 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 };