Files
AgentHub/scripts/server/install-system.sh
T
nntrivi2001 c83ddf62a0 feat(server-s3): a member's tmux follows tmux upgrades — tmux-member + Users → Members' tmux
A member's tmux server keeps the version it started with until their last
pane closes, but the dashboard asked it with TMUX_BIN, the newest tmux: a
3.8 client on a 3.6 server spun it at 100% CPU (three members needed a
manual kill on 2026-10-15).

- /opt/agenthub/libexec/tmux-member (install-system, in sudoers): runs a
  member's tmux call with the binary of THEIR running server
  (/proc/<pid>/exe, found from the socket in /proc/net/unix), else
  TMUX_BIN. It runs as the member and execs only a process they own.
  sudoArgv uses it once installed (linux-accounts readConf), TMUX_BIN
  until then.
- Users → Members' tmux (GET /api/users/tmux): each member server's version
  beside the installed one, sessions, agents working. An admin restarts one
  (POST /api/users/:id/tmux-restart) or every idle outdated one
  (POST /api/users/tmux-restart-idle), with their password again, audited
  per member; a server that will not stop answers 409 ETMUXSTUCK naming
  its pid for root.
- server-s3-system.test.js: `delete` is a provision action since Delete
  user, so the "unknown action" case now uses `purge`.

Operators: run `sudo agenthub server install-system` once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 14:44:47 +07:00

213 lines
11 KiB
Bash

#!/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
# /opt/agenthub/libexec/tmux-member a member's tmux, with their running server's own binary
# /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
install -m 0755 -o root -g root "$ROOT/scripts/server/tmux-member.sh" /opt/agenthub/libexec/tmux-member
# ── 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)
# `!requiretty` matters only where a distro turns requiretty ON (older RHEL);
# sudo-rs (Ubuntu 25.10+) has no such setting and rejects the line, so it is
# written only when this machine's visudo accepts it.
NOTTY=""
printf 'Defaults:%s !requiretty\n' "$HOST" >"$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, /opt/agenthub/libexec/tmux-member, $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" <<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)"