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>
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
#!/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
|
||||
@@ -0,0 +1,201 @@
|
||||
#!/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ĩ <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
|
||||
|
||||
# ── 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)
|
||||
{
|
||||
echo "# AgentHub server mode (install-system). Members are never in sudoers."
|
||||
echo "Defaults:$HOST !requiretty"
|
||||
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"];
|
||||
const hook = (e) => [{ ...(["PreToolUse","PostToolUse"].includes(e) ? { matcher: "*" } : {}),
|
||||
hooks: [{ type: "command", command: `/opt/agenthub/libexec/member-hook ${e}`, timeout: 5 }] }];
|
||||
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)"
|
||||
@@ -0,0 +1,27 @@
|
||||
#!/bin/bash
|
||||
# @file pane-launch.sh
|
||||
# @description Server mode, one Linux user per account (S3): installed as
|
||||
# /opt/agenthub/libexec/pane-launch. A member's pane starts as
|
||||
# `pane-launch <launch file>`: the dashboard creates the session AS the member
|
||||
# through sudo, and sudo logs its whole command line — so the pane's
|
||||
# environment (a pane token, a pool account's token) and its argv (the prompt)
|
||||
# are not in that command line but in the launch file, which `write-as`
|
||||
# wrote as the member (0600) from stdin. This reads it, deletes it, exports
|
||||
# the environment and execs the command; no shell ever parses a value.
|
||||
#
|
||||
# The file is NUL-separated: the number of environment entries, the
|
||||
# NAME=value entries, then the argv (none = a login shell).
|
||||
# @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
set -eu
|
||||
f=${1:?usage: pane-launch <launch file>}
|
||||
mapfile -d '' -t parts <"$f"
|
||||
rm -f -- "$f"
|
||||
n=${parts[0]}
|
||||
[[ "$n" =~ ^[0-9]+$ ]] || { echo "pane-launch: malformed launch file" >&2; exit 2; }
|
||||
for kv in "${parts[@]:1:n}"; do
|
||||
[[ "$kv" =~ ^[A-Za-z_][A-Za-z0-9_]*= ]] || { echo "pane-launch: bad variable" >&2; exit 2; }
|
||||
export "$kv"
|
||||
done
|
||||
argv=("${parts[@]:n+1}")
|
||||
if [ ${#argv[@]} -eq 0 ]; then exec -l "${SHELL:-/bin/bash}"; fi
|
||||
exec "${argv[@]}"
|
||||
@@ -0,0 +1,129 @@
|
||||
#!/bin/bash
|
||||
# @file spike-managed-plugins.sh
|
||||
# @description S3 plan, P6 spike — run BY HAND, as root, never by AgentHub:
|
||||
# does /etc/claude-code/managed-settings.json alone (`extraKnownMarketplaces`
|
||||
# + `enabledPlugins`) give a brand-new Linux user the admin's plugins, with no
|
||||
# `claude plugin install` run as that user?
|
||||
#
|
||||
# sudo bash spike-managed-plugins.sh run <fragment.json> [seconds]
|
||||
# sudo bash spike-managed-plugins.sh undo
|
||||
#
|
||||
# run: backs up managed-settings.json (once), merges the fragment's two keys
|
||||
# into it, makes the throwaway user agh-spike (NOT an AgentHub member),
|
||||
# seeds its onboarding like agenthub-provision, lists its marketplaces
|
||||
# and plugins, starts one interactive `claude` in a private tmux socket
|
||||
# for [seconds] (default 45), lists again, and prints a verdict per
|
||||
# enabled plugin. Everything lands in /var/tmp/agenthub-spike-managed/.
|
||||
# undo: puts managed-settings.json back exactly (or removes it when there was
|
||||
# none), ends agh-spike's processes and deletes that user and its home.
|
||||
#
|
||||
# While it is applied, EVERY Claude Code on this machine (the host's own
|
||||
# sessions too) reads the merged file. Run it when that is acceptable and
|
||||
# undo right after. It never touches /etc/agenthub or an ah-* account.
|
||||
# @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
set -euo pipefail
|
||||
|
||||
die() { echo "spike: $*" >&2; exit 1; }
|
||||
[ "$(id -u)" = 0 ] || die "run as root (sudo bash $0 …)"
|
||||
|
||||
MANAGED=/etc/claude-code/managed-settings.json
|
||||
BACKUP=$MANAGED.agenthub-spike-bak
|
||||
NONE=$MANAGED.agenthub-spike-none # marker: there was no file before
|
||||
OUT=/var/tmp/agenthub-spike-managed
|
||||
U=agh-spike
|
||||
CLAUDE=/opt/agenthub/tools/bin/claude
|
||||
NODE=/opt/agenthub/tools/bin/node
|
||||
|
||||
as_user() {
|
||||
local h
|
||||
h=$(getent passwd "$U" | cut -d: -f6)
|
||||
(cd / && runuser -u "$U" -- env -i HOME="$h" USER="$U" LOGNAME="$U" TERM=xterm-256color \
|
||||
PATH=/opt/agenthub/tools/bin:/usr/local/bin:/usr/bin:/bin DISABLE_AUTOUPDATER=1 "$@")
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
run)
|
||||
FRAG=${2:-}
|
||||
SECS=${3:-45}
|
||||
[ -f "$FRAG" ] || die "usage: $0 run <fragment.json> [seconds]"
|
||||
[[ "$SECS" =~ ^[0-9]+$ ]] || die "seconds: a number"
|
||||
[ -x "$CLAUDE" ] && [ -x "$NODE" ] || die "$CLAUDE or $NODE missing (agenthub server install-system)"
|
||||
! id "$U" >/dev/null 2>&1 || die "$U already exists — run '$0 undo' first"
|
||||
install -d -m 0700 "$OUT"
|
||||
|
||||
# 1. Back up once; a second run never overwrites the first backup.
|
||||
install -d -m 0755 /etc/claude-code
|
||||
if [ ! -e "$BACKUP" ] && [ ! -e "$NONE" ]; then
|
||||
if [ -f "$MANAGED" ]; then cp -p "$MANAGED" "$BACKUP"; else touch "$NONE"; fi
|
||||
fi
|
||||
|
||||
# 2. Merge only the two keys under test; everything else (the member hooks) stays.
|
||||
"$NODE" -e '
|
||||
const fs = require("fs"), [file, frag] = process.argv.slice(1);
|
||||
let d = {};
|
||||
try { d = JSON.parse(fs.readFileSync(file, "utf8")); } catch (e) { if (e.code !== "ENOENT") throw e; }
|
||||
const f = JSON.parse(fs.readFileSync(frag, "utf8"));
|
||||
for (const k of ["extraKnownMarketplaces", "enabledPlugins"]) if (f[k]) d[k] = { ...(d[k] || {}), ...f[k] };
|
||||
fs.writeFileSync(file, JSON.stringify(d, null, 2) + "\n", { mode: 0o644 });
|
||||
' "$MANAGED" "$FRAG"
|
||||
cp "$MANAGED" "$OUT/managed-settings.applied.json"
|
||||
|
||||
# 3. A fresh account, first run seeded like agenthub-provision does.
|
||||
useradd --create-home --shell /bin/bash --comment "AgentHub P6 spike (delete me)" "$U"
|
||||
as_user "$NODE" -e '
|
||||
const fs = require("fs"), home = process.env.HOME, p = home + "/.claude.json";
|
||||
fs.writeFileSync(p, JSON.stringify({ hasCompletedOnboarding: true,
|
||||
projects: { [home]: { hasTrustDialogAccepted: true } } }, null, 2) + "\n", { mode: 0o600 });'
|
||||
as_user "$CLAUDE" --version >"$OUT/claude-version.txt" 2>&1 || true
|
||||
|
||||
# 4. Before any session: does the CLI alone see them?
|
||||
as_user "$CLAUDE" plugin marketplace list --json >"$OUT/marketplaces.before.json" 2>&1 || true
|
||||
as_user "$CLAUDE" plugin list --json >"$OUT/plugins.before.json" 2>&1 || true
|
||||
|
||||
# 5. One interactive start (managed marketplaces may sync only there). A
|
||||
# private socket; kill-session only — never kill-server.
|
||||
as_user tmux -L agh-spike new-session -d -s spike -x 200 -y 50 "$CLAUDE"
|
||||
sleep "$SECS"
|
||||
as_user tmux -L agh-spike capture-pane -p -t spike >"$OUT/session-screen.txt" 2>&1 || true
|
||||
as_user tmux -L agh-spike kill-session -t spike 2>/dev/null || true
|
||||
|
||||
# 6. After.
|
||||
as_user "$CLAUDE" plugin marketplace list --json >"$OUT/marketplaces.after.json" 2>&1 || true
|
||||
as_user "$CLAUDE" plugin list --json >"$OUT/plugins.after.json" 2>&1 || true
|
||||
find "$(getent passwd "$U" | cut -d: -f6)/.claude" -maxdepth 3 >"$OUT/claude-dir.txt" 2>&1 || true
|
||||
|
||||
# 7. Verdict per plugin the fragment enables.
|
||||
"$NODE" -e '
|
||||
const fs = require("fs"), [frag, out] = process.argv.slice(1);
|
||||
const want = Object.keys(JSON.parse(fs.readFileSync(frag, "utf8")).enabledPlugins || {});
|
||||
const read = (f) => fs.readFileSync(`${out}/${f}`, "utf8");
|
||||
for (const p of want) {
|
||||
const at = (f) => (read(f).includes(p) ? "listed" : "absent");
|
||||
console.log(`${p}: before session ${at("plugins.before.json")}, after ${at("plugins.after.json")}`);
|
||||
}
|
||||
' "$FRAG" "$OUT" | tee "$OUT/verdict.txt"
|
||||
echo "spike: details in $OUT; the screen it showed: $OUT/session-screen.txt"
|
||||
echo "spike: now run '$0 undo'"
|
||||
;;
|
||||
undo)
|
||||
if [ -e "$BACKUP" ]; then
|
||||
mv -f "$BACKUP" "$MANAGED"
|
||||
elif [ -e "$NONE" ]; then
|
||||
rm -f "$MANAGED" "$NONE"
|
||||
else
|
||||
echo "spike: no backup — managed-settings.json left as it is"
|
||||
fi
|
||||
if id "$U" >/dev/null 2>&1; then
|
||||
pkill -KILL -u "$U" 2>/dev/null || true
|
||||
userdel --remove "$U" 2>/dev/null || userdel "$U"
|
||||
fi
|
||||
# The install-system mark matches again only if the file is back byte for byte.
|
||||
if [ -f /etc/agenthub/managed-settings.sha256 ]; then
|
||||
sha256sum -c --status /etc/agenthub/managed-settings.sha256 &&
|
||||
echo "spike: managed-settings.json matches install-system's mark again" ||
|
||||
echo "spike: WARNING managed-settings.json differs from install-system's mark" >&2
|
||||
fi
|
||||
echo "spike: undone (the report in $OUT is kept)"
|
||||
;;
|
||||
*) die "usage: $0 run <fragment.json> [seconds] | undo" ;;
|
||||
esac
|
||||
@@ -0,0 +1,41 @@
|
||||
#!/bin/sh
|
||||
# @file write-as.sh
|
||||
# @description Server mode, one Linux user per account (S3): installed as
|
||||
# /opt/agenthub/libexec/write-as. The dashboard runs it AS a member through
|
||||
# sudo (`sudo -n -u ah-<name> /opt/agenthub/libexec/write-as <path> [mode]`)
|
||||
# for every change it makes inside that member's home — the file editor, a
|
||||
# `.gitignore` line, the files a pane reads (prompt, proxy settings, context
|
||||
# copies, drops, a session's launch file) — because the dashboard itself may
|
||||
# never write, chmod or delete there: the member can swap any name in their
|
||||
# own home, and a write by another account would follow the swap. As the
|
||||
# member this can only do what the member could. Content comes on stdin,
|
||||
# never in argv (sudo logs argv). Modes:
|
||||
# (none) stdin into the file in place (keeps its mode and owner)
|
||||
# append stdin appended
|
||||
# replace stdin into a new 0644 file renamed over the path (atomic)
|
||||
# private same, 0600
|
||||
# mkdir the directory, 0755, parents included
|
||||
# remove the path, recursively (rm never follows a symlink it removes)
|
||||
# @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
set -eu
|
||||
if [ $# -lt 1 ] || [ $# -gt 2 ]; then
|
||||
echo "usage: write-as <absolute path> [append|replace|private|mkdir|remove]" >&2
|
||||
exit 2
|
||||
fi
|
||||
case "$1" in
|
||||
/*) ;;
|
||||
*) echo "write-as: an absolute path is required" >&2; exit 2 ;;
|
||||
esac
|
||||
swap() {
|
||||
t=$(mktemp "$1.XXXXXX")
|
||||
cat >"$t" && chmod "$2" "$t" && mv -f "$t" "$1" || { rm -f "$t"; exit 1; }
|
||||
}
|
||||
case "${2:-}" in
|
||||
"") exec cat >"$1" ;;
|
||||
append) exec cat >>"$1" ;;
|
||||
replace) swap "$1" 0644 ;;
|
||||
private) swap "$1" 0600 ;;
|
||||
mkdir) exec mkdir -p -m 0755 -- "$1" ;;
|
||||
remove) exec rm -rf -- "$1" ;;
|
||||
*) echo "write-as: unknown mode: $2" >&2; exit 2 ;;
|
||||
esac
|
||||
Reference in New Issue
Block a user