Files
AgentHub/scripts/server/agenthub-provision.sh
T
nntrivi2001 84e2a9f0ac 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>
2026-10-09 14:57:10 +07:00

89 lines
4.2 KiB
Bash

#!/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