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>
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* @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 };
|
||||
Reference in New Issue
Block a user