/** * @file server-s3-cc-config.test.js * @description Settings for members (S3 plan, P6): a non-admin manages THEIR * OWN Claude Code configuration through `/api/cc-config`. Covers the router's * own allowlist gate (`cc-target.js` — no `/profiles*`, no * `/plugins/update-all`, nothing unlisted), the one target resolver (a * member's `~/.claude`, an admin's host home unchanged, 403 ENOACCOUNT for a * developer with no account — never the host's), every file write running AS * the member through `write-as` with the content on stdin (skills, settings, * keybindings, backups), `claude plugin|mcp …` run as the member's sudo argv * (no `-y`, user scope only, no env/headers across sudo), project writes only * in the member's own workspaces, an admin's project-memory create in a * member's folder written as that member, and a member's change broadcast to * that member alone. A fake passwd (every member = this test's uid), * server.conf, fake sudo (run-as) and a fake `claude` spawn make it run * without root. * @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 TMP = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "server-s3-cc-config-"))); const USERS = path.join(TMP, "users"); const MIA = path.join(USERS, "mia"); const BOB = path.join(USERS, "bob"); const ANA = path.join(USERS, "ana"); const HOST = path.join(TMP, "host-claude"); const TOOLS = path.join(TMP, "tools"); process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data"); process.env.DASHBOARD_DB_PATH = path.join(TMP, "data", "dashboard.db"); process.env.DASHBOARD_LIVENESS_PROBE = "0"; process.env.LANES_ROOT = path.join(TMP, "lanes"); process.env.CLAUDE_HOME = HOST; process.env.CODEX_HOME = path.join(TMP, "host-codex"); process.env.AGENTHUB_SERVER_CONF = path.join(TMP, "server.conf"); process.env.AGENTHUB_PASSWD = path.join(TMP, "passwd"); process.env.AGENTHUB_USERS_ROOT = USERS; process.env.AGENTHUB_TOOLS_ROOT = TOOLS; const { createApp, startServer } = require("../index"); const { db } = require("../db"); const auth = require("../lib/auth"); const linux = require("../lib/linux-accounts"); const runAs = require("../lib/run-as"); const tmux = require("../lib/tmux"); const ccCli = require("../lib/cc-plugin-cli"); const scope = require("../lib/scope"); const { fakeSudo } = require("./fixtures/fake-sudo"); const WRITE_AS = "/opt/agenthub/libexec/write-as"; const CLAUDE = path.join(TOOLS, "bin", "claude"); const UID = process.getuid(); let server; let BASE; const cookies = {}; let sudo; let cliCalls = []; const call = (who, method, p, body) => fetch(BASE + p, { method, headers: { "Content-Type": "application/json", cookie: cookies[who] }, body: body ? JSON.stringify(body) : undefined, }).then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) })); const idOf = (name) => db.prepare("SELECT id FROM users WHERE username = ?").get(name).id; const addLane = (cwd, owner) => db .prepare("INSERT INTO lanes (cwd, tool, owner_id, status) VALUES (?, 'claude', ?, 'idle')") .run(cwd, idOf(owner)); /** Every sudo argv recorded since the last reset, as one string each. */ const sudoLines = () => sudo.calls.map((c) => c.join(" ")); before(async () => { for (const d of [path.join(MIA, "proj"), path.join(MIA, "proj2"), path.join(BOB, "proj"), ANA]) fs.mkdirSync(d, { recursive: true }); fs.mkdirSync(HOST, { recursive: true }); fs.mkdirSync(path.dirname(CLAUDE), { recursive: true }); fs.writeFileSync(CLAUDE, "#!/bin/sh\nexit 0\n", { mode: 0o755 }); fs.writeFileSync( process.env.AGENTHUB_PASSWD, ["mia", "bob", "ana", "boss"].map((n) => `ah-${n}:x:${UID}:${UID}::/:/bin/sh`).join("\n") + "\n" ); runAs.__resetCache(); fs.writeFileSync( process.env.AGENTHUB_SERVER_CONF, `HOST_USER=h\nHOST_UID=1\nUSERS_ROOT=${USERS}\nPORT=4820\n` ); linux.__resetConfCache(); linux.__setRunImpl(() => "ok"); sudo = fakeSudo(); runAs.__setExecImpl(sudo.exec); tmux.__setExecImpl((args) => { if (args[0] === "-V") return "tmux 3.4"; if (args.includes("has-session")) throw Object.assign(new Error("no"), { status: 1 }); return ""; }); // The `claude` CLI never really runs: every spawn is recorded and answers ok. ccCli.__setExecFileImpl((cmd, argv, opts, cb) => { cliCalls.push({ cmd, argv, opts }); const listing = argv.includes("list") ? JSON.stringify({ installed: [], available: [{ pluginId: "p@m" }] }) : "done\n"; setImmediate(() => cb(null, listing, "")); return { stdin: { end() {} } }; }); server = await startServer(createApp(), 0); BASE = `http://127.0.0.1:${server.address().port}`; process.env.AGENTHUB_MODE = "server"; for (const [n, role] of [ ["mia", "developer"], ["bob", "developer"], ["eve", "developer"], // no Linux account ["ana", "analyst"], ["boss", "admin"], ]) { await auth.createUser(n, `${n}-pass-123`, role); const login = await fetch(`${BASE}/api/auth/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: n, password: `${n}-pass-123` }), }); cookies[n] = login.headers.get("set-cookie").split(";")[0]; } addLane(path.join(MIA, "proj"), "mia"); addLane(path.join(BOB, "proj"), "bob"); }); beforeEach(() => { sudo.calls.length = 0; cliCalls = []; }); after(() => { ccCli.__setExecFileImpl(null); tmux.__reset(); delete process.env.AGENTHUB_MODE; delete process.env.AGENTHUB_USERS_ROOT; delete process.env.AGENTHUB_TOOLS_ROOT; server?.close(); fs.rmSync(TMP, { recursive: true, force: true }); }); describe("the gate: what a non-admin may reach under /api/cc-config", () => { it("keeps the host's login profiles and Update all an admin's", async () => { for (const [method, p] of [ ["GET", "/api/cc-config/profiles"], ["POST", "/api/cc-config/profiles"], ["POST", "/api/cc-config/profiles/activate"], ["POST", "/api/cc-config/profiles/usage"], ["DELETE", "/api/cc-config/profiles"], ["GET", "/api/cc-config/Profiles"], ["GET", "/api/cc-config/%70rofiles"], ["GET", "/api/cc-config/plugins/update-all"], ["POST", "/api/cc-config/plugins/update-all"], ["GET", "/api/cc-config/no-such-route"], ["POST", "/api/cc-config/no-such-route"], ]) { const r = await call("mia", method, p, method === "GET" ? undefined : {}); assert.equal(r.status, 403, `${method} ${p}`); assert.equal(r.body.error.code, "EADMIN", `${method} ${p}`); } assert.equal(cliCalls.length, 0); }); it("lets an analyst still read the host-wide views, never change them", async () => { assert.equal((await call("ana", "GET", "/api/cc-config/profiles")).status, 200); const w = await call("ana", "POST", "/api/cc-config/profiles", { name: "x" }); assert.equal(w.status, 403); assert.equal(w.body.error.code, "EADMIN"); }); it("leaves an admin every endpoint, the host's as before", async () => { assert.equal((await call("boss", "GET", "/api/cc-config/profiles")).status, 200); const t = await call("boss", "GET", "/api/cc-config/target"); assert.deepEqual(t.body, { kind: "host", readOnly: false, claudeHome: HOST }); }); }); describe("a developer with no Linux account", () => { it("is refused — never served or written the host's config", async () => { const t = await call("eve", "GET", "/api/cc-config/target"); assert.equal(t.body.kind, "none"); for (const [method, p, body] of [ ["GET", "/api/cc-config/overview"], ["GET", "/api/cc-config/settings"], ["PUT", "/api/cc-config/file", { scope: "user", type: "skills", name: "x", content: "y" }], ["PATCH", "/api/cc-config/settings", { scope: "user", set: { theme: "dark" } }], ["POST", "/api/cc-config/plugins/install", { spec: "p@m" }], ]) { const r = await call("eve", method, p, body); assert.equal(r.status, 403, `${method} ${p}`); assert.equal(r.body.error.code, "ENOACCOUNT", `${method} ${p}`); } assert.ok(!fs.existsSync(path.join(HOST, "skills", "x"))); assert.ok(!fs.existsSync(path.join(HOST, "settings.json"))); assert.equal(cliCalls.length, 0); assert.equal(sudo.calls.length, 0); // The run form's own lists still answer (empty), as before. const skills = await call("eve", "GET", "/api/cc-config/skills"); assert.equal(skills.status, 200); assert.deepEqual(skills.body.items, []); }); }); describe("a member's own config", () => { const own = path.join(MIA, ".claude"); it("is the one the page shows", async () => { const t = await call("mia", "GET", "/api/cc-config/target"); assert.deepEqual(t.body, { kind: "own", readOnly: false, claudeHome: own }); const o = await call("mia", "GET", "/api/cc-config/overview"); assert.equal(o.status, 200); assert.equal(o.body.roots.claudeHome, own); assert.equal(o.body.roots.claudeJson, path.join(MIA, ".claude.json")); // No workspace named: no project layer — never the dashboard's own folder. assert.equal(o.body.roots.projectRoot, null); }); it("writes a skill AS the member through write-as, content on stdin, with a backup", async () => { const body = { scope: "user", type: "skills", name: "mine", content: "---\nname: mine\n---\nv1", }; const r1 = await call("mia", "PUT", "/api/cc-config/file", body); assert.equal(r1.status, 200, JSON.stringify(r1.body)); const file = path.join(own, "skills", "mine", "SKILL.md"); assert.equal(fs.readFileSync(file, "utf8"), body.content); assert.ok(sudoLines().some((l) => l.includes(`-u ah-mia ${WRITE_AS} ${file} replace`))); assert.ok(!sudoLines().some((l) => l.includes("v1")), "content crossed sudo's argv"); assert.ok(!fs.existsSync(path.join(HOST, "skills", "mine"))); sudo.calls.length = 0; const r2 = await call("mia", "PUT", "/api/cc-config/file", { ...body, content: "v2" }); assert.equal(r2.status, 200); assert.ok(r2.body.backupPath.startsWith(path.join(own, "cc-config-backups", "skills"))); assert.equal(fs.readFileSync(path.join(r2.body.backupPath, "SKILL.md"), "utf8"), body.content); assert.ok(sudoLines().some((l) => l.includes(` copy ${r2.body.backupPath}`))); const list = await call("mia", "GET", "/api/cc-config/skills?scope=user"); assert.deepEqual( list.body.items.map((i) => i.name), ["mine"] ); sudo.calls.length = 0; const del = await call("mia", "DELETE", "/api/cc-config/file", { scope: "user", type: "skills", name: "mine", }); assert.equal(del.status, 200); assert.ok(!fs.existsSync(path.join(own, "skills", "mine"))); assert.ok(sudoLines().some((l) => l.includes(`${path.join(own, "skills", "mine")} remove`))); }); it("merges settings.json and writes keybindings.json as the member", async () => { const r = await call("mia", "PATCH", "/api/cc-config/settings", { scope: "user", set: { "env.SECRETISH": "s3cr3t-value", theme: "dark" }, }); assert.equal(r.status, 200, JSON.stringify(r.body)); const settings = JSON.parse(fs.readFileSync(path.join(own, "settings.json"), "utf8")); assert.equal(settings.theme, "dark"); assert.ok(!sudoLines().some((l) => l.includes("s3cr3t-value"))); assert.ok(sudoLines().some((l) => l.includes(`${path.join(own, "settings.json")} replace`))); const again = await call("mia", "PATCH", "/api/cc-config/settings", { scope: "user", set: { theme: "light" }, }); assert.ok(again.body.backupPath.startsWith(path.join(own, ".cc-config-backups"))); const kb = await call("mia", "PUT", "/api/cc-config/keybindings", { groups: [{ context: "Chat", bindings: [{ key: "ctrl+t", action: "toggleTodos" }] }], }); assert.equal(kb.status, 200, JSON.stringify(kb.body)); assert.equal(kb.body.file, path.join(own, "keybindings.json")); const read = await call("mia", "GET", "/api/cc-config/keybindings"); assert.equal(read.body.groups[0].bindings[0].action, "toggleTodos"); // Project layers need a workspace named. const proj = await call("mia", "PATCH", "/api/cc-config/settings", { scope: "project", set: { theme: "dark" }, }); assert.equal(proj.status, 400); assert.equal(proj.body.error.code, "EBADSCOPE"); assert.ok(!fs.existsSync(path.join(HOST, "settings.json"))); }); it("writes into a project only in a workspace of the member's own", async () => { const q = (cwd) => `/api/cc-config/file?cwd=${encodeURIComponent(cwd)}`; const body = { scope: "project", type: "agents", name: "helper", content: "agent" }; const mine = await call("mia", "PUT", q(path.join(MIA, "proj")), body); assert.equal(mine.status, 200, JSON.stringify(mine.body)); const file = path.join(MIA, "proj", ".claude", "agents", "helper.md"); assert.equal(fs.readFileSync(file, "utf8"), "agent"); assert.ok(sudoLines().some((l) => l.includes(`-u ah-mia ${WRITE_AS} ${file} replace`))); for (const cwd of [path.join(BOB, "proj"), path.join(MIA, "proj2"), MIA]) { const r = await call("mia", "PUT", q(cwd), body); assert.equal(r.status, 403, cwd); assert.equal(r.body.error.code, "EFOLDER", cwd); } assert.ok(!fs.existsSync(path.join(BOB, "proj", ".claude"))); const pm = await call("mia", "POST", "/api/cc-config/project-memory", { dir: path.join(BOB, "proj"), content: "x", }); assert.equal(pm.status, 403); assert.ok(!fs.existsSync(path.join(BOB, "proj", "CLAUDE.md"))); }); it("runs `claude plugin` and `claude mcp` as the member — no -y, user scope only", async () => { const inst = await call("mia", "POST", "/api/cc-config/plugins/install", { spec: "p@m" }); assert.equal(inst.status, 200, JSON.stringify(inst.body)); const add = await call("mia", "POST", "/api/cc-config/marketplaces/add", { source: "owner/repo", }); assert.equal(add.status, 200); const mcp = await call("mia", "POST", "/api/cc-config/mcp/add", { name: "srv", scope: "user", command: "npx", args: ["srv"], }); assert.equal(mcp.status, 200, JSON.stringify(mcp.body)); const avail = await call("mia", "GET", "/api/cc-config/plugins/available"); assert.equal(avail.status, 200); const prefix = ["-n", "-u", "ah-mia", `HOME=${MIA}`, "DISABLE_AUTOUPDATER=1", CLAUDE]; assert.deepEqual( cliCalls.map((c) => [c.cmd, ...c.argv]), [ ["sudo", ...prefix, "plugin", "install", "p@m", "--scope", "user"], ["sudo", ...prefix, "plugin", "marketplace", "add", "owner/repo", "--scope", "user"], ["sudo", ...prefix, "mcp", "add", "--scope", "user", "srv", "--", "npx", "srv"], ["sudo", ...prefix, "plugin", "list", "--available", "--json"], ] ); assert.ok(cliCalls.every((c) => c.opts.cwd === MIA)); assert.ok(!cliCalls.some((c) => c.argv.some((a) => a === "-y" || a.startsWith("--danger")))); cliCalls = []; for (const [p, body] of [ ["/api/cc-config/plugins/install", { spec: "p@m", scope: "project" }], ["/api/cc-config/marketplaces/add", { source: "owner/repo", scope: "local" }], ["/api/cc-config/mcp/add", { name: "s", scope: "local", command: "x" }], ["/api/cc-config/mcp/add", { name: "s", scope: "user", command: "x", env: { K: "v" } }], [ "/api/cc-config/mcp/add", { name: "s", scope: "user", url: "https://x", headers: { a: "b" } }, ], ]) { const r = await call("mia", "POST", p, body); assert.equal(r.status, 400, `${p} ${JSON.stringify(body)}`); } assert.equal(cliCalls.length, 0, "a refused call never spawned"); }); it("answers ENOTSHARED while claude is not in the shared tools", async () => { fs.renameSync(CLAUDE, `${CLAUDE}.off`); try { const r = await call("mia", "POST", "/api/cc-config/plugins/action", { key: "p@m", action: "enable", }); assert.equal(r.status, 503); assert.equal(r.body.error.code, "ENOTSHARED"); assert.equal(cliCalls.length, 0); } finally { fs.renameSync(`${CLAUDE}.off`, CLAUDE); } }); }); describe("an admin", () => { it("still writes the host's own config, as the host", async () => { const r = await call("boss", "PUT", "/api/cc-config/file", { scope: "user", type: "commands", name: "hosty", content: "host cmd", }); assert.equal(r.status, 200); assert.equal(fs.readFileSync(path.join(HOST, "commands", "hosty.md"), "utf8"), "host cmd"); assert.equal(sudo.calls.length, 0); const inst = await call("boss", "POST", "/api/cc-config/plugins/install", { spec: "p@m" }); assert.equal(inst.status, 200); assert.deepEqual( cliCalls.map((c) => [c.cmd, ...c.argv]), [["claude", "plugin", "install", "p@m", "--scope", "user", "-y"]] ); }); it("creates CLAUDE.md in a member's folder as that member, never as the host", async () => { const dir = path.join(MIA, "proj2"); const r = await call("boss", "POST", "/api/cc-config/project-memory", { dir, content: "# memo", }); assert.equal(r.status, 200, JSON.stringify(r.body)); assert.equal(fs.readFileSync(path.join(dir, "CLAUDE.md"), "utf8"), "# memo"); assert.ok( sudoLines().some((l) => l.includes(`-u ah-mia ${WRITE_AS} ${path.join(dir, "CLAUDE.md")}`)) ); }); }); describe("a member's config change on the WebSocket", () => { it("names the owner, so it reaches that member alone", () => { assert.deepEqual([...scope.exactAudience("cc_config_changed", { ownerId: 7, name: "x" })], [7]); assert.equal(scope.exactAudience("cc_config_changed", { name: "x" }), null); assert.equal(scope.exactAudience("lane_update", { ownerId: 7 }), null); }); it("is delivered to the member and not to an admin; the host's reaches both", async () => { const WebSocket = require("ws"); const open = (who) => { const ws = new WebSocket(BASE.replace("http", "ws") + "/ws", { headers: { cookie: cookies[who] }, }); const seen = []; ws.on("message", (m) => { const msg = JSON.parse(String(m)); if (msg.type === "cc_config_changed") seen.push(msg.data); }); return new Promise((resolve, reject) => ws.on("open", () => resolve({ ws, seen })).on("error", reject) ); }; const mia = await open("mia"); const boss = await open("boss"); try { await call("mia", "PUT", "/api/cc-config/file", { scope: "user", type: "commands", name: "secret-plan", content: "x", }); await call("boss", "PUT", "/api/cc-config/file", { scope: "user", type: "commands", name: "host-cmd", content: "x", }); await new Promise((r) => setTimeout(r, 300)); assert.ok( mia.seen.some((d) => d.name === "secret-plan"), JSON.stringify(mia.seen) ); assert.ok(!JSON.stringify(boss.seen).includes("secret-plan"), JSON.stringify(boss.seen)); assert.ok(boss.seen.some((d) => d.name === "host-cmd")); // The host's change reaches the member as the bare type (as before). assert.ok(!JSON.stringify(mia.seen).includes("host-cmd")); assert.ok(mia.seen.some((d) => d && Object.keys(d).length === 0)); } finally { mia.ws.close(); boss.ws.close(); } }); });