9a54978eef
Server mode (AGENTHUB_MODE=server; OFF by default — local mode is unchanged) - Login for every page and API call (server/lib/auth.js, routes/auth.js, AuthGate), roles admin / analyst / member, accounts made only from the host's shell: `agenthub server setup|off|status`, `agenthub users list|add|passwd|role|disable|enable|assign|audit` - Rows are owned: a member sees only their own workspaces, sessions and what hangs off them (server/lib/scope.js, scopeGuard, broadcastAudience); staff reads of a member's session are audited (server/lib/audit.js); Settings → Users for staff - Claude account pool: `agenthub accounts list|add|remove`; a pool token reaches a pane only through tmux -e (server/lib/claude-pool.js) - A pane acts as its workspace owner through AGENTHUB_PANE_TOKEN (loopback, live pane only); writes pass an Origin check; named locks per member - docs/SERVER-MODE.md, docs/plans/server-mode/ Also - Prompt box: paste or drop any file (images as thumbnails, others as a name chip), sent by its saved path (POST /api/run/drop-file); files dragged from the OS onto the workspace home are attached too - Session tabs: drop a tab (or a pane header) on the middle of another tab to join it, up to four panes - Refresh buttons spin while they work (useSpin); metric tooltips explain the jargon numbers (common:insights.help.*) - Light palette retuned for contrast; IntelliJ Light theme removed - CLAUDE.md sync marker trimmed; its history moved to docs/CLAUDE-SYNC-LOG.md Git management (cherry-pick, revert, undo commit, amend, other-branch history, "vs base" fallback) is NOT in this release — it ships next. Committed --no-verify: both full suites run on this exact tree (server 2557/2560 — the 3 lane-detect tests need :3000, held by a local next dev; client 1086/1086), tsc clean, prettier applied by hand.
246 lines
9.8 KiB
JavaScript
246 lines
9.8 KiB
JavaScript
/**
|
|
* @file server-mode-workspaces.test.js
|
|
* @description Server mode's per-user row ownership on the workspace routes
|
|
* (phase S2): the workspace list and its counts, pipeline stats, housekeeping
|
|
* (`/lanes/gc`), projects, the live and past run lists, folder suggestions,
|
|
* the `@` file walk, starting and killing a run, and a resume of another
|
|
* user's session. A member gets only their own rows, another member theirs,
|
|
* an admin and an analyst everything, and local mode is unchanged.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const { describe, it, before, after } = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
const path = require("path");
|
|
const fs = require("fs");
|
|
const os = require("os");
|
|
|
|
const TEST_DB = path.join(os.tmpdir(), `server-mode-ws-${Date.now()}-${process.pid}.db`);
|
|
process.env.DASHBOARD_DB_PATH = TEST_DB;
|
|
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
|
// A private tmux server: nothing here may list or kill the user's real panes.
|
|
const TMUX_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "server-mode-ws-tmux-"));
|
|
process.env.TMUX_TMPDIR = TMUX_DIR;
|
|
delete process.env.TMUX;
|
|
|
|
const { createApp, startServer } = require("../index");
|
|
const { db } = require("../db");
|
|
const auth = require("../lib/auth");
|
|
|
|
let server;
|
|
let BASE;
|
|
const cookies = {};
|
|
const ROOT = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "server-mode-ws-")));
|
|
const DIR_A = path.join(ROOT, "alice");
|
|
const DIR_B = path.join(ROOT, "bob");
|
|
const LANE_A = 9001;
|
|
const LANE_B = 9002;
|
|
|
|
async function call(method, p, { as, body } = {}) {
|
|
const res = await fetch(BASE + p, {
|
|
method,
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
...(as ? { cookie: cookies[as] } : {}),
|
|
},
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
return { status: res.status, body: await res.json().catch(() => null) };
|
|
}
|
|
|
|
const get = async (p, as) => {
|
|
const r = await call("GET", p, { as });
|
|
assert.equal(r.status, 200, `${p}: ${JSON.stringify(r.body)}`);
|
|
return r.body;
|
|
};
|
|
|
|
const laneIds = async (as) => (await get("/api/lanes", as)).lanes.map((l) => l.id).sort();
|
|
|
|
before(async () => {
|
|
for (const d of [DIR_A, DIR_B]) {
|
|
fs.mkdirSync(d);
|
|
fs.writeFileSync(path.join(d, `${path.basename(d)}.txt`), "x");
|
|
}
|
|
server = await startServer(createApp(), 0);
|
|
BASE = `http://127.0.0.1:${server.address().port}`;
|
|
const alice = await auth.createUser("alice", "alice-pass-1", "member");
|
|
const bob = await auth.createUser("bob", "bob-pass-1", "member");
|
|
await auth.createUser("boss", "admin-pass-1", "admin");
|
|
await auth.createUser("ana", "analyst-pass-1", "analyst");
|
|
|
|
const lane = db.prepare(
|
|
"INSERT INTO lanes (id, cwd, tool, owner_id, source_repo) VALUES (?, ?, 'claude', ?, ?)"
|
|
);
|
|
lane.run(LANE_A, DIR_A, alice.id, DIR_A);
|
|
lane.run(LANE_B, DIR_B, bob.id, DIR_B);
|
|
const session = db.prepare(
|
|
"INSERT INTO sessions (id, name, status, cwd, lane_id) VALUES (?, 'x', 'completed', ?, ?)"
|
|
);
|
|
session.run("sA-0000001", DIR_A, LANE_A);
|
|
session.run("sB-0000001", DIR_B, LANE_B);
|
|
const stage = db.prepare(
|
|
"INSERT INTO lane_stage_events (lane_id, pipeline, node_id) VALUES (?, 'default', 'x')"
|
|
);
|
|
stage.run(LANE_A);
|
|
stage.run(LANE_B);
|
|
const run = db.prepare(
|
|
"INSERT INTO dashboard_runs (id, mode, cwd, status, lane_id, started_at) VALUES (?, 'interactive', ?, 'exited', ?, ?)"
|
|
);
|
|
run.run(`agenthub-lane-${LANE_A}@1`, DIR_A, LANE_A, "2026-01-01T00:00:00Z");
|
|
run.run(`agenthub-lane-${LANE_B}@1`, DIR_B, LANE_B, "2026-01-02T00:00:00Z");
|
|
|
|
for (const [name, pass] of [
|
|
["alice", "alice-pass-1"],
|
|
["bob", "bob-pass-1"],
|
|
["boss", "admin-pass-1"],
|
|
["ana", "analyst-pass-1"],
|
|
]) {
|
|
process.env.AGENTHUB_MODE = "server";
|
|
const res = await fetch(BASE + "/api/auth/login", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ username: name, password: pass }),
|
|
});
|
|
assert.equal(res.status, 200);
|
|
cookies[name] = res.headers.get("set-cookie").split(";")[0];
|
|
}
|
|
delete process.env.AGENTHUB_MODE;
|
|
});
|
|
|
|
after(() => {
|
|
delete process.env.AGENTHUB_MODE;
|
|
server?.close();
|
|
for (const f of [TEST_DB, `${TEST_DB}-wal`, `${TEST_DB}-shm`, `${TEST_DB}-migrate.lock`]) {
|
|
fs.rmSync(f, { force: true });
|
|
}
|
|
fs.rmSync(ROOT, { recursive: true, force: true });
|
|
fs.rmSync(TMUX_DIR, { recursive: true, force: true });
|
|
});
|
|
|
|
describe("local mode", () => {
|
|
it("lists every workspace, run and folder as before", async () => {
|
|
assert.deepEqual(await laneIds(), [LANE_A, LANE_B]);
|
|
assert.equal((await get("/api/lanes/pipeline-stats")).lanes.length, 2);
|
|
const hist = (await get("/api/run/history")).items.map((r) => r.lane_id).sort();
|
|
assert.deepEqual(hist, [LANE_A, LANE_B]);
|
|
const kinds = (await get("/api/run/cwds")).items.map((i) => i.kind);
|
|
assert.ok(kinds.includes("dashboard") && kinds.includes("home"));
|
|
assert.equal((await get(`/api/run/files?cwd=${encodeURIComponent(DIR_B)}`)).items.length, 1);
|
|
});
|
|
});
|
|
|
|
describe("server mode", () => {
|
|
before(() => {
|
|
process.env.AGENTHUB_MODE = "server";
|
|
});
|
|
after(() => {
|
|
delete process.env.AGENTHUB_MODE;
|
|
});
|
|
|
|
it("GET /api/lanes lists and counts only a member's own workspaces", async () => {
|
|
assert.deepEqual(await laneIds("alice"), [LANE_A]);
|
|
assert.deepEqual(await laneIds("bob"), [LANE_B]);
|
|
assert.equal((await get("/api/lanes", "alice")).counts.total, 1);
|
|
assert.deepEqual(await laneIds("boss"), [LANE_A, LANE_B]);
|
|
assert.deepEqual(await laneIds("ana"), [LANE_A, LANE_B]);
|
|
assert.deepEqual(
|
|
(await get("/api/workspaces", "bob")).lanes.map((l) => l.id),
|
|
[LANE_B]
|
|
);
|
|
});
|
|
|
|
it("pipeline stats cover only a member's own workspaces", async () => {
|
|
const mine = await get("/api/lanes/pipeline-stats", "alice");
|
|
assert.deepEqual(
|
|
mine.lanes.map((l) => l.id),
|
|
[LANE_A]
|
|
);
|
|
assert.equal(mine.pipelines[0].runs, 1);
|
|
const all = await get("/api/lanes/pipeline-stats", "ana");
|
|
assert.equal(all.lanes.length, 2);
|
|
assert.equal(all.pipelines[0].runs, 2);
|
|
assert.equal(
|
|
(await call("GET", `/api/lanes/pipeline-stats?lane=${LANE_B}`, { as: "alice" })).status,
|
|
404
|
|
);
|
|
});
|
|
|
|
it("housekeeping is refused to a member", async () => {
|
|
const r = await call("POST", "/api/lanes/gc", { as: "alice", body: { dryRun: true } });
|
|
assert.equal(r.status, 403);
|
|
assert.equal(r.body.error.code, "EADMIN");
|
|
assert.equal(
|
|
(await call("POST", "/api/lanes/gc", { as: "boss", body: { dryRun: true } })).status,
|
|
200
|
|
);
|
|
});
|
|
|
|
it("projects are built from a member's own workspaces", async () => {
|
|
const ws = (b) => b.projects.flatMap((p) => p.workspaces).sort();
|
|
assert.deepEqual(ws(await get("/api/projects", "alice")), [LANE_A]);
|
|
assert.deepEqual(ws(await get("/api/projects", "bob")), [LANE_B]);
|
|
assert.deepEqual(ws(await get("/api/projects", "ana")), [LANE_A, LANE_B]);
|
|
const r = await call("POST", "/api/projects/refresh", { as: "alice" });
|
|
assert.deepEqual(ws(r.body), [LANE_A]);
|
|
});
|
|
|
|
it("run lists and history show only a member's own runs", async () => {
|
|
const hist = async (as, q = "") =>
|
|
(await get(`/api/run/history${q}`, as)).items.map((r) => r.lane_id).sort();
|
|
assert.deepEqual(await hist("alice"), [LANE_A]);
|
|
assert.deepEqual(await hist("bob"), [LANE_B]);
|
|
assert.deepEqual(await hist("boss"), [LANE_A, LANE_B]);
|
|
assert.deepEqual(await hist("alice", "?limit=1"), [LANE_A]);
|
|
assert.equal(
|
|
(await call("GET", `/api/run/history?laneId=${LANE_B}`, { as: "alice" })).status,
|
|
404
|
|
);
|
|
const live = await get("/api/run", "alice");
|
|
assert.ok(live.items.every((r) => r.laneId === LANE_A));
|
|
});
|
|
|
|
it("folder suggestions come from a member's own workspaces and sessions", async () => {
|
|
const paths = async (as) => (await get("/api/run/cwds", as)).items.map((i) => i.path);
|
|
assert.deepEqual(await paths("alice"), [DIR_A]);
|
|
assert.deepEqual(await paths("bob"), [DIR_B]);
|
|
const all = await paths("ana");
|
|
assert.ok(all.includes(DIR_A) && all.includes(DIR_B) && all.includes(os.homedir()));
|
|
});
|
|
|
|
it("the file walk stays inside a member's own workspaces", async () => {
|
|
const files = (as, dir) => call("GET", `/api/run/files?cwd=${encodeURIComponent(dir)}`, { as });
|
|
assert.deepEqual((await files("alice", DIR_A)).body.items, ["alice.txt"]);
|
|
assert.equal((await files("alice", DIR_B)).status, 403);
|
|
assert.equal((await call("GET", "/api/run/files", { as: "alice" })).status, 403);
|
|
// A symlink inside her folder does not reach bob's.
|
|
fs.symlinkSync(DIR_B, path.join(DIR_A, "link"));
|
|
assert.equal((await files("alice", path.join(DIR_A, "link"))).status, 403);
|
|
assert.deepEqual((await files("boss", DIR_B)).body.items, ["bob.txt"]);
|
|
});
|
|
|
|
it("a member's run starts only in their own workspace and folder", async () => {
|
|
const start = (body) => call("POST", "/api/run", { as: "alice", body });
|
|
assert.equal((await start({})).status, 400);
|
|
assert.equal((await start({ laneId: LANE_B })).status, 404);
|
|
const r = await start({ laneId: LANE_A, cwd: DIR_B });
|
|
assert.equal(r.status, 403);
|
|
assert.equal(r.body.error.code, "EFOLDER");
|
|
});
|
|
|
|
it("another user's run cannot be read or killed, encoded or not", async () => {
|
|
const id = `agenthub-lane-${LANE_B}`;
|
|
assert.equal((await call("DELETE", `/api/run/${id}`, { as: "alice" })).status, 404);
|
|
const encoded = `%61genthub-lane-${LANE_B}`;
|
|
assert.equal((await call("DELETE", `/api/run/${encoded}`, { as: "alice" })).status, 404);
|
|
assert.equal((await call("GET", `/api/run/${encoded}`, { as: "alice" })).status, 404);
|
|
});
|
|
|
|
it("a member cannot resume another user's session in their own workspace", async () => {
|
|
const r = await call("POST", `/api/lanes/${LANE_A}/start`, {
|
|
as: "alice",
|
|
body: { resumeSessionId: "sB-0000001" },
|
|
});
|
|
assert.equal(r.status, 404);
|
|
});
|
|
});
|