#!/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 [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ĩ 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 [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 [seconds] | undo" ;; esac