/** * @file server-s3-lanes.test.js * @description S3 plan, P3e: a workspace in a member's folder gets neither the * managed-worktree mode nor the runtime layer (they run as the dashboard's own * account and write into LANES_ROOT) — 403 EMEMBERLANE; a host workspace is * untouched. A host plugin reload never types into a member's pane. An analyst * WRITES like a developer: their workspace only in their own folder, run as * their own Linux account. * @author Nguyễn Ngọc Trí Vĩ */ const { describe, it, before, after } = 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 { execFileSync } = require("node:child_process"); const TMP = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "server-s3-lanes-"))); const USERS = path.join(TMP, "users"); const PROJ = path.join(USERS, "mia", "proj"); process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db"); process.env.DASHBOARD_LIVENESS_PROBE = "0"; process.env.LANES_ROOT = path.join(TMP, "lanes"); process.env.AGENTHUB_SERVER_CONF = path.join(TMP, "server.conf"); process.env.AGENTHUB_PASSWD = path.join(TMP, "passwd"); 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"); let server; let BASE; let memberLane; let hostLane; let cookie; const call = (method, p, body) => fetch(BASE + p, { method, headers: { "Content-Type": "application/json", cookie }, body: body ? JSON.stringify(body) : undefined, }).then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) })); before(async () => { fs.mkdirSync(PROJ, { recursive: true }); execFileSync("git", ["init", "-q", PROJ]); execFileSync("git", [ "-C", PROJ, "-c", "user.email=a@b", "-c", "user.name=a", "commit", "-q", "--allow-empty", "-m", "x", ]); fs.writeFileSync( process.env.AGENTHUB_PASSWD, "ah-mia:x:5001:5001::/:/bin/sh\nah-ana:x:5002:5002::/:/bin/sh\n" ); fs.writeFileSync( process.env.AGENTHUB_SERVER_CONF, `HOST_USER=h\nHOST_UID=1\nUSERS_ROOT=${USERS}\nPORT=4820\n` ); linux.__resetConfCache(); runAs.__resetCache(); linux.__setRunImpl(() => "ok"); server = await startServer(createApp(), 0); BASE = `http://127.0.0.1:${server.address().port}`; process.env.AGENTHUB_MODE = "server"; await auth.createUser("boss", "admin-pass-1", "admin"); await auth.createUser("mia", "member-pass-1", "developer"); await auth.createUser("ana", "analyst-pass-1", "analyst"); const boss = db.prepare("SELECT id FROM users WHERE username = 'boss'").get().id; const insert = db.prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES (?, 'claude', ?)"); memberLane = Number(insert.run(PROJ, boss).lastInsertRowid); hostLane = Number(insert.run(path.join(TMP, "hostproj"), boss).lastInsertRowid); const login = await fetch(`${BASE}/api/auth/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: "boss", password: "admin-pass-1" }), }); cookie = login.headers.get("set-cookie").split(";")[0]; }); after(() => { tmux.__reset(); delete process.env.AGENTHUB_MODE; server?.close(); fs.rmSync(TMP, { recursive: true, force: true }); }); describe("no runtime layer and no managed worktree in a member's folder", () => { for (const [method, p] of [ ["POST", "up"], ["POST", "down"], ["POST", "hook/bootstrap"], ["POST", "sync-base"], ["POST", "profile/init"], ["POST", "mcp/sync"], ]) { it(`refuses ${method} /:id/${p}`, async () => { const r = await call(method, `/api/lanes/${memberLane}/${p}`, {}); assert.equal(r.status, 403, JSON.stringify(r.body)); assert.equal(r.body.error.code, "EMEMBERLANE"); }); } it("refuses a managed worktree of a member's repository", async () => { const r = await call("POST", "/api/lanes/worktree", { sourceRepo: PROJ, slug: "feat" }); assert.equal(r.status, 403); assert.equal(r.body.error.code, "EMEMBERLANE"); }); it("leaves a host workspace's runtime routes as they were", async () => { const r = await call("POST", `/api/lanes/${hostLane}/up`, {}); assert.notEqual(r.body?.error?.code, "EMEMBERLANE"); }); }); describe("a host plugin reload", () => { it("never types into a member's pane", () => { const { reloadableLanes } = require("../lib/cc-reload"); const runs = require("../lib/pty-run"); const orig = runs.listRuns; db.prepare( "INSERT INTO dashboard_runs (id, lane_id, cwd, mode, status, tool) VALUES (?, ?, ?, 'interactive', 'running', 'claude')" ).run(`agenthub-lane-${memberLane}`, memberLane, PROJ); tmux.__setExecImpl((args) => args.includes("list-sessions") ? `agenthub-lane-${memberLane}\n` : "claude\n" ); try { assert.ok(!reloadableLanes().some((l) => l.id === `agenthub-lane-${memberLane}`)); } finally { runs.listRuns = orig; } }); }); describe("an analyst's workspace is theirs, like a developer's", () => { it("is created only in their own folder and runs as their Linux account", async () => { const own = path.join(USERS, "ana", "proj"); fs.mkdirSync(own, { recursive: true }); process.env.AGENTHUB_USERS_ROOT = USERS; try { const login = await fetch(`${BASE}/api/auth/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: "ana", password: "analyst-pass-1" }), }); const ca = login.headers.get("set-cookie").split(";")[0]; const post = (cwd) => fetch(`${BASE}/api/lanes`, { method: "POST", headers: { "Content-Type": "application/json", cookie: ca }, body: JSON.stringify({ cwd }), }).then(async (r) => ({ status: r.status, body: await r.json() })); const theirs = await post(path.join(USERS, "mia")); assert.equal(theirs.status, 403); assert.equal(theirs.body.error.code, "EFOLDER"); const mine = await post(own); assert.equal(mine.status, 201, JSON.stringify(mine.body)); assert.equal(runAs.accountForLane(mine.body.lane.id).login, "ah-ana"); } finally { delete process.env.AGENTHUB_USERS_ROOT; } }); }); describe("a member's new project folder", () => { it("is made as the member, and nothing else is made for anyone", () => { const { fakeSudo } = require("./fixtures/fake-sudo"); const sudo = fakeSudo(); runAs.__setExecImpl(sudo.exec); const fresh = path.join(USERS, "mia", "brand-new", "sub"); assert.equal(runAs.makeMemberFolder(fresh), true); assert.ok(fs.statSync(fresh).isDirectory()); const call = sudo.calls.find((c) => c.includes("/opt/agenthub/libexec/write-as")); assert.ok(call && call.includes("ah-mia") && call.includes(fresh) && call.includes("mkdir")); sudo.calls.length = 0; assert.equal(runAs.makeMemberFolder(path.join(USERS, "mia")), false, "the home itself"); assert.equal(runAs.makeMemberFolder(path.join(TMP, "host-new")), false, "not a member's"); assert.equal(runAs.makeMemberFolder(path.join(USERS, "nobody", "x")), false, "no account"); assert.deepEqual(sudo.calls, []); assert.equal(fs.existsSync(path.join(TMP, "host-new")), false); }); });