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:
2026-10-09 14:57:10 +07:00
parent b7f3735b29
commit 84e2a9f0ac
123 changed files with 12051 additions and 605 deletions
+54 -29
View File
@@ -13,7 +13,6 @@
const fs = require("fs");
const path = require("path");
const readline = require("readline");
const {
bucketKey,
emptyBucket,
@@ -93,24 +92,51 @@ function snapshotTranscript(sourceJsonlPath, sessionId) {
/**
* Copy `src` to `dest` only when `dest` is missing or smaller than `src`
* (i.e. the source grew). Creates parent dirs as needed.
* (i.e. the source grew). Creates parent dirs as needed. The source is OPENED
* through run-as (S3): a file in a member's home is copied only when the open
* file is a regular file that member owns — `statSync`/`copyFileSync` would
* follow a link the member planted to another account's transcript, with the
* dashboard's CAP_DAC_READ_SEARCH, into a snapshot the member may then read.
*/
const SNAPSHOT_MEMBER_MAX = 256 * 1024 * 1024;
const ZERO_MB = Buffer.alloc(1 << 20);
function copyIfNewer(src, dest) {
let srcSize;
let fd;
try {
srcSize = fs.statSync(src).size;
fd = require("../server/lib/run-as").openSafe(src);
} catch {
return; // source vanished mid-import — nothing to copy
return; // source vanished mid-import, or is not its member's own file
}
let destSize = -1;
try {
destSize = fs.statSync(dest).size;
} catch {
/* dest missing */
const srcSize = fs.fstatSync(fd).size;
let destSize = -1;
try {
destSize = fs.statSync(dest).size;
} catch {
/* dest missing */
}
if (destSize >= srcSize) return; // snapshot already at least as complete
// S3: a member's file is copied into the HOST's disk, so only up to a cap,
// and a run of zeros stays a hole — a sparse file costs its member nothing.
// ponytail: sync copy of ≤ 256 MiB; go async if member transcripts get that big.
if (srcSize > SNAPSHOT_MEMBER_MAX && require("../server/lib/run-as").isMemberFile(src)) return;
fs.mkdirSync(path.dirname(dest), { recursive: true });
const out = fs.openSync(dest, "w", 0o600);
try {
const buf = Buffer.allocUnsafe(ZERO_MB.length);
let pos = 0;
for (let n; pos < srcSize; pos += n) {
n = fs.readSync(fd, buf, 0, Math.min(buf.length, srcSize - pos), pos);
if (n <= 0) break;
if (!buf.subarray(0, n).equals(ZERO_MB.subarray(0, n))) fs.writeSync(out, buf, 0, n, pos);
}
fs.ftruncateSync(out, pos);
} finally {
fs.closeSync(out);
}
} finally {
fs.closeSync(fd);
}
if (destSize >= srcSize) return; // snapshot already at least as complete
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.copyFileSync(src, dest);
}
/**
@@ -130,10 +156,8 @@ function firstUserLabel(text) {
async function parseSessionFile(filePath) {
const sessionId = path.basename(filePath, ".jsonl");
const rl = readline.createInterface({
input: fs.createReadStream(filePath, { encoding: "utf8" }),
crlfDelay: Infinity,
});
// readLines, never readline: a member's one-line 512 MB file crashes readline.
const rl = require("../server/lib/run-as").readLines(filePath);
let cwd = null;
let model = null;
@@ -386,10 +410,8 @@ async function parseSessionFile(filePath) {
async function parseSubagentFile(filePath) {
const agentId = path.basename(filePath, ".jsonl").replace(/^agent-/, "");
const rl = readline.createInterface({
input: fs.createReadStream(filePath, { encoding: "utf8" }),
crlfDelay: Infinity,
});
// readLines, never readline: a member's one-line 512 MB file crashes readline.
const rl = require("../server/lib/run-as").readLines(filePath);
let task = null;
let model = null;
@@ -531,7 +553,7 @@ async function parseSubagentFile(filePath) {
const metaPath = filePath.replace(/\.jsonl$/, ".meta.json");
try {
if (fs.existsSync(metaPath)) {
const meta = JSON.parse(fs.readFileSync(metaPath, "utf8"));
const meta = JSON.parse(require("../server/lib/run-as").readFileSafe(metaPath, "utf8", { max: 1 << 20 }));
if (meta.agentType) agentType = meta.agentType;
}
} catch {
@@ -1746,10 +1768,7 @@ async function backfillCompactions(dbModule) {
if (!session) continue;
const filePath = path.join(projPath, file);
const rl = readline.createInterface({
input: fs.createReadStream(filePath, { encoding: "utf8" }),
crlfDelay: Infinity,
});
const rl = require("../server/lib/run-as").readLines(filePath);
const compactions = [];
for await (const line of rl) {
@@ -1910,13 +1929,15 @@ async function ingestWorkflowsForBatch(dbModule, sessions) {
*/
async function syncDefaultProjects(dbModule, options = {}) {
const mtimeCache = options.mtimeCache instanceof Map ? options.mtimeCache : new Map();
// One projects dir per call: this account's, or (S3) a member's own.
const root = options.projectsDir || getProjectsDir();
const changed = [];
if (!fs.existsSync(getProjectsDir())) return { changed };
if (!fs.existsSync(root)) return { changed };
let projectDirs;
try {
projectDirs = fs
.readdirSync(getProjectsDir(), { withFileTypes: true })
.readdirSync(root, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name);
} catch {
@@ -1928,7 +1949,7 @@ async function syncDefaultProjects(dbModule, options = {}) {
let scanned = 0;
for (const projDir of projectDirs) {
const projPath = path.join(getProjectsDir(), projDir);
const projPath = path.join(root, projDir);
let files;
try {
files = fs.readdirSync(projPath).filter((f) => f.endsWith(".jsonl"));
@@ -1983,6 +2004,10 @@ async function syncDefaultProjects(dbModule, options = {}) {
// re-parse an unusable file every tick.
mtimeCache.set(sourcePath, mtime);
if (!session) continue;
// S3: a transcript in a member's home names only their own folder and
// session — never another account's (a crafted file's sessionId/cwd).
if (!require("../server/lib/run-as").sameOwner(sourcePath, session.cwd, existingRow?.cwd))
continue;
const result = importSession(dbModule, session);
snapshotTranscript(session._sourceJsonlPath, session.sessionId);
@@ -2249,7 +2274,7 @@ if (require.main === module) {
function findCompactionsInFile(filePath) {
if (!fs.existsSync(filePath)) return [];
const compactions = [];
const content = fs.readFileSync(filePath, "utf8");
const content = require("../server/lib/run-as").readFileSafe(filePath, "utf8");
for (const line of content.split("\n")) {
if (!line) continue;
try {
+20 -2
View File
@@ -628,14 +628,31 @@ function pruneRetiredPluginRoots(rt, root = PLUGIN_ROOT) {
return removed;
}
/**
* Server mode with one Linux user per account (S3): this machine's dashboard
* belongs to the host account named in `/etc/agenthub/server.conf`. A member
* who installs the plugin in their own `~/.claude` must never bootstrap — it
* would start a second dashboard, as them, onto the host's port. Pure fs.
*/
function notTheHost() {
try {
const conf = process.env.AGENTHUB_SERVER_CONF || "/etc/agenthub/server.conf";
const m = /^HOST_UID=(\d+)$/m.exec(fs.readFileSync(conf, "utf8"));
return Boolean(m) && typeof process.getuid === "function" && process.getuid() !== Number(m[1]);
} catch {
return false; // no S3 install: every account bootstraps as before
}
}
/**
* The real work. Runs detached from the SessionStart hook, so its only channel
* is the log file; `/agenthub-update` runs it in the foreground with `force`, which
* reinstalls dependencies and restarts the server unconditionally.
*
* @returns {"ok"|"locked"|"node-too-old"|"install-failed"|"superseded"}
* @returns {"ok"|"locked"|"node-too-old"|"install-failed"|"superseded"|"not-host"}
*/
function bootstrap(rt = runtimeDir(), { force = false } = {}) {
if (notTheHost()) return "not-host";
fs.mkdirSync(rt, { recursive: true });
// First, and ahead of the lock: a retired build has nothing to do and must
@@ -852,7 +869,7 @@ function bootstrap(rt = runtimeDir(), { force = false } = {}) {
* worker so the session never waits on an install.
*/
function main() {
if (fastPathOk()) return;
if (notTheHost() || fastPathOk()) return;
const child = spawn(process.execPath, [__filename, "--worker"], {
detached: true,
stdio: "ignore",
@@ -869,6 +886,7 @@ module.exports = {
LOCK_STALE_MS,
KEEP_RETIRED_ROOTS,
supersededBy,
notTheHost,
pruneRetiredPluginRoots,
installedPluginsPath,
pluginVersion,
+88
View File
@@ -0,0 +1,88 @@
#!/bin/bash
# @file agenthub-provision.sh
# @description Server mode, one Linux user per account (S3): the ONE root
# command the dashboard's host account may run (sudoers, written by
# install-system.sh; installed as /opt/agenthub/sbin/agenthub-provision).
#
# agenthub-provision create <name> Linux user ah-<name>, home <USERS_ROOT>/<name>
# agenthub-provision disable <name> lock the account and end its processes
# agenthub-provision enable <name> unlock it
#
# Nothing but an action and a name comes from the caller; every path comes
# from /etc/agenthub/server.conf (root's). It never deletes an account or a
# home, never touches an account that is not an AgentHub member (one that
# already exists outside group agenthub-members is refused), and never
# grants a member sudo.
# @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
set -euo pipefail
umask 022
cd / # runuser keeps the cwd: one every account may enter
die() { echo "agenthub-provision: $*" >&2; exit "${2:-1}"; }
[ "$(id -u)" = 0 ] || die "must run as root (through sudo)"
[ $# -eq 2 ] || die "usage: agenthub-provision create|disable|enable <name>" 2
CONF=/etc/agenthub/server.conf
[ -f "$CONF" ] || die "$CONF is missing — run agenthub server install-system first"
USERS_ROOT=$(sed -n 's/^USERS_ROOT=//p' "$CONF")
[[ "$USERS_ROOT" == /* ]] || die "USERS_ROOT in $CONF is not absolute"
ACTION=$1 NAME=$2
# Lower case, starts with a letter, ≤ 29 so "ah-<name>" fits Linux's 32.
[[ "$NAME" =~ ^[a-z][a-z0-9_-]{0,28}$ ]] || die "invalid name: $NAME" 3
LOGIN="ah-$NAME"
HOME_DIR="$USERS_ROOT/$NAME"
is_member() { id -nG "$LOGIN" 2>/dev/null | tr ' ' '\n' | grep -qx agenthub-members; }
case "$ACTION" in
create)
if id "$LOGIN" >/dev/null 2>&1; then
is_member || die "$LOGIN exists and is not an AgentHub member — refusing to adopt it" 4
else
[ ! -e "$HOME_DIR" ] || die "$HOME_DIR already exists" 5
useradd --create-home --home-dir "$HOME_DIR" --shell /bin/bash --user-group \
--groups agenthub-members --comment "AgentHub member $NAME" "$LOGIN"
fi
# Home: the member's, readable by the dashboard (group agenthub), nobody else.
# The home itself sits in root's USERS_ROOT, so the member cannot swap it.
chown "$LOGIN:agenthub" "$HOME_DIR"
chmod 0750 "$HOME_DIR"
# Nothing INSIDE the home is touched as root: the member owns every name
# there and could have made one a link to a system file. Their tmux socket
# folder and the folder the dashboard writes a pane's files to (through
# write-as, as them) are made by the member.
runuser -u "$LOGIN" -- mkdir -p -m 0700 "$HOME_DIR/.agenthub/tmux" "$HOME_DIR/.agenthub/handoff"
# Claude Code's first run, as the member: onboarding done (the dashboard's
# own setup chose theme and the rest) and the home trusted, which covers
# every folder under it. Merged into what is there; a file that is not
# JSON is the member's and is left alone.
if [ -x /opt/agenthub/tools/bin/node ]; then
runuser -u "$LOGIN" -- /opt/agenthub/tools/bin/node -e '
const fs = require("fs"), home = process.argv[1], p = home + "/.claude.json";
let d = {};
try { d = JSON.parse(fs.readFileSync(p, "utf8")); } catch (e) { if (e.code !== "ENOENT") process.exit(0); }
d.hasCompletedOnboarding = true;
d.projects = d.projects || {};
(d.projects[home] = d.projects[home] || {}).hasTrustDialogAccepted = true;
fs.writeFileSync(p, JSON.stringify(d, null, 2) + "\n", { mode: 0o600 });
' "$HOME_DIR" || echo "agenthub-provision: Claude Code first run not seeded" >&2
fi
echo "created $LOGIN $HOME_DIR"
;;
disable)
is_member || die "$LOGIN is not an AgentHub member" 4
usermod --lock --expiredate 1 "$LOGIN"
pkill -KILL -u "$LOGIN" 2>/dev/null || true
echo "disabled $LOGIN"
;;
enable)
is_member || die "$LOGIN is not an AgentHub member" 4
usermod --expiredate "" "$LOGIN"
# A member has no password (they never log in to the OS); unlocking one
# that never had any is refused by usermod and changes nothing.
usermod --unlock "$LOGIN" >/dev/null 2>&1 || true
echo "enabled $LOGIN"
;;
*) die "unknown action: $ACTION" 2 ;;
esac
+201
View File
@@ -0,0 +1,201 @@
#!/bin/bash
# @file install-system.sh
# @description Server mode, one Linux user per account (S3) — the ROOT half of
# `agenthub server install-system` (bin/agenthub.js runs it through sudo with
# values it resolved as the host account). Idempotent; `--uninstall` reverses
# everything but the members' homes, their Linux accounts and the groups.
#
# Installs:
# groups agenthub (reads member homes: the host), agenthub-members (every
# member account; sudoers runs-as target), agenthub-proc (sees all
# processes under hidepid: the host, polkitd)
# dirs /srv/agenthub/users 0751 root; /opt/agenthub/{sbin,libexec} root;
# /opt/agenthub/{tools,app} owned by the host (it updates them)
# /etc/agenthub/server.conf HOST_USER, HOST_UID, USERS_ROOT, PORT (root, 0644)
# /opt/agenthub/sbin/agenthub-provision the only root command the host may run
# /opt/agenthub/libexec/member-hook Claude hook forwarder for members
# /opt/agenthub/libexec/write-as writes in a member's home AS the member (sudo -u)
# /opt/agenthub/libexec/pane-launch starts a member's pane from its launch file
# /etc/sudoers.d/agenthub checked with visudo before it is installed
# /etc/claude-code/managed-settings.json the member hooks (never over a foreign file)
# /proc hidepid=invisible,gid=agenthub-proc (now and in /etc/fstab)
# /etc/systemd/system/agenthub.service reads the host-owned env file
# @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
set -euo pipefail
die() { echo "install-system: $*" >&2; exit 1; }
[ "$(id -u)" = 0 ] || die "run through sudo (agenthub server install-system)"
HOST="" NODE="" ROOT="" DATA="" PORT="" TMUX_BIN="" GIT_BIN="" GH_BIN="" UNINSTALL=0
USERS_ROOT=/srv/agenthub/users
while [ $# -gt 0 ]; do
case "$1" in
--host) HOST="$2"; shift 2 ;;
--node) NODE="$2"; shift 2 ;;
--root) ROOT="$2"; shift 2 ;;
--data) DATA="$2"; shift 2 ;;
--port) PORT="$2"; shift 2 ;;
--tmux) TMUX_BIN="$2"; shift 2 ;;
--git) GIT_BIN="$2"; shift 2 ;;
--gh) GH_BIN="$2"; shift 2 ;;
--uninstall) UNINSTALL=1; shift ;;
*) die "unknown argument: $1" ;;
esac
done
UNIT=/etc/systemd/system/agenthub.service
SUDOERS=/etc/sudoers.d/agenthub
MANAGED=/etc/claude-code/managed-settings.json
MANAGED_MARK=/etc/agenthub/managed-settings.sha256
FSTAB_TAG="# agenthub hidepid"
[[ "$HOST" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] || die "--host: not a Linux user name"
HOST_UID=$(id -u "$HOST") || die "no such user: $HOST"
[ "$HOST_UID" -ne 0 ] || die "--host may not be root"
if [ "$UNINSTALL" = 1 ]; then
systemctl disable --now agenthub.service 2>/dev/null || true
if grep -q '^# agenthub-managed-system-unit' "$UNIT" 2>/dev/null; then rm -f "$UNIT"; fi
systemctl daemon-reload
rm -f "$SUDOERS"
if [ -f "$MANAGED_MARK" ] && sha256sum -c --status "$MANAGED_MARK" 2>/dev/null; then
rm -f "$MANAGED" "$MANAGED_MARK"
fi
if grep -q "$FSTAB_TAG" /etc/fstab; then
cp /etc/fstab /etc/fstab.agenthub-bak
sed -i "/$FSTAB_TAG/,+1d" /etc/fstab
mount -o remount,hidepid=off /proc 2>/dev/null || mount -o remount,hidepid=0 /proc || true
fi
rm -rf /opt/agenthub/sbin /opt/agenthub/libexec /etc/agenthub/server.conf
echo "install-system: removed. Kept: $USERS_ROOT, the ah-* accounts, groups agenthub*, /opt/agenthub/{tools,app}."
exit 0
fi
for v in NODE ROOT DATA TMUX_BIN GIT_BIN; do
val="${!v}"
[[ "$val" == /* && "$val" != *..* && "$val" != *[[:space:]]* ]] || die "--${v,,}: absolute path without spaces"
done
[[ "$PORT" =~ ^[0-9]{2,5}$ ]] || die "--port: a number"
[ -x "$NODE" ] && [ -f "$ROOT/server/index.js" ] && [ -x "$TMUX_BIN" ] && [ -x "$GIT_BIN" ] ||
die "node, the plugin root, tmux or git not found"
[ -z "$GH_BIN" ] || [ -x "$GH_BIN" ] || die "--gh: not executable"
command -v visudo >/dev/null || die "visudo is missing"
# ── groups ───────────────────────────────────────────────────────────────────
for g in agenthub agenthub-members agenthub-proc; do getent group "$g" >/dev/null || groupadd "$g"; done
usermod -aG agenthub,agenthub-proc "$HOST"
if getent passwd polkitd >/dev/null; then usermod -aG agenthub-proc polkitd; fi
# ── dirs ─────────────────────────────────────────────────────────────────────
install -d -m 0751 -o root -g root /srv/agenthub "$USERS_ROOT"
install -d -m 0755 -o root -g root /opt/agenthub /opt/agenthub/sbin /opt/agenthub/libexec /etc/agenthub
install -d -m 0755 -o "$HOST" -g "$HOST" /opt/agenthub/tools /opt/agenthub/tools/bin /opt/agenthub/app
cat >/etc/agenthub/server.conf <<EOF
HOST_USER=$HOST
HOST_UID=$HOST_UID
USERS_ROOT=$USERS_ROOT
PORT=$PORT
TMUX_BIN=$TMUX_BIN
GIT_BIN=$GIT_BIN
GH_BIN=$GH_BIN
EOF
chmod 0644 /etc/agenthub/server.conf
install -m 0755 -o root -g root "$ROOT/scripts/server/agenthub-provision.sh" /opt/agenthub/sbin/agenthub-provision
install -m 0755 -o root -g root "$ROOT/scripts/server/write-as.sh" /opt/agenthub/libexec/write-as
install -m 0755 -o root -g root "$ROOT/scripts/server/pane-launch.sh" /opt/agenthub/libexec/pane-launch
# ── node + the hook handler, for members (host-owned: the dashboard refreshes them) ──
NODE_VER=$("$NODE" -p process.version)
install -d -m 0755 -o "$HOST" -g "$HOST" "/opt/agenthub/tools/node/$NODE_VER/bin"
install -m 0755 -o "$HOST" -g "$HOST" "$NODE" "/opt/agenthub/tools/node/$NODE_VER/bin/node"
ln -sfn "../node/$NODE_VER/bin/node" /opt/agenthub/tools/bin/node
for d in scripts server/lib; do
install -d -m 0755 -o "$HOST" -g "$HOST" "/opt/agenthub/app/$d"
cp -rP "$ROOT/$d/." "/opt/agenthub/app/$d/"
done
chown -R -h "$HOST:$HOST" /opt/agenthub/app /opt/agenthub/tools
chmod -R a+rX /opt/agenthub/app /opt/agenthub/tools
cat >/opt/agenthub/libexec/member-hook <<EOF
#!/bin/sh
# AgentHub: forwards a member's Claude Code hook to the dashboard (written by
# install-system). The host's own sessions report through the agenthub plugin.
[ "\$(id -u)" = "$HOST_UID" ] && exit 0
CLAUDE_DASHBOARD_PORT=$PORT exec /opt/agenthub/tools/bin/node /opt/agenthub/app/scripts/hook-handler.js "\$@"
EOF
chmod 0755 /opt/agenthub/libexec/member-hook
# ── sudoers: run-as members only (a fixed command list), and three root commands ──
TMP_SUDOERS=$(mktemp)
{
echo "# AgentHub server mode (install-system). Members are never in sudoers."
echo "Defaults:$HOST !requiretty"
echo "$HOST ALL=(%agenthub-members) NOPASSWD:SETENV: $TMUX_BIN, $GIT_BIN${GH_BIN:+, $GH_BIN}, /opt/agenthub/libexec/write-as, /opt/agenthub/tools/bin/claude"
echo "$HOST ALL=(root) NOPASSWD: /opt/agenthub/sbin/agenthub-provision"
for a in start stop restart enable disable; do
[ "$a" = start ] || [ "$a" = stop ] || [ "$a" = restart ] && extra=" --no-block" || extra=""
echo "$HOST ALL=(root) NOPASSWD: /usr/bin/systemctl $a$extra agenthub.service, /bin/systemctl $a$extra agenthub.service"
done
} >"$TMP_SUDOERS"
visudo -cf "$TMP_SUDOERS" >/dev/null || { rm -f "$TMP_SUDOERS"; die "generated sudoers did not validate"; }
install -m 0440 -o root -g root "$TMP_SUDOERS" "$SUDOERS"
rm -f "$TMP_SUDOERS"
# ── managed settings: the members' Claude Code hooks ────────────────────────
install -d -m 0755 /etc/claude-code
if [ -f "$MANAGED" ] && ! { [ -f "$MANAGED_MARK" ] && sha256sum -c --status "$MANAGED_MARK"; }; then
echo "install-system: $MANAGED exists and is not ours — left alone; add the member hooks by hand (docs/SERVER-MODE.md)" >&2
else
"$NODE" -e '
const ev = ["PreToolUse","PostToolUse","Stop","SubagentStop","Notification","SessionStart","SessionEnd","UserPromptSubmit"];
const hook = (e) => [{ ...(["PreToolUse","PostToolUse"].includes(e) ? { matcher: "*" } : {}),
hooks: [{ type: "command", command: `/opt/agenthub/libexec/member-hook ${e}`, timeout: 5 }] }];
process.stdout.write(JSON.stringify({ hooks: Object.fromEntries(ev.map((e) => [e, hook(e)])) }, null, 2) + "\n");
' >"$MANAGED"
chmod 0644 "$MANAGED"
sha256sum "$MANAGED" >"$MANAGED_MARK"
fi
# ── the host's own Claude data: no other account reads it ───────────────────
HOST_HOME=$(getent passwd "$HOST" | cut -d: -f6)
[ ! -d "$HOST_HOME/.claude" ] || chmod o-rwx "$HOST_HOME/.claude"
[ "$(( $(stat -c %a "$HOST_HOME") % 10 ))" = 0 ] ||
echo "install-system: $HOST_HOME is open to other accounts — chmod o-rwx it (the dashboard's database lives under it)" >&2
# ── hidepid: nobody sees another account's processes (argv carries tokens) ──
if ! grep -q "$FSTAB_TAG" /etc/fstab; then
cp /etc/fstab /etc/fstab.agenthub-bak
printf '%s\nproc /proc proc defaults,hidepid=invisible,gid=agenthub-proc 0 0\n' "$FSTAB_TAG" >>/etc/fstab
fi
mount -o remount,hidepid=invisible,gid="$(getent group agenthub-proc | cut -d: -f3)" /proc
# ── the system unit ──────────────────────────────────────────────────────────
cat >"$UNIT" <<EOF
[Unit]
# agenthub-managed-system-unit
Description=AgentHub dashboard server (server mode, one Linux user per account)
After=network.target
[Service]
Type=simple
User=$HOST
Group=$HOST
SupplementaryGroups=agenthub agenthub-proc
AmbientCapabilities=CAP_DAC_READ_SEARCH
# Not /etc/shadow: sudo (PAM) reads it, and every member action goes through sudo.
InaccessiblePaths=-/root
WorkingDirectory=~
EnvironmentFile=$DATA/runtime/agenthub.system.env
ExecStart=/usr/bin/env \${AGENTHUB_NODE} \${AGENTHUB_ROOT}/server/index.js
Restart=always
RestartSec=3
KillMode=process
[Install]
WantedBy=multi-user.target
EOF
chmod 0644 "$UNIT"
systemctl daemon-reload
echo "install-system: done (unit installed, not started)"
+27
View File
@@ -0,0 +1,27 @@
#!/bin/bash
# @file pane-launch.sh
# @description Server mode, one Linux user per account (S3): installed as
# /opt/agenthub/libexec/pane-launch. A member's pane starts as
# `pane-launch <launch file>`: the dashboard creates the session AS the member
# through sudo, and sudo logs its whole command line — so the pane's
# environment (a pane token, a pool account's token) and its argv (the prompt)
# are not in that command line but in the launch file, which `write-as`
# wrote as the member (0600) from stdin. This reads it, deletes it, exports
# the environment and execs the command; no shell ever parses a value.
#
# The file is NUL-separated: the number of environment entries, the
# NAME=value entries, then the argv (none = a login shell).
# @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
set -eu
f=${1:?usage: pane-launch <launch file>}
mapfile -d '' -t parts <"$f"
rm -f -- "$f"
n=${parts[0]}
[[ "$n" =~ ^[0-9]+$ ]] || { echo "pane-launch: malformed launch file" >&2; exit 2; }
for kv in "${parts[@]:1:n}"; do
[[ "$kv" =~ ^[A-Za-z_][A-Za-z0-9_]*= ]] || { echo "pane-launch: bad variable" >&2; exit 2; }
export "$kv"
done
argv=("${parts[@]:n+1}")
if [ ${#argv[@]} -eq 0 ]; then exec -l "${SHELL:-/bin/bash}"; fi
exec "${argv[@]}"
+129
View File
@@ -0,0 +1,129 @@
#!/bin/bash
# @file spike-managed-plugins.sh
# @description S3 plan, P6 spike — run BY HAND, as root, never by AgentHub:
# does /etc/claude-code/managed-settings.json alone (`extraKnownMarketplaces`
# + `enabledPlugins`) give a brand-new Linux user the admin's plugins, with no
# `claude plugin install` run as that user?
#
# sudo bash spike-managed-plugins.sh run <fragment.json> [seconds]
# sudo bash spike-managed-plugins.sh undo
#
# run: backs up managed-settings.json (once), merges the fragment's two keys
# into it, makes the throwaway user agh-spike (NOT an AgentHub member),
# seeds its onboarding like agenthub-provision, lists its marketplaces
# and plugins, starts one interactive `claude` in a private tmux socket
# for [seconds] (default 45), lists again, and prints a verdict per
# enabled plugin. Everything lands in /var/tmp/agenthub-spike-managed/.
# undo: puts managed-settings.json back exactly (or removes it when there was
# none), ends agh-spike's processes and deletes that user and its home.
#
# While it is applied, EVERY Claude Code on this machine (the host's own
# sessions too) reads the merged file. Run it when that is acceptable and
# undo right after. It never touches /etc/agenthub or an ah-* account.
# @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
set -euo pipefail
die() { echo "spike: $*" >&2; exit 1; }
[ "$(id -u)" = 0 ] || die "run as root (sudo bash $0 …)"
MANAGED=/etc/claude-code/managed-settings.json
BACKUP=$MANAGED.agenthub-spike-bak
NONE=$MANAGED.agenthub-spike-none # marker: there was no file before
OUT=/var/tmp/agenthub-spike-managed
U=agh-spike
CLAUDE=/opt/agenthub/tools/bin/claude
NODE=/opt/agenthub/tools/bin/node
as_user() {
local h
h=$(getent passwd "$U" | cut -d: -f6)
(cd / && runuser -u "$U" -- env -i HOME="$h" USER="$U" LOGNAME="$U" TERM=xterm-256color \
PATH=/opt/agenthub/tools/bin:/usr/local/bin:/usr/bin:/bin DISABLE_AUTOUPDATER=1 "$@")
}
case "${1:-}" in
run)
FRAG=${2:-}
SECS=${3:-45}
[ -f "$FRAG" ] || die "usage: $0 run <fragment.json> [seconds]"
[[ "$SECS" =~ ^[0-9]+$ ]] || die "seconds: a number"
[ -x "$CLAUDE" ] && [ -x "$NODE" ] || die "$CLAUDE or $NODE missing (agenthub server install-system)"
! id "$U" >/dev/null 2>&1 || die "$U already exists — run '$0 undo' first"
install -d -m 0700 "$OUT"
# 1. Back up once; a second run never overwrites the first backup.
install -d -m 0755 /etc/claude-code
if [ ! -e "$BACKUP" ] && [ ! -e "$NONE" ]; then
if [ -f "$MANAGED" ]; then cp -p "$MANAGED" "$BACKUP"; else touch "$NONE"; fi
fi
# 2. Merge only the two keys under test; everything else (the member hooks) stays.
"$NODE" -e '
const fs = require("fs"), [file, frag] = process.argv.slice(1);
let d = {};
try { d = JSON.parse(fs.readFileSync(file, "utf8")); } catch (e) { if (e.code !== "ENOENT") throw e; }
const f = JSON.parse(fs.readFileSync(frag, "utf8"));
for (const k of ["extraKnownMarketplaces", "enabledPlugins"]) if (f[k]) d[k] = { ...(d[k] || {}), ...f[k] };
fs.writeFileSync(file, JSON.stringify(d, null, 2) + "\n", { mode: 0o644 });
' "$MANAGED" "$FRAG"
cp "$MANAGED" "$OUT/managed-settings.applied.json"
# 3. A fresh account, first run seeded like agenthub-provision does.
useradd --create-home --shell /bin/bash --comment "AgentHub P6 spike (delete me)" "$U"
as_user "$NODE" -e '
const fs = require("fs"), home = process.env.HOME, p = home + "/.claude.json";
fs.writeFileSync(p, JSON.stringify({ hasCompletedOnboarding: true,
projects: { [home]: { hasTrustDialogAccepted: true } } }, null, 2) + "\n", { mode: 0o600 });'
as_user "$CLAUDE" --version >"$OUT/claude-version.txt" 2>&1 || true
# 4. Before any session: does the CLI alone see them?
as_user "$CLAUDE" plugin marketplace list --json >"$OUT/marketplaces.before.json" 2>&1 || true
as_user "$CLAUDE" plugin list --json >"$OUT/plugins.before.json" 2>&1 || true
# 5. One interactive start (managed marketplaces may sync only there). A
# private socket; kill-session only — never kill-server.
as_user tmux -L agh-spike new-session -d -s spike -x 200 -y 50 "$CLAUDE"
sleep "$SECS"
as_user tmux -L agh-spike capture-pane -p -t spike >"$OUT/session-screen.txt" 2>&1 || true
as_user tmux -L agh-spike kill-session -t spike 2>/dev/null || true
# 6. After.
as_user "$CLAUDE" plugin marketplace list --json >"$OUT/marketplaces.after.json" 2>&1 || true
as_user "$CLAUDE" plugin list --json >"$OUT/plugins.after.json" 2>&1 || true
find "$(getent passwd "$U" | cut -d: -f6)/.claude" -maxdepth 3 >"$OUT/claude-dir.txt" 2>&1 || true
# 7. Verdict per plugin the fragment enables.
"$NODE" -e '
const fs = require("fs"), [frag, out] = process.argv.slice(1);
const want = Object.keys(JSON.parse(fs.readFileSync(frag, "utf8")).enabledPlugins || {});
const read = (f) => fs.readFileSync(`${out}/${f}`, "utf8");
for (const p of want) {
const at = (f) => (read(f).includes(p) ? "listed" : "absent");
console.log(`${p}: before session ${at("plugins.before.json")}, after ${at("plugins.after.json")}`);
}
' "$FRAG" "$OUT" | tee "$OUT/verdict.txt"
echo "spike: details in $OUT; the screen it showed: $OUT/session-screen.txt"
echo "spike: now run '$0 undo'"
;;
undo)
if [ -e "$BACKUP" ]; then
mv -f "$BACKUP" "$MANAGED"
elif [ -e "$NONE" ]; then
rm -f "$MANAGED" "$NONE"
else
echo "spike: no backup — managed-settings.json left as it is"
fi
if id "$U" >/dev/null 2>&1; then
pkill -KILL -u "$U" 2>/dev/null || true
userdel --remove "$U" 2>/dev/null || userdel "$U"
fi
# The install-system mark matches again only if the file is back byte for byte.
if [ -f /etc/agenthub/managed-settings.sha256 ]; then
sha256sum -c --status /etc/agenthub/managed-settings.sha256 &&
echo "spike: managed-settings.json matches install-system's mark again" ||
echo "spike: WARNING managed-settings.json differs from install-system's mark" >&2
fi
echo "spike: undone (the report in $OUT is kept)"
;;
*) die "usage: $0 run <fragment.json> [seconds] | undo" ;;
esac
+41
View File
@@ -0,0 +1,41 @@
#!/bin/sh
# @file write-as.sh
# @description Server mode, one Linux user per account (S3): installed as
# /opt/agenthub/libexec/write-as. The dashboard runs it AS a member through
# sudo (`sudo -n -u ah-<name> /opt/agenthub/libexec/write-as <path> [mode]`)
# for every change it makes inside that member's home — the file editor, a
# `.gitignore` line, the files a pane reads (prompt, proxy settings, context
# copies, drops, a session's launch file) — because the dashboard itself may
# never write, chmod or delete there: the member can swap any name in their
# own home, and a write by another account would follow the swap. As the
# member this can only do what the member could. Content comes on stdin,
# never in argv (sudo logs argv). Modes:
# (none) stdin into the file in place (keeps its mode and owner)
# append stdin appended
# replace stdin into a new 0644 file renamed over the path (atomic)
# private same, 0600
# mkdir the directory, 0755, parents included
# remove the path, recursively (rm never follows a symlink it removes)
# @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
set -eu
if [ $# -lt 1 ] || [ $# -gt 2 ]; then
echo "usage: write-as <absolute path> [append|replace|private|mkdir|remove]" >&2
exit 2
fi
case "$1" in
/*) ;;
*) echo "write-as: an absolute path is required" >&2; exit 2 ;;
esac
swap() {
t=$(mktemp "$1.XXXXXX")
cat >"$t" && chmod "$2" "$t" && mv -f "$t" "$1" || { rm -f "$t"; exit 1; }
}
case "${2:-}" in
"") exec cat >"$1" ;;
append) exec cat >>"$1" ;;
replace) swap "$1" 0644 ;;
private) swap "$1" 0600 ;;
mkdir) exec mkdir -p -m 0755 -- "$1" ;;
remove) exec rm -rf -- "$1" ;;
*) echo "write-as: unknown mode: $2" >&2; exit 2 ;;
esac
+11 -3
View File
@@ -4,9 +4,17 @@
* @description Picks the per-user service manager that keeps the dashboard
* running without Claude Code: a LaunchAgent on macOS (`launchd-service.js`),
* a systemd `--user` unit everywhere else (`systemd-service.js`, which reports
* itself unavailable off Linux). Both export the same API.
* itself unavailable off Linux). Both export the same API — and so does the
* third, `systemd-system-service.js`, chosen while the server-mode SYSTEM unit
* `sudo agenthub server install-system` wrote exists (pure fs check).
* Require THIS module, never one of the three directly.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
module.exports =
process.platform === "darwin" ? require("./launchd-service") : require("./systemd-service");
function pick() {
if (process.platform === "darwin") return require("./launchd-service");
const system = require("./systemd-system-service");
return system.unitIsOurs("agenthub") ? system : require("./systemd-service");
}
module.exports = pick();
+201
View File
@@ -0,0 +1,201 @@
#!/usr/bin/env node
/**
* @file systemd-system-service.js
* @description Server mode with one Linux user per account (S3): the dashboard
* runs as a SYSTEM unit, `/etc/systemd/system/agenthub.service`, written once
* by `sudo agenthub server install-system` (scripts/server/install-system.sh).
* The unit runs as the host account with group `agenthub` and
* CAP_DAC_READ_SEARCH — the two things a `systemd --user` unit cannot give.
*
* Same API as `systemd-service.js`, so the bootstrap, Quit, the autostart
* switch and the doctor keep their rules unchanged (`service-manager.js`
* picks this module while that unit exists and is ours). The difference is who
* may write what:
* - the unit file is root's and never rewritten here. It reads the plugin
* root, node and env from `<runtime dir>/agenthub.system.env`, a file the
* HOST owns — so `writeUserService` (the bootstrap's repoint after a plugin
* update) is a plain file write, no root;
* - start, stop, restart, enable and disable go through
* `sudo -n systemctl …`, allowed for exactly those commands by the
* sudoers file the installer wrote. `--no-block` everywhere it matters,
* for the same cgroup reason as the user unit (CLAUDE.md).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("fs");
const path = require("path");
const { spawnSync } = require("child_process");
const MARKER = "# agenthub-managed-system-unit";
const UNIT_DIR = "/etc/systemd/system";
function unitPath(name) {
return path.join(process.env.AGENTHUB_SYSTEM_UNIT_DIR || UNIT_DIR, `${name}.service`);
}
/** The host-owned env file the unit's `EnvironmentFile=` names. */
function envPath() {
const { getDataDir } = require("../server/lib/claude-home");
return path.join(getDataDir(), "runtime", "agenthub.system.env");
}
// Test seams: `systemctl` itself (reads) and `sudo -n systemctl` (writes).
const defaultExec = (args, opts) => spawnSync("systemctl", args, opts);
const defaultSudo = (args, opts) => spawnSync("sudo", ["-n", "systemctl", ...args], opts);
let execImpl = defaultExec;
let sudoImpl = defaultSudo;
function __setExecImpl(fn, sudoFn) {
execImpl = fn;
sudoImpl = sudoFn || fn;
}
function __reset() {
execImpl = defaultExec;
sudoImpl = defaultSudo;
}
/** Is there a system unit by this name, written by the installer? Pure fs. */
function unitIsOurs(name) {
try {
return fs.readFileSync(unitPath(name), "utf8").includes(MARKER);
} catch {
return false;
}
}
function systemctlAvailable() {
if (process.platform !== "linux" || !unitIsOurs("agenthub")) return false;
return execImpl(["--version"], { stdio: "ignore" }).status === 0;
}
function readEnv() {
try {
return Object.fromEntries(
fs
.readFileSync(envPath(), "utf8")
.split("\n")
.map((l) => /^([A-Z_][A-Z0-9_]*)=(.*)$/.exec(l))
.filter(Boolean)
.map((m) => [m[1], m[2]])
);
} catch {
return null;
}
}
/** The plugin root the unit runs from (env file), or null. Pure fs. */
function unitRoot(name) {
if (!fs.existsSync(unitPath(name))) return null;
return readEnv()?.AGENTHUB_ROOT || null;
}
/** Does the env file still name `root`? Pure fs, like the user unit's. */
function unitIsStale(name, root) {
if (!unitIsOurs(name)) return false;
return unitRoot(name) !== root;
}
function unitIsEnabled(name) {
if (!systemctlAvailable()) return false;
const res = execImpl(["is-enabled", `${name}.service`], { encoding: "utf8", stdio: "pipe" });
return String(res.stdout || "").trim() === "enabled";
}
function unitIsActive(name) {
if (!systemctlAvailable()) return false;
const res = execImpl(["is-active", `${name}.service`], { encoding: "utf8", stdio: "pipe" });
return String(res.stdout || "").trim() === "active";
}
function setUnitEnabled(name, on) {
if (!systemctlAvailable()) return "unavailable";
const res = sudoImpl([on ? "enable" : "disable", `${name}.service`], { stdio: "ignore" });
return res.status === 0 ? "ok" : "failed";
}
/**
* Point the unit at this plugin version: rewrite the host-owned env file the
* unit reads. Starts nothing, enables nothing (the unit file is root's).
* @returns {"written"|"unchanged"|"unavailable"|"failed"}
*/
function writeUserService({ argv, env = {} }) {
if (!systemctlAvailable()) return "unavailable";
const root = path.dirname(path.dirname(argv[1])); // <root>/server/index.js
// The port install-system chose outlives a bootstrap that does not pass one
// (server.conf and the member hook still name it).
// ponytail: only DASHBOARD_PORT is carried over; others if they ever matter.
let kept = {};
try {
const m = /^DASHBOARD_PORT=(\d+)$/m.exec(fs.readFileSync(envPath(), "utf8"));
if (m) kept = { DASHBOARD_PORT: m[1] };
} catch {
/* no file yet */
}
const body =
Object.entries({ AGENTHUB_NODE: argv[0], AGENTHUB_ROOT: root, ...kept, ...env })
.map(([k, v]) => `${k}=${String(v).replace(/\n/g, "")}`)
.join("\n") + "\n";
try {
const file = envPath();
if (fs.existsSync(file) && fs.readFileSync(file, "utf8") === body) return "unchanged";
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(`${file}.tmp`, body, { mode: 0o600 });
fs.renameSync(`${file}.tmp`, file);
return "written";
} catch {
return "failed";
}
}
/** `--no-block`: the caller may sit in this unit's cgroup (CLAUDE.md). */
function restartUserService(name) {
if (!systemctlAvailable()) return "unavailable";
const res = sudoImpl(["restart", "--no-block", `${name}.service`], { stdio: "ignore" });
return res.status === 0 ? "restarted" : "failed";
}
/** Point the unit here, then start it (without blocking). */
function installUserService(opts) {
const result = writeUserService(opts);
if (result === "written" || result === "unchanged") {
sudoImpl(["start", "--no-block", `${opts.name}.service`], { stdio: "ignore" });
}
return result;
}
/** Removing the system unit is root's: `sudo agenthub server install-system --uninstall`. */
function uninstallUserService() {
return "foreign";
}
/** A system unit starts at boot; there is no linger to enable. */
function enableLinger() {
return true;
}
/** The argv that stops the unit without waiting (Quit, server/lib/quit.js). */
function stopArgv(name) {
return ["sudo", ["-n", "systemctl", "stop", "--no-block", `${name}.service`]];
}
module.exports = {
kind: "systemd-system",
label: "systemd system service",
MARKER,
systemctlAvailable,
unitIsOurs,
unitIsStale,
unitRoot,
unitIsActive,
unitIsEnabled,
setUnitEnabled,
writeUserService,
restartUserService,
installUserService,
uninstallUserService,
enableLinger,
stopArgv,
unitPath,
envPath,
__setExecImpl,
__reset,
};
+5
View File
@@ -50,6 +50,11 @@ const env = {
GROK_HOME: tmuxDir,
KIMI_CODE_HOME: tmuxDir,
DASHBOARD_PIPELINES_DIR: path.join(dir, "fixtures", "pipelines"),
// Never this machine's server-mode S3 install: its system unit would become
// "the" service manager and its server.conf would send user changes to sudo.
AGENTHUB_SYSTEM_UNIT_DIR: path.join(tmuxDir, "system-units"),
AGENTHUB_SERVER_CONF: path.join(tmuxDir, "server.conf"),
AGENTHUB_PASSWD: path.join(tmuxDir, "passwd"),
};
delete env.TMUX;
delete env.TMUX_PANE;