22de5009ff
- Artifacts (/artifacts): claude.ai pages sessions publish (captured from the Artifact tool's hook), pinned workspace files, skills and plugins, each kept as a snapshot; shared ones appear in a Hub and a skill or plugin installs into the viewer's own account (server/lib/artifacts.js, /api/artifacts, docs/plans/artifacts/). - Workspace extra folders: clone one or more related repos beside the workspace and add them in one step (POST /api/lanes/:id/sources/clone). - A pane that ended by itself while tmux kept running is no longer offered for resume (tmux.serverStartedAt in reconcileOrphans). - Changes panel: a folder that is not a git repo opens on Files, and its files open from there. - Plus the parallel sessions' work in this tree: File Explorer button in the panel header, user delete, RSA SSH keys, chat/terminal and pet fixes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
119 lines
5.8 KiB
Bash
119 lines
5.8 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
|
|
# agenthub-provision delete <name> end its processes, remove the account
|
|
# and its home <USERS_ROOT>/<name>
|
|
#
|
|
# Nothing but an action and a name comes from the caller; every path comes
|
|
# from /etc/agenthub/server.conf (root's). It deletes only on `delete` (an
|
|
# admin's step-up-confirmed "Delete user" on the Users page): only an AgentHub
|
|
# member's account, and only the home at exactly <USERS_ROOT>/<name> — a real
|
|
# directory, never a link, removed without following links or leaving its
|
|
# filesystem. It 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|delete <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"
|
|
;;
|
|
delete)
|
|
if id "$LOGIN" >/dev/null 2>&1; then
|
|
is_member || die "$LOGIN is not an AgentHub member — refusing to delete it" 4
|
|
# Locked first, so nothing new starts while its processes are ended.
|
|
usermod --lock --expiredate 1 "$LOGIN"
|
|
for _ in 1 2 3; do
|
|
pkill -KILL -u "$LOGIN" 2>/dev/null || true
|
|
pgrep -u "$LOGIN" >/dev/null 2>&1 || break
|
|
sleep 1
|
|
done
|
|
userdel "$LOGIN"
|
|
# Its own user group, if userdel left it and nobody else is in it.
|
|
if getent group "$LOGIN" >/dev/null 2>&1; then groupdel "$LOGIN" 2>/dev/null || true; fi
|
|
fi
|
|
if [ -e "$HOME_DIR" ] || [ -L "$HOME_DIR" ]; then
|
|
# Exactly <USERS_ROOT>/<name>: a real directory sitting directly in the
|
|
# (root-owned) users root — never a link a member planted elsewhere.
|
|
[ -d "$HOME_DIR" ] && [ ! -L "$HOME_DIR" ] || die "$HOME_DIR is not a plain directory" 5
|
|
ROOT_REAL=$(realpath -e "$USERS_ROOT")
|
|
[ "$(realpath -e "$HOME_DIR")" = "$ROOT_REAL/$NAME" ] || die "$HOME_DIR resolves elsewhere" 5
|
|
# rm never follows a link it removes; --one-file-system stays on this disk.
|
|
rm -rf --one-file-system -- "$HOME_DIR"
|
|
fi
|
|
echo "deleted $LOGIN $HOME_DIR"
|
|
;;
|
|
*) die "unknown action: $ACTION" 2 ;;
|
|
esac
|