/** * @file server-s3-provision.test.js * @description S3 plan, P6, without root: (1) `agenthub-provision create` * seeds the member's `~/.claude.json` (onboarding done, home trusted, merged, * a non-JSON file left alone) — the real script, run with stub `id`, * `useradd`, `chown` and `runuser` first on PATH and its two fixed system * paths pointed at a temp dir; (2) the opt-in plugin baseline runs * `claude plugin marketplace add|install` AS the member through sudo (played * by fixtures/fake-sudo.js), skips entries that could be read as options, and * starts by itself after a user is created. * @author Nguyễn Ngọc Trí Vĩ */ const { describe, it, before, after, beforeEach } = require("node:test"); const assert = require("node:assert/strict"); const path = require("node:path"); const fs = require("node:fs"); const os = require("node:os"); const { spawnSync } = require("node:child_process"); const TMP = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "server-s3-provision-"))); const USERS = path.join(TMP, "users"); process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db"); process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data"); process.env.AGENTHUB_SERVER_CONF = path.join(TMP, "etc", "server.conf"); process.env.AGENTHUB_TOOLS_ROOT = path.join(TMP, "tools"); const linux = require("../lib/linux-accounts"); const memberClaude = require("../lib/member-claude"); const auth = require("../lib/auth"); const { fakeSudo } = require("./fixtures/fake-sudo"); const SCRIPT = path.join(__dirname, "..", "..", "scripts", "server", "agenthub-provision.sh"); const BASELINE = path.join(TMP, "etc", "plugin-baseline.json"); const CLAUDE_LOG = path.join(TMP, "claude.log"); before(() => { fs.mkdirSync(path.join(TMP, "etc"), { recursive: true }); fs.mkdirSync(USERS, { recursive: true }); fs.writeFileSync( process.env.AGENTHUB_SERVER_CONF, `HOST_USER=h\nHOST_UID=1\nUSERS_ROOT=${USERS}\nPORT=4820\n` ); linux.__resetConfCache(); }); after(() => { delete process.env.AGENTHUB_MODE; fs.rmSync(TMP, { recursive: true, force: true }); }); describe("agenthub-provision create seeds ~/.claude.json", () => { const BIN = path.join(TMP, "bin"); const STATE = path.join(TMP, "accounts"); const script = path.join(TMP, "agenthub-provision"); const home = path.join(USERS, "mia"); const file = path.join(home, ".claude.json"); const stub = (name, body) => fs.writeFileSync(path.join(BIN, name), `#!/bin/sh\n${body}\n`, { mode: 0o755 }); const create = () => spawnSync("bash", [script, "create", "mia"], { encoding: "utf8", env: { ...process.env, PATH: `${BIN}:${process.env.PATH}`, STATE }, }); before(() => { fs.mkdirSync(BIN, { recursive: true }); fs.mkdirSync(STATE, { recursive: true }); // Root, the account database and the switch of user are stubs; mkdir, // chmod and the seed itself are real. stub( "id", `case "$1" in -u) echo 0 ;; -nG) [ -f "$STATE/$2" ] && echo "$2 agenthub-members" || exit 1 ;; *) [ -f "$STATE/$1" ] || exit 1 ;; esac` ); stub( "useradd", `while [ $# -gt 1 ]; do [ "$1" = --home-dir ] && h=$2; shift; done mkdir -p "$h" && touch "$STATE/$1"` ); stub("chown", "exit 0"); stub("runuser", `shift 2; [ "$1" = -- ] && shift; exec "$@"`); fs.symlinkSync(process.execPath, path.join(TMP, "node")); const text = fs .readFileSync(SCRIPT, "utf8") .replace("CONF=/etc/agenthub/server.conf", `CONF=${process.env.AGENTHUB_SERVER_CONF}`) .replaceAll("/opt/agenthub/tools/bin/node", path.join(TMP, "node")); fs.writeFileSync(script, text); }); it("marks onboarding done and trusts the home, 0600", () => { const r = create(); assert.equal(r.status, 0, r.stderr); assert.equal(r.stdout.trim(), `created ah-mia ${home}`); assert.deepEqual(JSON.parse(fs.readFileSync(file, "utf8")), { hasCompletedOnboarding: true, projects: { [home]: { hasTrustDialogAccepted: true } }, }); assert.equal(fs.statSync(file).mode & 0o777, 0o600); assert.equal(fs.statSync(path.join(home, ".agenthub", "handoff")).mode & 0o777, 0o700); }); it("merges into what the member already has", () => { const mine = { theme: "dark", projects: { "/x": { a: 1 }, [home]: { allowedTools: ["Bash"] } }, }; fs.writeFileSync(file, JSON.stringify(mine)); assert.equal(create().status, 0); assert.deepEqual(JSON.parse(fs.readFileSync(file, "utf8")), { theme: "dark", hasCompletedOnboarding: true, projects: { "/x": { a: 1 }, [home]: { allowedTools: ["Bash"], hasTrustDialogAccepted: true }, }, }); }); it("leaves a file that is not JSON alone", () => { fs.writeFileSync(file, "{ not json"); const r = create(); assert.equal(r.status, 0, r.stderr); assert.equal(fs.readFileSync(file, "utf8"), "{ not json"); }); }); describe("the plugin baseline (opt-in)", () => { const sudo = fakeSudo(); const home = path.join(USERS, "rex"); const claude = path.join(process.env.AGENTHUB_TOOLS_ROOT, "bin", "claude"); before(() => { fs.mkdirSync(home, { recursive: true }); fs.mkdirSync(path.dirname(claude), { recursive: true }); fs.writeFileSync( claude, `#!/bin/sh echo "$PWD|$*" >> '${CLAUDE_LOG}' case "$*" in *broken*) echo "boom: $*" >&2; exit 1 ;; esac `, { mode: 0o755 } ); memberClaude.__setExecImpl(async (cmd, argv, opts) => sudo.exec(cmd, argv, opts)); linux.__setRunImpl(() => "ok"); }); beforeEach(() => { process.env.AGENTHUB_MODE = "server"; sudo.calls.length = 0; fs.rmSync(CLAUDE_LOG, { force: true }); fs.rmSync(BASELINE, { force: true }); }); it("does nothing without the baseline file, or outside S3", async () => { assert.equal(await memberClaude.applyPluginBaseline("rex"), null); fs.writeFileSync(BASELINE, JSON.stringify({ plugins: ["a@b"] })); delete process.env.AGENTHUB_MODE; assert.equal(await memberClaude.applyPluginBaseline("rex"), null); assert.deepEqual(sudo.calls, []); }); it("adds each marketplace, then installs each plugin, AS the member", async () => { fs.writeFileSync( BASELINE, JSON.stringify({ marketplaces: ["acme/market", "--claudeai x"], plugins: ["good@market", "broken@market", "-y@market"], }) ); const results = await memberClaude.applyPluginBaseline("rex"); assert.deepEqual( results.map((r) => [r.item, r.ok]), [ ["acme/market", true], ["--claudeai x", false], ["good@market", true], ["broken@market", false], ["-y@market", false], ] ); assert.match(results[3].error, /boom/); assert.equal(results[1].error, "not a valid entry"); const env = ["HOME=" + home, "DISABLE_AUTOUPDATER=1"]; assert.deepEqual(sudo.calls, [ ["sudo", "-n", "-u", "ah-rex", ...env, claude, "plugin", "marketplace", "add", "acme/market"], [ "sudo", "-n", "-u", "ah-rex", ...env, claude, "plugin", "install", "--scope", "user", "good@market", ], [ "sudo", "-n", "-u", "ah-rex", ...env, claude, "plugin", "install", "--scope", "user", "broken@market", ], ]); // Run from the member's home, never the dashboard's cwd. assert.ok( fs .readFileSync(CLAUDE_LOG, "utf8") .split("\n") .filter(Boolean) .every((l) => l.startsWith(`${home}|`)) ); }); it("refuses when claude is not in the shared tools", async () => { fs.writeFileSync(BASELINE, JSON.stringify({ marketplaces: ["acme/market"] })); process.env.AGENTHUB_TOOLS_ROOT = path.join(TMP, "no-tools"); try { await assert.rejects(memberClaude.applyPluginBaseline("rex"), { code: "ENOTSHARED" }); } finally { process.env.AGENTHUB_TOOLS_ROOT = path.join(TMP, "tools"); } assert.deepEqual(sudo.calls, []); }); it("starts by itself once a user is created", async () => { fs.writeFileSync(BASELINE, JSON.stringify({ plugins: ["good@market"] })); fs.mkdirSync(path.join(USERS, "zed"), { recursive: true }); await auth.createUser("zed", "member-pass-1", "developer"); for (let i = 0; i < 100 && !sudo.calls.length; i++) await new Promise((r) => setTimeout(r, 20)); assert.equal(sudo.calls.length, 1); assert.equal(sudo.calls[0][3], "ah-zed"); assert.equal(sudo.calls[0].at(-1), "good@market"); }); });