#!/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 Linux user ah-, home / # agenthub-provision disable lock the account and end its processes # agenthub-provision enable 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ĩ 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 " 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-" 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