Files
AgentHub/server/__tests__/server-s3-cc-config.test.js
T
nntrivi2001 8546748759 chore: maintenance — dependency majors, rename step 4, member Claude config, OpenAPI coverage
Dependencies
- Security: body-parser and multer DoS advisories fixed (npm audit: 0).
- Server: Express 5 (query parser kept "extended", req.body defaults to {},
  SPA catch-all /{*splat}, send-file helper restores Express 4's dot-folder
  rule for sendFile/download, alerts ack route without inline regex),
  better-sqlite3 13, js-yaml 5 (server/lib/yaml.js keeps js-yaml 4's default
  schema), uuid dropped for crypto.randomUUID, node-pty beta.18, multer 2.5.
- Client: React 19, xterm 6 (WebGL renderer replaces the removed canvas one,
  DOM fallback on context loss; Alt+arrow word moves re-mapped), lucide-react 1,
  TypeScript 7, i18next/react-i18next/codemirror minors.

Workspace rename step 4
- `agenthub lanes` and `--lane` exit 1 with "renamed: use …"; client api.ts,
  the CLI and the MCP server call /api/workspaces; /api/lanes stays mounted.

Server mode (S3): members manage their own Claude Code config
- server/lib/cc-target.js resolves whose config a /cc-config request touches
  and gates the router by allowlist; /profiles* and /plugins/update-all stay
  admin-only. Member writes go through write-as, CLI calls through sudo as the
  member (never -y), reads through openSafe; cc_config_changed reaches only
  its owner. Not verified live on an S3 install yet.

Other
- Chat: an HTML file is an artifact only when written whole and not a project
  page (index.html, or under src/, public/, templates/, …).
- OpenAPI covers every /api route; openapi-coverage.test.js guards it.
- read-pool test keeps its event loop alive (its worker is unref'd).
- Docs: plan statuses, checklists, CLI.md, LANES.md, SERVER-MODE.md, API.md.
2026-10-26 14:22:41 +07:00

470 lines
19 KiB
JavaScript

/**
* @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ĩ <vinnt@smartgift.vn>
*/
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();
}
});
});