#!/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ĩ 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 </opt/agenthub/libexec/member-hook <"$TMP_SUDOERS" visudo -cf "$TMP_SUDOERS" >/dev/null 2>&1 && NOTTY="Defaults:$HOST !requiretty" { echo "# AgentHub server mode (install-system). Members are never in sudoers." [ -z "$NOTTY" ] || echo "$NOTTY" 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"]; // Per-tool-call hooks run in the background (async): a synchronous one // makes Claude Code wait a Node start (0.1-0.2 s) before and after every call. const hook = (e) => [{ ...(["PreToolUse","PostToolUse"].includes(e) ? { matcher: "*" } : {}), hooks: [{ type: "command", command: `/opt/agenthub/libexec/member-hook ${e}`, timeout: 5, ...(["PreToolUse","PostToolUse"].includes(e) ? { async: true } : {}) }] }]; 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" <