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:
2026-10-09 14:57:10 +07:00
parent b7f3735b29
commit 84e2a9f0ac
123 changed files with 12051 additions and 605 deletions
+88
View File
@@ -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
+201
View File
@@ -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)"
+27
View File
@@ -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[@]}"
+129
View File
@@ -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
+41
View File
@@ -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