c83ddf62a0
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>
213 lines
11 KiB
Bash
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)"
|