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:
+54
-29
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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)"
|
||||
@@ -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[@]}"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user