feat: v2.5.10 — server mode for a team, file drops in the prompt, tab joins

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.
This commit is contained in:
2026-10-07 14:11:25 +07:00
parent ca821008e2
commit 9a54978eef
112 changed files with 6509 additions and 436 deletions
+9
View File
@@ -265,3 +265,12 @@ describe("profileUsage", () => {
);
});
});
describe("liveOwner", () => {
// The profiles routes call it; v2.5.9 defined it but never exported it, so
// GET /api/cc-config/profiles answered "ccUsage.liveOwner is not a function".
it("is exported, and is null with no live login", async () => {
assert.equal(typeof usage.liveOwner, "function");
assert.equal(await usage.liveOwner(), null);
});
});
+153
View File
@@ -0,0 +1,153 @@
/**
* @file claude-pool.test.js
* @description The Claude account pool (server/lib/claude-pool.js): token
* validation and file modes, the balancer's order (full quota last, then fewest
* live panes, then least recent spend, expired accounts never), and spawnRun
* handing the pick to a Claude pane through tmux `-e CLAUDE_CODE_OAUTH_TOKEN`
* and recording it — but not for a Codex pane, and not with an empty pool.
* @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.mkdtempSync(path.join(os.tmpdir(), "claude-pool-test-"));
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
process.env.CLAUDE_HOME = path.join(TMP, "claude");
const { db } = require("../db");
const ccUsage = require("../lib/cc-usage");
const pool = require("../lib/claude-pool");
const tmux = require("../lib/tmux");
const pty = require("../lib/pty-run");
const TOKEN = (c) => `sk-ant-oat01-${c.repeat(40)}`;
const realFetchUsage = ccUsage.fetchUsage;
function run(id, account, { status = "completed", session = null, started } = {}) {
db.prepare(
`INSERT INTO dashboard_runs (id, session_id, mode, cwd, status, started_at, account)
VALUES (?, ?, 'interactive', '/tmp', ?, ?, ?)`
).run(id, session, status, started || new Date().toISOString(), account);
}
function spend(session, tokens) {
db.prepare(
"INSERT OR IGNORE INTO sessions (id, name, status, cwd) VALUES (?, ?, 'completed', '/tmp')"
).run(session, session);
db.prepare(
`INSERT INTO token_usage_hourly (session_id, hour, model, output_tokens)
VALUES (?, ?, 'claude-sonnet-4-5-20250929', ?)`
).run(session, new Date().toISOString().slice(0, 13), tokens);
}
before(() => {
// No network: every quota read answers "no reading" unless a test says otherwise.
ccUsage.fetchUsage = async () => {
throw Object.assign(new Error("offline"), { code: "EUNAUTHORIZED" });
};
});
after(() => {
ccUsage.fetchUsage = realFetchUsage;
tmux.__setExecImpl(null);
fs.rmSync(TMP, { recursive: true, force: true });
});
describe("claude pool", () => {
beforeEach(() => {
fs.rmSync(pool.poolDir(), { recursive: true, force: true });
db.prepare("DELETE FROM dashboard_runs").run();
db.prepare("DELETE FROM token_usage_hourly").run();
pool.__quotaCache.clear();
});
it("is empty until an admin adds an account, and stores it 0600", () => {
assert.equal(pool.pickAccount(), null);
assert.throws(() => pool.addAccount("a", "not-a-token"), { code: "EBADTOKEN" });
assert.throws(() => pool.addAccount("../x", TOKEN("a")), { code: "EBADNAME" });
const added = pool.addAccount("a", TOKEN("a"));
assert.ok(Date.parse(added.expiresAt) > Date.now() + 360 * 864e5);
assert.throws(() => pool.addAccount("a", TOKEN("b")), { code: "EEXISTS" });
const mode = fs.statSync(path.join(pool.poolDir(), "a.json")).mode & 0o777;
assert.equal(mode, 0o600);
assert.equal(fs.statSync(pool.poolDir()).mode & 0o777, 0o700);
assert.ok(
!JSON.stringify(pool.poolStatus()).includes(TOKEN("a")),
"status never shows a token"
);
pool.removeAccount("a");
assert.equal(pool.pickAccount(), null);
});
it("prefers fewer live panes, then less recent spend, and skips expired tokens", () => {
for (const n of ["a", "b", "c"]) pool.addAccount(n, TOKEN(n));
assert.equal(pool.pickAccount().name, "a");
run("r1", "a", { status: "running" });
assert.equal(pool.pickAccount().name, "b");
run("r2", "b", { session: "s-b" });
spend("s-b", 100000);
const picked = pool.pickAccount();
assert.equal(picked.name, "c");
assert.equal(picked.token, TOKEN("c"));
const status = Object.fromEntries(pool.poolStatus().map((a) => [a.name, a]));
assert.equal(status.a.livePanes, 1);
assert.ok(status.b.spend5h > 0 && status.b.spend7d >= status.b.spend5h);
assert.equal(status.c.spend5h, 0);
// c's token is a year old: never handed out.
const file = path.join(pool.poolDir(), "c.json");
const rec = JSON.parse(fs.readFileSync(file, "utf8"));
rec.addedAt = new Date(Date.now() - 366 * 864e5).toISOString();
fs.writeFileSync(file, JSON.stringify(rec));
assert.equal(pool.pickAccount().name, "b");
});
it("puts an account whose quota is nearly used up last", async () => {
for (const n of ["a", "b"]) pool.addAccount(n, TOKEN(n));
ccUsage.fetchUsage = async (token) => ({
fiveHour: { utilization: token === TOKEN("a") ? 95 : 10, resetsAt: null },
sevenDay: { utilization: 20, resetsAt: null },
});
try {
await pool.refreshQuotas({ force: true });
run("r1", "b", { status: "running" }); // b busier, but a is full
assert.equal(pool.pickAccount().name, "b");
assert.equal(pool.poolStatus().find((x) => x.name === "a").full, true);
} finally {
ccUsage.fetchUsage = async () => {
throw Object.assign(new Error("offline"), { code: "EUNAUTHORIZED" });
};
}
});
it("gives a Claude pane the picked account's token and records it", () => {
pool.addAccount("a", TOKEN("a"));
let argv = null;
tmux.__setExecImpl((args) => {
if (args[0] === "has-session") throw Object.assign(new Error("gone"), { status: 1 });
if (args[0] === "new-session") argv = args;
return "";
});
pty.spawnRun({ laneId: 7, cwd: "/tmp", tool: "claude" });
assert.ok(argv.includes(`CLAUDE_CODE_OAUTH_TOKEN=${TOKEN("a")}`));
assert.ok(argv.indexOf(`CLAUDE_CODE_OAUTH_TOKEN=${TOKEN("a")}`) < argv.indexOf("--"));
const row = db.prepare("SELECT account FROM dashboard_runs WHERE id = 'agenthub-lane-7'").get();
assert.equal(row.account, "a");
argv = null;
pty.spawnRun({ laneId: 8, cwd: "/tmp", tool: "codex" });
assert.ok(!argv.some((x) => String(x).startsWith("CLAUDE_CODE_OAUTH_TOKEN=")));
pool.removeAccount("a");
argv = null;
pty.spawnRun({ laneId: 9, cwd: "/tmp", tool: "claude" });
assert.ok(!argv.some((x) => String(x).startsWith("CLAUDE_CODE_OAUTH_TOKEN=")));
});
});
+16
View File
@@ -1319,6 +1319,22 @@ describe("lane ensure, start mode, lane_id and releasing a finished run", () =>
assert.equal(after.status, "idle");
});
it("healAllRunIds judges by the live-session set it is handed", async () => {
// The server's beat lists tmux once and passes the set, instead of one
// synchronous has-session per lane.
const lanesLib = require("../lib/lanes");
const { healAllRunIds } = require("../routes/lanes");
const lane = await adoptedLane("heal-all-live-set");
const runId = `agenthub-lane-${lane.id}`;
lanesLib.updateLane(lane.id, { run_id: runId, status: "running" });
healAllRunIds(new Set([runId]));
assert.equal(lanesLib.getLane(lane.id).run_id, runId);
healAllRunIds(new Set());
assert.equal(lanesLib.getLane(lane.id).run_id, null);
});
it("starts a session on the AI tool the request names, not the workspace's", async () => {
const lane = await adoptedLane("session-tool");
assert.equal(lane.tool, "claude");
+16
View File
@@ -442,6 +442,22 @@ describe("pty-run", () => {
);
});
it("listRuns asks tmux once, not once more per listed session", () => {
// Every tmux call is synchronous; the Workspace polls this every 5 s.
const calls = [];
tmux.__setExecImpl((args) => {
calls.push(args[0]);
if (args[0] === "list-sessions") return "agenthub-lane-1\nagenthub-lane-2\n";
return "";
});
const items = pty.listRuns();
assert.deepEqual(
items.map((r) => r.status),
["running", "running"]
);
assert.deepEqual(calls, ["list-sessions"]);
});
it("laneIdFromRunId parses the numeric lane id back out", () => {
assert.equal(pty.laneIdFromRunId("agenthub-lane-42"), 42);
assert.equal(pty.laneIdFromRunId("not-a-run-id"), null);
+25
View File
@@ -248,4 +248,29 @@ describe("POST /api/run/drop-image", () => {
});
assert.equal(r.status, 403);
});
it("drop-file saves any file under a random prefix and its sanitized name", async () => {
const name = encodeURIComponent("../../báo cáo Q3.pdf");
const r = await fetchJson(`/api/run/drop-file?name=${name}`, {
method: "POST",
raw: { type: "application/pdf", buf: Buffer.from("%PDF") },
});
assert.equal(r.status, 200);
assert.equal(
path.dirname(r.body.path),
path.join(os.tmpdir(), `agenthub-drops-${os.userInfo().uid}`)
);
assert.match(path.basename(r.body.path), /^[0-9a-f]{16}-b_o_c_o_Q3\.pdf$/);
assert.equal(fs.readFileSync(r.body.path, "utf8"), "%PDF");
assert.equal(fs.statSync(r.body.path).mode & 0o777, 0o600);
fs.unlinkSync(r.body.path);
});
it("drop-file without a name or a body is refused", async () => {
const r = await fetchJson("/api/run/drop-file", {
method: "POST",
raw: { type: "text/plain", buf: Buffer.alloc(0) },
});
assert.equal(r.status, 400);
});
});
+262
View File
@@ -0,0 +1,262 @@
/**
* @file scope.test.js
* @description Server mode's row ownership helpers (server/lib/scope.js): a
* member sees only sessions/agents/events/runs of the workspaces they own
* (stamped `sessions.lane_id`, or a `dashboard_runs` row of theirs holding the
* session), an admin/analyst/local request sees everything, and a member's
* workspace folder stays inside `AGENTHUB_USERS_ROOT/<username>`.
* @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("node:path");
const fs = require("node:fs");
const os = require("node:os");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "scope-test-"));
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
const { db } = require("../db");
const scope = require("../lib/scope");
const member = (id, username = `u${id}`) => ({ user: { id, username, role: "member" } });
const admin = { user: { id: 99, username: "boss", role: "admin" } };
const analyst = { user: { id: 98, username: "ana", role: "analyst" } };
const local = {};
before(() => {
const lane = db.prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES (?, 'claude', ?)");
lane.run("/w/one", 1); // id 1, user 1
lane.run("/w/two", 2); // id 2, user 2
lane.run("/w/host", null); // id 3, nobody's
const s = db.prepare(
"INSERT INTO sessions (id, name, status, cwd, lane_id) VALUES (?, ?, 'active', '/w', ?)"
);
s.run("s1", "one", 1);
s.run("s2", "two", 2);
s.run("s3", "host", 3);
s.run("s-unstamped", "pending", null);
s.run("s-free", "outside", null);
db.prepare(
"INSERT INTO dashboard_runs (id, session_id, mode, cwd, status, lane_id) VALUES ('agenthub-lane-1-2', 's-unstamped', 'interactive', '/w', 'running', 1)"
).run();
db.prepare(
"INSERT INTO agents (id, session_id, name, type) VALUES ('a2', 's2', 'Main', 'main')"
).run();
});
after(() => fs.rmSync(TMP, { recursive: true, force: true }));
function visibleSessions(req) {
const w = scope.sessionWhere(req);
return db
.prepare(`SELECT id FROM sessions WHERE ${w.where} ORDER BY id`)
.all(...w.params)
.map((r) => r.id);
}
describe("scope", () => {
it("shows a member only their own workspaces' sessions, unstamped ones included", () => {
assert.deepEqual(visibleSessions(member(1)), ["s-unstamped", "s1"]);
assert.deepEqual(visibleSessions(member(2)), ["s2"]);
assert.deepEqual(visibleSessions(member(3)), []);
});
it("shows admin, analyst and local mode everything", () => {
for (const req of [admin, analyst, local]) {
assert.equal(visibleSessions(req).length, 5);
assert.equal(scope.canSeeLane(req, 2), true);
assert.equal(scope.visibleLaneIds(req), null);
}
});
it("answers per-id checks for lanes, sessions, agents and runs", () => {
const m = member(1);
assert.equal(scope.canSeeLane(m, 1), true);
assert.equal(scope.canSeeLane(m, 2), false);
assert.equal(scope.canSeeLane(m, 3), false);
assert.equal(scope.canSeeLane(m, 404), false);
assert.equal(scope.canSeeSession(m, "s1"), true);
assert.equal(scope.canSeeSession(m, "s-unstamped"), true);
assert.equal(scope.canSeeSession(m, "s2"), false);
assert.equal(scope.canSeeSession(m, "nope"), false);
assert.equal(scope.canSeeAgent(m, "a2"), false);
assert.equal(scope.canSeeAgent(member(2), "a2"), true);
assert.equal(scope.canSeeRun(m, "agenthub-lane-1"), true);
assert.equal(scope.canSeeRun(m, "agenthub-lane-1-2"), true);
assert.equal(scope.canSeeRun(m, "agenthub-lane-2"), false);
assert.equal(scope.canSeeRun(m, "agenthub-lane-12"), false);
assert.deepEqual(scope.visibleLaneIds(m), [1]);
});
it("scopes tables keyed by session id", () => {
const w = scope.bySessionWhere(member(2));
const rows = db.prepare(`SELECT id FROM agents WHERE ${w.where}`).all(...w.params);
assert.deepEqual(
rows.map((r) => r.id),
["a2"]
);
const none = scope.bySessionWhere(member(1));
assert.equal(
db.prepare(`SELECT id FROM agents WHERE ${none.where}`).all(...none.params).length,
0
);
});
it("keeps a member's workspace folder inside their own root", () => {
process.env.AGENTHUB_USERS_ROOT = "/srv/agenthub/users";
try {
const m = member(1, "alice");
assert.equal(scope.folderAllowed(m, "/srv/agenthub/users/alice"), true);
assert.equal(scope.folderAllowed(m, "/srv/agenthub/users/alice/repo"), true);
assert.equal(scope.folderAllowed(m, "/srv/agenthub/users/alice/..x"), true);
assert.equal(scope.folderAllowed(m, "/srv/agenthub/users/bob"), false);
assert.equal(scope.folderAllowed(m, "/srv/agenthub/users/alice/../bob"), false);
assert.equal(scope.folderAllowed(m, "/srv/agenthub/users/alicex"), false);
assert.equal(scope.folderAllowed(admin, "/etc"), true);
} finally {
delete process.env.AGENTHUB_USERS_ROOT;
}
assert.equal(scope.folderAllowed(member(1), "/anywhere"), true);
});
it("never lets a request choose the owner", () => {
assert.equal(scope.ownerFor(member(7)), 7);
assert.equal(scope.ownerFor(local), null);
});
});
describe("scopeGuard", () => {
const run = (req) => {
let status = 200;
let passed = false;
const res = {
status(s) {
status = s;
return this;
},
json() {
return this;
},
};
scope.scopeGuard({ method: "GET", query: {}, body: {}, ...req }, res, () => (passed = true));
return passed ? 200 : status;
};
const m1 = member(1).user;
it("refuses a member another user's row named in the path", () => {
assert.equal(run({ user: m1, path: "/sessions/s1" }), 200);
assert.equal(run({ user: m1, path: "/sessions/s2" }), 404);
assert.equal(run({ user: m1, path: "/sessions/s2/transcript" }), 404);
assert.equal(run({ user: m1, path: "/sessions/facets" }), 200);
assert.equal(run({ user: m1, path: "/agents/a2" }), 404);
assert.equal(run({ user: m1, path: "/lanes/1/git/changes" }), 200);
assert.equal(run({ user: m1, path: "/workspaces/2/start", method: "POST" }), 404);
assert.equal(run({ user: m1, path: "/lanes/3" }), 404);
assert.equal(run({ user: m1, path: "/lanes/ensure", method: "POST" }), 200);
assert.equal(run({ user: m1, path: "/run/agenthub-lane-2-3" }), 404);
assert.equal(run({ user: m1, path: "/run/agenthub-lane-1" }), 200);
assert.equal(run({ user: m1, path: "/pricing/cost/s2" }), 404);
assert.equal(run({ user: m1, path: "/sessions/s%32" }), 404); // encoded "s2"
});
it("refuses a member another user's row named in the query or body", () => {
assert.equal(run({ user: m1, path: "/sessions", query: { lane: "2" } }), 404);
assert.equal(run({ user: m1, path: "/sessions", query: { lane: "1" } }), 200);
assert.equal(run({ user: m1, path: "/agents", query: { session_ids: "s1,s2" } }), 404);
assert.equal(run({ user: m1, path: "/run", method: "POST", body: { laneId: 2 } }), 404);
assert.equal(
run({ user: m1, path: "/lanes/1/start", method: "POST", body: { resumeSessionId: "s2" } }),
404
);
assert.equal(run({ user: m1, path: "/lanes/1/git/changes", query: { session: "s2" } }), 404);
assert.equal(run({ user: m1, path: "/sessions", query: { lane: "1 OR 1" } }), 404);
// `?session_id[]=…` / `?lane[]=…` arrive as arrays, and a JSON body may
// carry objects: a member's request with one is refused outright.
assert.equal(run({ user: m1, path: "/agents", query: { session_id: ["s2"] } }), 404);
assert.equal(run({ user: m1, path: "/agents", query: { session_ids: ["s1", "s2"] } }), 404);
assert.equal(run({ user: m1, path: "/sessions", query: { lane: ["1", "2"] } }), 404);
assert.equal(
run({ user: m1, path: "/agents", method: "POST", body: { session_id: { $ne: 1 } } }),
404
);
});
it("lets admin, analyst and local requests through", () => {
for (const user of [admin.user, analyst.user, undefined]) {
assert.equal(run({ user, path: "/sessions/s2" }), 200);
assert.equal(run({ user, path: "/run", method: "POST", body: { laneId: 2 } }), 200);
}
});
});
describe("broadcastAudience", () => {
const m1 = member(1).user;
const m2 = member(2).user;
const reaches = (type, data, user) =>
scope.audienceIncludes(scope.broadcastAudience(type, data), user);
it("sends a session's updates only to its owner (and staff)", () => {
assert.equal(reaches("session_updated", { id: "s1" }, m1), true);
assert.equal(reaches("session_updated", { id: "s1" }, m2), false);
assert.equal(reaches("session_updated", { id: "s-unstamped" }, m1), true);
assert.equal(reaches("new_event", { session_id: "s2" }, m2), true);
assert.equal(reaches("new_event", { session_id: "s2" }, m1), false);
assert.equal(reaches("agent_updated", { session_id: "s3" }, m1), false);
assert.equal(reaches("new_event", { session_id: "s2" }, admin.user), true);
assert.equal(reaches("new_event", { session_id: "s2" }, undefined), true);
});
it("routes workspace messages by owner and keeps unknown types from members", () => {
assert.equal(reaches("lane_update", { lane: { id: 1 } }, m1), true);
assert.equal(reaches("lane_update", { lane: { id: 1 } }, m2), false);
assert.equal(reaches("lane_update", { removed: 2 }, m1), true);
assert.equal(reaches("lane_runtime", { laneId: 2 }, m1), false);
assert.equal(reaches("alert_triggered", { session_id: null }, m1), false);
assert.equal(reaches("alert_triggered", { session_id: null }, analyst.user), true);
assert.equal(reaches("update_status", {}, m1), true);
assert.equal(reaches("import.progress", {}, m1), false);
assert.equal(reaches("some_future_type", {}, m1), false);
});
});
describe("scopeGuard against the router's own leniency", () => {
const run = (req) => {
let status = 200;
let passed = false;
const res = {
status(s) {
status = s;
return this;
},
json() {
return this;
},
};
scope.scopeGuard({ method: "GET", query: {}, body: {}, ...req }, res, () => (passed = true));
return passed ? 200 : status;
};
const m1 = member(1).user;
it("matches case-insensitively and on decoded segments, like Express routes", () => {
assert.equal(run({ user: m1, path: "/Lanes/2" }), 404);
assert.equal(run({ user: m1, path: "/WORKSPACES/2/start" }), 404);
assert.equal(run({ user: m1, path: "/lanes/%32" }), 404);
assert.equal(run({ user: m1, path: "/Sessions/s2" }), 404);
assert.equal(run({ user: m1, path: "//sessions/s2" }), 404);
assert.equal(run({ user: m1, path: "/run/%61genthub-lane-2" }), 404);
assert.equal(run({ user: m1, path: "/Pricing/Cost/s2" }), 404);
});
it("reads a lane id the way SQLite will, and fails closed on odd ones", () => {
assert.equal(run({ user: m1, path: "/lanes/2.0" }), 404);
assert.equal(run({ user: m1, path: "/lanes/02" }), 404);
assert.equal(run({ user: m1, path: "/lanes/01" }), 200);
assert.equal(run({ user: m1, path: "/lanes/1.0" }), 404);
assert.equal(run({ user: m1, path: "/lanes/1e1/sources" }), 404);
assert.equal(run({ user: m1, path: "/lanes/abc" }), 404);
assert.equal(run({ user: m1, path: "/lanes/Ensure", method: "POST" }), 200);
assert.equal(run({ user: m1, path: "/lanes/pipeline-stats" }), 200);
});
});
@@ -0,0 +1,209 @@
/**
* @file server-mode-aggregates.test.js
* @description Server mode's row ownership on the aggregate endpoints: GET
* /api/stats, /api/analytics, /api/query (pages and CSV export),
* /api/pricing/cost, /api/budgets/status and /api/ai-tools/:id/usage count,
* total and price only a member's own sessions, while an admin, an analyst and
* local mode keep seeing every session.
* @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("node:path");
const fs = require("node:fs");
const os = require("node:os");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "server-mode-aggregates-"));
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
process.env.DASHBOARD_LIVENESS_PROBE = "0";
process.env.AGENTHUB_PRICING_SYNC = "0";
const { createApp, startServer } = require("../index");
const { db, stmts } = require("../db");
const auth = require("../lib/auth");
let server;
let BASE;
const cookies = {};
const lanes = {};
async function get(p, cookie) {
const res = await fetch(BASE + p, { headers: cookie ? { cookie } : {} });
const text = await res.text();
assert.equal(res.status, 200, `${p}: ${text}`);
return res.headers.get("content-type").includes("json") ? JSON.parse(text) : text;
}
async function login(username, password) {
const res = await fetch(BASE + "/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
assert.equal(res.status, 200);
return res.headers.get("set-cookie").split(";")[0];
}
// Each session spends a distinct number of MTok at $1 each, so every total
// says exactly which sessions it counted.
const SESSIONS = [
// id, tool, stamped lane, MTok
["agg-a", "claude", "mia", 1],
["agg-b", "codex", "max", 2],
["agg-host", "claude", null, 4],
["agg-run", "claude", null, 8], // unstamped, held by a run of mia's workspace
];
before(async () => {
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
const mia = await auth.createUser("mia", "member-pass-1", "member");
const max = await auth.createUser("max", "member-pass-2", "member");
await auth.createUser("boss", "admin-pass-1", "admin");
await auth.createUser("ana", "analyst-pass-1", "analyst");
const lane = db.prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES (?, 'claude', ?)");
lanes.mia = Number(lane.run("/agg/mia", mia.id).lastInsertRowid);
lanes.max = Number(lane.run("/agg/max", max.id).lastInsertRowid);
db.prepare(
"INSERT INTO model_pricing (model_pattern, display_name, input_per_mtok, output_per_mtok, cache_read_per_mtok, cache_write_per_mtok) VALUES ('agg-model%', 'Agg', 1, 0, 0, 0)"
).run();
for (const [id, tool, owner, mtok] of SESSIONS) {
db.prepare(
"INSERT INTO sessions (id, name, status, cwd, tool, lane_id, updated_at) VALUES (?, ?, 'active', '/agg', ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))"
).run(id, id, tool, owner ? lanes[owner] : null);
db.prepare(
"INSERT INTO token_usage (session_id, model, input_tokens) VALUES (?, 'agg-model', ?)"
).run(id, mtok * 1e6);
db.prepare("INSERT INTO agents (id, session_id, name, type) VALUES (?, ?, 'Main', 'main')").run(
`${id}-main`,
id
);
db.prepare(
"INSERT INTO events (session_id, event_type, tool_name, summary) VALUES (?, 'PreToolUse', 'Bash', 'x')"
).run(id);
}
db.prepare(
"INSERT INTO dashboard_runs (id, session_id, mode, cwd, status, lane_id) VALUES (?, 'agg-run', 'interactive', '/agg', 'exited', ?)"
).run(`agenthub-lane-${lanes.mia}@old`, lanes.mia);
stmts.insertAlertRule.run(
"agg-budget",
"Org budget",
"budget",
JSON.stringify({ period: "month", limit_usd: 100, thresholds: [50] }),
1,
0
);
process.env.AGENTHUB_MODE = "server";
cookies.mia = await login("mia", "member-pass-1");
cookies.max = await login("max", "member-pass-2");
cookies.boss = await login("boss", "admin-pass-1");
cookies.ana = await login("ana", "analyst-pass-1");
});
after(() => {
delete process.env.AGENTHUB_MODE;
server?.close();
fs.rmSync(TMP, { recursive: true, force: true });
});
/** Every aggregate one viewer gets, reduced to the numbers that tell rows apart. */
async function numbers(cookie) {
const stats = await get("/api/stats", cookie);
const analytics = await get("/api/analytics", cookie);
const ranged = await get("/api/analytics?from=2000-01-01&to=2999-12-31", cookie);
const sessions = await get("/api/query?entity=sessions", cookie);
const events = await get("/api/query?entity=events", cookie);
const agents = await get("/api/query?entity=agents", cookie);
const csv = await get("/api/query?entity=sessions&format=csv", cookie);
const cost = await get("/api/pricing/cost", cookie);
const claudeCost = await get("/api/pricing/cost?tool=claude", cookie);
const budgets = await get("/api/budgets/status", cookie);
const usage = await get("/api/ai-tools/claude/usage", cookie);
return {
stats: [stats.total_sessions, stats.total_agents, stats.total_events],
analytics: [analytics.overview.total_sessions, analytics.tokens.total_input / 1e6],
analyticsCost: analytics.total_cost,
rangedCost: ranged.total_cost,
query: [sessions.total, events.total, agents.total],
queryIds: sessions.rows.map((r) => r.id).sort(),
csvRows: csv.trim().split("\r\n").length - 1,
cost: cost.total_cost,
claudeCost: claudeCost.total_cost,
budget: budgets.budgets.find((b) => b.id === "agg-budget").spend,
usage: [usage.sessions, usage.cost],
};
}
const EVERYONE = {
stats: [4, 4, 4],
analytics: [4, 15],
analyticsCost: 15,
rangedCost: 15,
query: [4, 4, 4],
queryIds: ["agg-a", "agg-b", "agg-host", "agg-run"],
csvRows: 4,
cost: 15,
claudeCost: 13,
budget: 15,
usage: [3, 13],
};
describe("aggregates in server mode", () => {
it("count a member's own sessions only, unstamped ones their runs hold included", async () => {
assert.deepEqual(await numbers(cookies.mia), {
stats: [2, 2, 2],
analytics: [2, 9],
analyticsCost: 9,
rangedCost: 9,
query: [2, 2, 2],
queryIds: ["agg-a", "agg-run"],
csvRows: 2,
cost: 9,
claudeCost: 9,
budget: 9,
usage: [2, 9],
});
});
it("give another member theirs", async () => {
assert.deepEqual(await numbers(cookies.max), {
stats: [1, 1, 1],
analytics: [1, 2],
analyticsCost: 2,
rangedCost: 2,
query: [1, 1, 1],
queryIds: ["agg-b"],
csvRows: 1,
cost: 2,
claudeCost: 0,
budget: 2,
usage: [0, 0],
});
});
it("show an admin and an analyst everything", async () => {
assert.deepEqual(await numbers(cookies.boss), EVERYONE);
assert.deepEqual(await numbers(cookies.ana), EVERYONE);
});
it("refuse a member who names another member's workspace", async () => {
const res = await fetch(`${BASE}/api/pricing/cost?lane=${lanes.max}`, {
headers: { cookie: cookies.mia },
});
assert.equal(res.status, 404);
});
});
describe("aggregates in local mode", () => {
it("are unchanged: every session, no login", async () => {
delete process.env.AGENTHUB_MODE;
try {
assert.deepEqual(await numbers(), EVERYONE);
} finally {
process.env.AGENTHUB_MODE = "server";
}
});
});
+231
View File
@@ -0,0 +1,231 @@
/**
* @file server-mode-auth.test.js
* @description Server mode's login gate (server/lib/auth.js, routes/auth.js):
* local mode untouched, every API call behind a login, roles (analyst
* read-only, admin-only config writes), hooks from this machine only, logout
* and disable ending a login, the failed-login throttle, the WebSocket check,
* and the same-origin guard accepting the dashboard's own allowed Host.
* @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-test-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
process.env.DASHBOARD_LIVENESS_PROBE = "0";
const { createApp, startServer } = require("../index");
const auth = require("../lib/auth");
const { sameOriginGuard } = require("../lib/same-origin");
let server;
let BASE;
async function call(method, p, { cookie, body, headers = {} } = {}) {
const res = await fetch(BASE + p, {
method,
headers: {
"Content-Type": "application/json",
...(cookie ? { cookie } : {}),
...headers,
},
body: body ? JSON.stringify(body) : undefined,
});
const text = await res.text();
let json = null;
try {
json = JSON.parse(text);
} catch {
/* not JSON */
}
return { status: res.status, body: json, setCookie: res.headers.get("set-cookie") };
}
async function loginAs(username, password) {
const r = await call("POST", "/api/auth/login", { body: { username, password } });
assert.equal(r.status, 200, JSON.stringify(r.body));
return r.setCookie.split(";")[0];
}
before(async () => {
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
await auth.createUser("boss", "admin-pass-1", "admin");
await auth.createUser("mia", "member-pass-1", "member");
await auth.createUser("ana", "analyst-pass-1", "analyst");
});
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 });
}
});
describe("local mode (the default)", () => {
it("needs no login and says it is local", async () => {
delete process.env.AGENTHUB_MODE;
assert.equal((await call("GET", "/api/sessions")).status, 200);
assert.deepEqual((await call("GET", "/api/auth/me")).body, { mode: "local" });
assert.equal((await call("POST", "/api/auth/login", { body: {} })).status, 404);
});
});
describe("server mode", () => {
before(() => {
process.env.AGENTHUB_MODE = "server";
});
it("refuses the API without a login, but answers health and /auth/me", async () => {
const r = await call("GET", "/api/sessions");
assert.equal(r.status, 401);
assert.equal(r.body.error.code, "EAUTH");
assert.equal((await call("GET", "/api/health")).status, 200);
assert.deepEqual((await call("GET", "/api/auth/me")).body, { mode: "server", user: null });
});
it("rejects a wrong password and sets an httpOnly strict cookie on a right one", async () => {
const bad = await call("POST", "/api/auth/login", { body: { username: "mia", password: "x" } });
assert.equal(bad.status, 401);
assert.equal(bad.body.error.code, "EBADLOGIN");
const ok = await call("POST", "/api/auth/login", {
body: { username: "MIA", password: "member-pass-1" },
});
assert.equal(ok.status, 200);
assert.equal(ok.body.user.username, "mia");
assert.match(ok.setCookie, /agenthub_sid=[^;]+; Path=\/; HttpOnly; SameSite=Strict/);
const cookie = ok.setCookie.split(";")[0];
assert.equal((await call("GET", "/api/sessions", { cookie })).status, 200);
assert.equal((await call("GET", "/api/auth/me", { cookie })).body.user.role, "member");
});
it("keeps an analyst read-only and the host's config writes an admin's", async () => {
const analyst = await loginAs("ana", "analyst-pass-1");
assert.equal((await call("GET", "/api/sessions", { cookie: analyst })).status, 200);
const w = await call("POST", "/api/lanes/ensure", { cookie: analyst, body: {} });
assert.equal(w.status, 403);
assert.equal(w.body.error.code, "EREADONLY");
const member = await loginAs("mia", "member-pass-1");
// A path no router answers: the guard decides before any handler could
// write the real settings file.
const m = await call("PUT", "/api/settings/no-such-route", { cookie: member, body: {} });
assert.equal(m.status, 403);
assert.equal(m.body.error.code, "EADMIN");
const admin = await loginAs("boss", "admin-pass-1");
const a = await call("PUT", "/api/settings/no-such-route", { cookie: admin, body: {} });
assert.equal(a.status, 404);
});
it("takes hooks from this machine only", async () => {
const remote = await call("POST", "/api/hooks/event", {
body: {},
headers: { "x-forwarded-for": "203.0.113.9" },
});
assert.equal(remote.status, 403);
assert.equal(remote.body.error.code, "ELOCALONLY");
const local = await call("POST", "/api/hooks/event", { body: {} });
assert.ok(![401, 403].includes(local.status), `got ${local.status}`);
});
it("ends a login on logout and on disable", async () => {
const one = await loginAs("mia", "member-pass-1");
await call("POST", "/api/auth/logout", { cookie: one });
assert.equal((await call("GET", "/api/sessions", { cookie: one })).status, 401);
const two = await loginAs("mia", "member-pass-1");
auth.setDisabled("mia", true);
assert.equal((await call("GET", "/api/sessions", { cookie: two })).status, 401);
const again = await call("POST", "/api/auth/login", {
body: { username: "mia", password: "member-pass-1" },
});
assert.equal(again.status, 401);
auth.setDisabled("mia", false);
});
it("checks a WebSocket upgrade's login and Origin", async () => {
const cookie = await loginAs("boss", "admin-pass-1");
const req = (headers, url = "/ws") => ({ url, headers: { host: "127.0.0.1:1", ...headers } });
assert.equal(auth.isWebSocketLoggedIn(req({})), false);
assert.equal(auth.isWebSocketLoggedIn(req({ cookie })), true);
assert.equal(auth.isWebSocketLoggedIn(req({ cookie, origin: "https://evil.example" })), false);
const analyst = await loginAs("ana", "analyst-pass-1");
assert.equal(auth.isWebSocketLoggedIn(req({ cookie: analyst })), true);
assert.equal(
auth.isWebSocketLoggedIn(req({ cookie: analyst }, "/ws-pty/agenthub-lane-1")),
false
);
});
// Last: it locks this test's address out of logging in.
it("throttles repeated failed logins", async () => {
let last;
for (let i = 0; i < 11; i++) {
last = await call("POST", "/api/auth/login", { body: { username: "boss", password: "no" } });
}
assert.equal(last.status, 429);
assert.equal(last.body.error.code, "ETOOMANY");
});
});
describe("sameOriginGuard", () => {
const run = (headers) => {
let status = 200;
let passed = false;
const res = {
status(s) {
status = s;
return this;
},
json() {
return this;
},
};
sameOriginGuard({ headers }, res, () => (passed = true));
return passed ? 200 : status;
};
it("accepts the dashboard's own allowed Host as an Origin", () => {
process.env.DASHBOARD_ALLOWED_HOSTS = "agenthub.example.com";
try {
assert.equal(
run({ origin: "https://agenthub.example.com", host: "agenthub.example.com" }),
200
);
assert.equal(run({ origin: "https://evil.example", host: "agenthub.example.com" }), 403);
assert.equal(run({ origin: "https://evil.example", host: "evil.example" }), 403);
assert.equal(run({ origin: "http://localhost:4820", host: "localhost:4820" }), 200);
assert.equal(run({}), 200);
} finally {
delete process.env.DASHBOARD_ALLOWED_HOSTS;
}
});
});
describe("server mode prefix checks follow Express routing", () => {
before(() => {
process.env.AGENTHUB_MODE = "server";
});
it("refuses a member a staff-only router reached by case or encoding", async () => {
const r = await call("POST", "/api/auth/login", {
body: { username: "mia", password: "member-pass-1" },
headers: { "x-forwarded-for": "198.51.100.7" },
});
const cookie = r.setCookie.split(";")[0];
for (const p of ["/api/webhooks", "/api/Webhooks", "/api/%77ebhooks", "/api//webhooks"]) {
const res = await call("GET", p, { cookie });
assert.ok([403, 404].includes(res.status), `${p} answered ${res.status}`);
}
const hook = await call("POST", "/api/Hooks/event", {
body: {},
headers: { "x-forwarded-for": "203.0.113.9" },
});
assert.equal(hook.status, 403);
});
});
@@ -0,0 +1,352 @@
/**
* @file server-mode-review-fixes.test.js
* @description Regressions for the server-mode review's confirmed defects: a
* member cannot read another user's agent through `POST /api/agents`, cannot
* PATCH the pane/session columns of a workspace, cannot open the account pool;
* a transcript `agent_id`/`run_id` cannot walk out of its folder (any mode);
* push subscriptions belong to the user who made them, and a member's quota
* view names no account.
* @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("node:path");
const fs = require("node:fs");
const os = require("node:os");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "server-mode-fixes-"));
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
process.env.DASHBOARD_LIVENESS_PROBE = "0";
// Named locks live under LANES_ROOT/.locks — never the real one.
process.env.LANES_ROOT = path.join(TMP, "lanes");
const { createApp, startServer } = require("../index");
const { db } = require("../db");
const auth = require("../lib/auth");
let server;
let BASE;
let laneMia;
let laneMax;
async function call(method, p, { cookie, body } = {}) {
const res = await fetch(BASE + p, {
method,
headers: { "Content-Type": "application/json", ...(cookie ? { cookie } : {}) },
body: body ? JSON.stringify(body) : undefined,
});
return { status: res.status, body: await res.json().catch(() => null) };
}
async function login(username, password) {
const res = await fetch(BASE + "/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
assert.equal(res.status, 200);
return res.headers.get("set-cookie").split(";")[0];
}
before(async () => {
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
const mia = await auth.createUser("mia", "member-pass-1", "member");
const max = await auth.createUser("max", "member-pass-2", "member");
await auth.createUser("boss", "admin-pass-1", "admin");
const lane = db.prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES (?, 'claude', ?)");
laneMia = lane.run("/w/mia", mia.id).lastInsertRowid;
laneMax = lane.run("/w/max", max.id).lastInsertRowid;
db.prepare(
"INSERT INTO sessions (id, name, status, cwd, lane_id) VALUES ('s-max', 'max', 'active', '/w/max', ?)"
).run(laneMax);
db.prepare(
"INSERT INTO agents (id, session_id, name, type) VALUES ('a-max', 's-max', 'Main', 'main')"
).run();
});
after(() => {
delete process.env.AGENTHUB_MODE;
server?.close();
fs.rmSync(TMP, { recursive: true, force: true });
});
describe("server mode review fixes", () => {
before(() => {
process.env.AGENTHUB_MODE = "server";
});
it("does not hand back another user's agent from POST /api/agents", async () => {
const mia = await login("mia", "member-pass-1");
const r = await call("POST", "/api/agents", {
cookie: mia,
body: { id: "a-max", session_id: `s-mia-${laneMia}`, name: "x" },
});
assert.equal(r.status, 404);
assert.ok(!JSON.stringify(r.body).includes("s-max"));
});
it("lets a member PATCH only a workspace's typed fields", async () => {
const mia = await login("mia", "member-pass-1");
const bad = await call("PATCH", `/api/lanes/${laneMia}`, {
cookie: mia,
body: { run_id: `agenthub-lane-${laneMax}` },
});
assert.equal(bad.status, 403);
assert.equal(db.prepare("SELECT run_id FROM lanes WHERE id = ?").get(laneMia).run_id, null);
const ok = await call("PATCH", `/api/lanes/${laneMia}`, {
cookie: mia,
body: { title: "Mine" },
});
assert.equal(ok.status, 200);
const admin = await login("boss", "admin-pass-1");
const staff = await call("PATCH", `/api/lanes/${laneMia}`, {
cookie: admin,
body: { notes: "x", status: "idle" },
});
assert.equal(staff.status, 200);
});
it("keeps the account pool and account names from members", async () => {
const mia = await login("mia", "member-pass-1");
assert.equal((await call("GET", "/api/accounts", { cookie: mia })).status, 403);
const q = await call("GET", "/api/pricing/quota", { cookie: mia });
assert.equal(q.status, 200);
for (const t of q.body.tools || []) assert.equal(t.account, null);
});
it("scopes push subscriptions to their user", async () => {
const mia = await login("mia", "member-pass-1");
const max = await login("max", "member-pass-2");
const sub = (endpoint, cookie) =>
call("POST", "/api/push/subscribe", {
cookie,
body: { endpoint, keys: { p256dh: "p", auth: "a" } },
});
await sub("https://push.example/mia", mia);
await sub("https://push.example/max", max);
const owners = db
.prepare("SELECT endpoint, user_id FROM push_subscriptions ORDER BY endpoint")
.all()
.map((r) => [r.endpoint.split("/").pop(), r.user_id]);
assert.deepEqual(
owners.map(([e]) => e),
["max", "mia"]
);
assert.notEqual(owners[0][1], owners[1][1]);
// max cannot unsubscribe mia's browser.
await call("DELETE", "/api/push/subscribe", {
cookie: max,
body: { endpoint: "https://push.example/mia" },
});
assert.equal(db.prepare("SELECT COUNT(*) AS n FROM push_subscriptions").get().n, 2);
});
});
describe("transcript ids (every mode)", () => {
it("refuses an agent_id or run_id that is a path", async () => {
delete process.env.AGENTHUB_MODE;
for (const q of ["agent_id=../../x", "run_id=..%2F..%2Fx", "agent_id=a%2Fb"]) {
const r = await call("GET", `/api/sessions/s-max/transcript?${q}`);
assert.equal(r.status, 400, q);
assert.equal(r.body.error.code, "EBADID");
}
const ok = await call("GET", "/api/sessions/s-max/transcript?agent_id=a-max");
assert.equal(ok.status, 200);
});
});
describe("server mode review fixes, second round", () => {
before(() => {
process.env.AGENTHUB_MODE = "server";
});
it("refuses a write whose Origin is not the dashboard's own", async () => {
const mia = await login("mia", "member-pass-1");
const res = await fetch(`${BASE}/api/lanes/${laneMia}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
cookie: mia,
origin: "https://wiki.example.com",
},
body: JSON.stringify({ title: "csrf" }),
});
assert.equal(res.status, 403);
assert.equal((await res.json()).error.code, "EBADORIGIN");
const own = await fetch(`${BASE}/api/lanes/${laneMia}`, {
method: "PATCH",
headers: { "Content-Type": "application/json", cookie: mia, origin: BASE },
body: JSON.stringify({ title: "fine" }),
});
assert.equal(own.status, 200);
});
it("counts parallel failed logins against the limit", async () => {
const tries = await Promise.all(
Array.from({ length: 25 }, () =>
fetch(`${BASE}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json", "x-forwarded-for": "192.0.2.55" },
body: JSON.stringify({ username: "max", password: "wrong" }),
}).then((r) => r.status)
)
);
assert.ok(tries.filter((s) => s === 401).length <= 10, JSON.stringify(tries));
assert.ok(tries.includes(429));
});
it("refuses a member a workspace that overlaps another user's, for any tool", async () => {
const mia = await login("mia", "member-pass-1");
for (const body of [{ cwd: "/w/max", tool: "codex" }, { cwd: "/w/max/sub" }, { cwd: "/w" }]) {
const r = await call("POST", "/api/lanes", { cookie: mia, body });
assert.equal(r.status, 409, JSON.stringify(body));
assert.equal(r.body.error.code, "EOWNED");
}
});
it("keeps members from planting sessions and from the shared pet store", async () => {
const mia = await login("mia", "member-pass-1");
const s = await call("POST", "/api/sessions", {
cookie: mia,
body: { id: "fake-1", name: "x" },
});
assert.equal(s.status, 403);
assert.equal(db.prepare("SELECT COUNT(*) AS n FROM sessions WHERE id = 'fake-1'").get().n, 0);
const p = await call("DELETE", "/api/pets/some-pet", { cookie: mia });
assert.equal(p.status, 403);
});
it("lets a live pane's CLI act as its workspace owner, and nothing else", async () => {
const { execFileSync } = require("node:child_process");
const crypto = require("node:crypto");
const run = `agenthub-lane-${laneMia}`;
const token = "ahpane_test-token-value-0123456789";
db.prepare(
`INSERT OR REPLACE INTO dashboard_runs (id, mode, cwd, status, lane_id, pane_token_hash)
VALUES (?, 'interactive', '/w/mia', 'running', ?, ?)`
).run(run, laneMia, crypto.createHash("sha256").update(token).digest("hex"));
const asPane = (p, extra = {}) =>
fetch(BASE + p, { headers: { "x-agenthub-pane-token": token, ...extra } }).then(
(r) => r.status
);
// No live tmux session yet: the credential is dead.
assert.equal(await asPane("/api/sessions"), 401);
execFileSync("tmux", ["new-session", "-d", "-s", run, "sleep", "120"]);
try {
assert.equal(await asPane("/api/sessions"), 200);
assert.equal(await asPane(`/api/lanes/${laneMax}`), 404); // still a member
assert.equal(await asPane("/api/sessions", { "x-forwarded-for": "203.0.113.1" }), 401);
assert.equal(
await fetch(BASE + "/api/sessions", {
headers: { "x-agenthub-pane-token": "ahpane_wrong" },
}).then((r) => r.status),
401
);
} finally {
execFileSync("tmux", ["kill-session", "-t", `=${run}`]);
}
});
it("sends a member the first frames of a session their own pane starts", async () => {
const WebSocket = require("ws");
const cookie = await login("mia", "member-pass-1");
const run = `agenthub-lane-${laneMia}-3`;
db.prepare(
`INSERT OR REPLACE INTO dashboard_runs (id, mode, cwd, status, lane_id)
VALUES (?, 'interactive', '/w/mia', 'running', ?)`
).run(run, laneMia);
const ws = new WebSocket(BASE.replace("http", "ws") + "/ws", { headers: { cookie } });
const seen = [];
ws.on("message", (m) => seen.push(JSON.parse(String(m))));
await new Promise((resolve, reject) => ws.on("open", resolve).on("error", reject));
try {
// The session is new: it has no workspace until bindRunSession stamps
// it, so frames sent before that stamp reached no member.
const res = await call("POST", "/api/hooks/event", {
body: {
hook_type: "SessionStart",
run_id: run,
data: { session_id: "s-new-mia", cwd: "/w/mia", source: "startup" },
},
});
assert.equal(res.status, 200);
await new Promise((r) => setTimeout(r, 300));
const mine = seen.filter((m) => JSON.stringify(m.data || {}).includes("s-new-mia"));
assert.ok(
mine.some((m) => m.type === "new_event"),
`got ${seen.map((m) => m.type).join(", ")}`
);
} finally {
ws.close();
}
});
it("sends members the config-change signal without its payload", () => {
const scope = require("../lib/scope");
const a = scope.broadcastAudience("cc_config_changed", { type: "profile", name: "boss@x" });
assert.equal(a.strip, true);
assert.equal(scope.audienceIncludes(a, { id: 1, role: "member" }), true);
});
});
describe("named locks", () => {
it("never takes a path for a lock name (any mode)", () => {
const namedLock = require("../lib/named-lock");
for (const bad of ["../x", "..", ".", "a/b", "a\\b", "", "x".repeat(256)]) {
assert.throws(() => namedLock.tryAcquire(bad, "me"), { code: "EBADNAME" }, bad);
assert.throws(() => namedLock.release(bad, "me"), { code: "EBADNAME" }, bad);
}
assert.throws(() => namedLock.tryAcquire("ok", " "), { code: "EBADHOLDER" });
// A holder with spaces keeps working — and now really holds the lock.
assert.deepEqual(namedLock.tryAcquire("ci", "ci bot"), { acquired: true });
assert.equal(namedLock.tryAcquire("ci", "other").acquired, false);
namedLock.release("ci", "ci bot");
// Names that were always valid directory names keep working.
for (const good of ["db:migrate", "build web", "-x"]) {
assert.deepEqual(namedLock.tryAcquire(good, "me"), { acquired: true }, good);
namedLock.release(good, "me");
}
});
it("gives each member their own lock namespace", async () => {
process.env.AGENTHUB_MODE = "server";
const mia = await login("mia", "member-pass-1");
const max = await login("max", "member-pass-2");
const take = (cookie, holder) =>
call("POST", "/api/locks/deploy/acquire", { cookie, body: { holder } });
assert.equal((await take(mia, "mia-agent")).status, 200);
assert.equal((await take(max, "max-agent")).status, 200); // not mia's lock
const seen = (await call("GET", "/api/locks", { cookie: max })).body.locks;
assert.deepEqual(
seen.map((l) => [l.name, l.holder]),
[["deploy", "max-agent"]]
);
const bad = await call("POST", "/api/locks/..%2F..%2Fx/acquire", {
cookie: mia,
body: { holder: "h" },
});
assert.ok([400, 404].includes(bad.status));
await call("POST", "/api/locks/deploy/release", { cookie: mia, body: { holder: "mia-agent" } });
await call("POST", "/api/locks/deploy/release", { cookie: max, body: { holder: "max-agent" } });
});
});
describe("data export", () => {
it("never writes a pane credential's hash into a bundle", async () => {
delete process.env.AGENTHUB_MODE;
db.prepare(
`INSERT OR REPLACE INTO dashboard_runs (id, mode, cwd, status, pane_token_hash)
VALUES ('agenthub-lane-999@x', 'interactive', '/w', 'killed', 'deadbeefhash')`
).run();
const text = await (await fetch(`${BASE}/api/settings/export`)).text();
assert.ok(text.includes("agenthub-lane-999@x"));
assert.ok(!text.includes("deadbeefhash"));
const { buildExportBundle } = require("../lib/data-transfer");
const bundle = buildExportBundle(db, require("../db").stmts);
assert.ok(bundle.dashboard_runs.some((r) => r.id === "agenthub-lane-999@x"));
assert.ok(!JSON.stringify(bundle).includes("deadbeefhash"));
});
});
+253
View File
@@ -0,0 +1,253 @@
/**
* @file server-mode-rows.test.js
* @description Server mode's per-user row ownership on the list and aggregate
* routes (phase S2): sessions (+ facets, POST), agents, events (+ facets),
* snapshots, the alert feed and ack-all, and the settings info counts. A
* member gets only their own workspaces' 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-rows-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
process.env.DASHBOARD_LIVENESS_PROBE = "0";
const { createApp, startServer } = require("../index");
const { db } = require("../db");
const auth = require("../lib/auth");
let server;
let BASE;
const cookies = {};
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 ids = (rows, key = "id") => rows.map((r) => r[key]).sort();
const ALL_SESSIONS = ["sA", "sA2", "sB", "sHost"];
before(async () => {
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 (cwd, tool, owner_id) VALUES (?, 'claude', ?)");
const laneA = lane.run("/w/alice", alice.id).lastInsertRowid;
const laneB = lane.run("/w/bob", bob.id).lastInsertRowid;
const session = db.prepare(
"INSERT INTO sessions (id, name, status, cwd, lane_id, source) VALUES (?, ?, 'active', ?, ?, ?)"
);
session.run("sA", "alice one", "/w/alice", laneA, "local");
session.run("sA2", "alice unstamped", "/w/alice", null, "local");
session.run("sB", "bob one", "/w/bob", laneB, "remote-b");
session.run("sHost", "host", "/w/host", null, "local");
// sA2 is alice's through a run of her workspace, not a stamp.
db.prepare(
"INSERT INTO dashboard_runs (id, session_id, mode, cwd, status, lane_id) VALUES ('agenthub-lane-1-2', 'sA2', 'interactive', '/w/alice', 'running', ?)"
).run(laneA);
const rule = db.prepare(
"INSERT INTO alert_rules (id, name, rule_type, config) VALUES ('r1', 'rule', 'error_rate', '{}')"
);
rule.run();
for (const sid of ALL_SESSIONS) {
db.prepare(
"INSERT INTO agents (id, session_id, name, type, status) VALUES (?, ?, 'Main', 'main', 'working')"
).run(`${sid}-main`, sid);
db.prepare(
"INSERT INTO events (session_id, agent_id, event_type, tool_name, summary) VALUES (?, ?, ?, ?, 'x')"
).run(sid, `${sid}-main`, `Type_${sid}`, `Tool_${sid}`);
db.prepare("INSERT INTO token_usage (session_id, model, input_tokens) VALUES (?, 'm', 10)").run(
sid
);
db.prepare(
"INSERT INTO alert_events (rule_id, rule_name, rule_type, session_id, message) VALUES ('r1', 'rule', 'error_rate', ?, 'boom')"
).run(sid);
db.prepare(
"INSERT INTO session_snapshots (token, session_id, payload) VALUES (?, ?, '{\"events\":[]}')"
).run(`tok-${sid}`.padEnd(32, "x"), sid);
}
// An alert with no session (a budget) is staff-only.
db.prepare(
"INSERT INTO alert_events (rule_id, rule_name, rule_type, session_id, message) VALUES ('r1', 'rule', 'budget', NULL, 'spend')"
).run();
process.env.AGENTHUB_MODE = "server";
for (const [name, pass] of [
["alice", "alice-pass-1"],
["bob", "bob-pass-1"],
["boss", "admin-pass-1"],
["ana", "analyst-pass-1"],
]) {
const r = await fetch(BASE + "/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: name, password: pass }),
});
assert.equal(r.status, 200);
cookies[name] = r.headers.get("set-cookie").split(";")[0];
}
});
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 });
}
});
/** What `as` sees on every scoped list/aggregate route. */
async function view(as) {
const sessions = await get("/api/sessions", as);
const priced = await get("/api/sessions?sort_by=price", as);
const waiting = await get("/api/sessions?status=active&archived=0&q=alice", as);
const sFacets = await get("/api/sessions/facets", as);
const agents = await get("/api/agents", as);
const working = await get("/api/agents?status=working", as);
const events = await get("/api/events", as);
const eFacets = await get("/api/events/facets", as);
const snaps = await get("/api/snapshots", as);
const alerts = await get("/api/alerts", as);
const info = await get("/api/settings/info", as);
return {
sessions: ids(sessions.sessions),
total: sessions.total,
priced: ids(priced.sessions),
waiting: ids(waiting.sessions),
cwds: sFacets.cwds,
sources: sFacets.sources,
agents: ids(agents.agents, "session_id"),
working: ids(working.agents, "session_id"),
events: ids(events.events, "session_id"),
eventTotal: events.total,
tools: eFacets.tool_names,
snapshots: ids(snaps.snapshots, "session_id"),
alerts: alerts.alerts.map((a) => a.session_id).sort(),
alertTotal: alerts.total,
unacked: alerts.unacked,
counts: info.db.counts,
h1: info.db.load_stats.h1,
};
}
function assertSees(v, sids) {
assert.deepEqual(v.sessions, sids);
assert.equal(v.total, sids.length);
assert.deepEqual(v.priced, sids);
assert.deepEqual(v.agents, sids);
assert.deepEqual(v.working, sids);
assert.deepEqual(v.events, sids);
assert.equal(v.eventTotal, sids.length);
assert.deepEqual(
v.tools,
sids.map((s) => `Tool_${s}`)
);
assert.deepEqual(v.snapshots, sids);
assert.equal(v.counts.sessions, sids.length);
assert.equal(v.counts.agents, sids.length);
assert.equal(v.counts.events, sids.length);
assert.equal(v.counts.token_usage, sids.length);
assert.equal(v.h1, sids.length);
}
describe("server mode: list and aggregate routes", () => {
it("shows a member only their own workspaces' rows, unstamped ones included", async () => {
const v = await view("alice");
assertSees(v, ["sA", "sA2"]);
assert.deepEqual(v.waiting, ["sA", "sA2"]);
assert.deepEqual(v.cwds, ["/w/alice"]);
assert.deepEqual(v.sources, ["local"]);
assert.deepEqual(v.alerts, ["sA", "sA2"]);
assert.equal(v.alertTotal, 2);
assert.equal(v.unacked, 2);
});
it("shows another member theirs", async () => {
const v = await view("bob");
assertSees(v, ["sB"]);
assert.deepEqual(v.waiting, []);
assert.deepEqual(v.cwds, ["/w/bob"]);
assert.deepEqual(v.sources, ["remote-b"]);
assert.deepEqual(v.alerts, ["sB"]);
assert.equal(v.unacked, 1);
});
it("shows an admin and an analyst everything", async () => {
for (const as of ["boss", "ana"]) {
const v = await view(as);
assertSees(v, ALL_SESSIONS);
assert.deepEqual(v.waiting, ["sA", "sA2"]);
assert.deepEqual(v.cwds, ["/w/alice", "/w/bob", "/w/host"]);
assert.deepEqual(v.sources, ["local", "remote-b"]);
assert.equal(v.alerts.length, 5); // the budget alert included
assert.equal(v.unacked, 5);
}
});
it("keeps a member out of another's rows through filters and POST", async () => {
// A filter naming another's session is refused, not answered empty.
assert.equal((await call("GET", "/api/events?session_id=sB", { as: "alice" })).status, 404);
assert.equal((await call("GET", "/api/agents?session_id=sB", { as: "alice" })).status, 404);
// An agent_id filter cannot reach past the ownership predicate.
const ev = await get("/api/events?agent_id=sB-main", "alice");
assert.equal(ev.total, 0);
// A member cannot create (or probe) sessions through POST /api/sessions at
// all — sessions come from hooks and the dashboard's own panes.
const r = await call("POST", "/api/sessions", { as: "alice", body: { id: "sB" } });
assert.equal(r.status, 403);
assert.ok(!JSON.stringify(r.body).includes("sB"));
const own = await call("POST", "/api/sessions", { as: "alice", body: { id: "sA" } });
assert.equal(own.status, 403);
// A snapshot of another's session cannot be captured.
const snap = await call("POST", "/api/snapshots", { as: "alice", body: { session_id: "sB" } });
assert.equal(snap.status, 404);
});
it("lets a member acknowledge only their own alerts", async () => {
const r = await call("POST", "/api/alerts/ack-all", { as: "alice" });
assert.equal(r.status, 200);
assert.equal(r.body.acknowledged, 2);
assert.equal((await get("/api/alerts", "alice")).unacked, 0);
assert.equal((await get("/api/alerts", "bob")).unacked, 1);
assert.equal((await get("/api/alerts?unacked=true", "boss")).total, 3);
});
});
describe("local mode", () => {
before(() => delete process.env.AGENTHUB_MODE);
after(() => (process.env.AGENTHUB_MODE = "server"));
it("needs no login and lists every row", async () => {
const v = await view();
assertSees(v, ALL_SESSIONS);
assert.equal(v.alerts.length, 5);
assert.equal(v.unacked, 3);
const ack = await call("POST", "/api/alerts/ack-all");
assert.equal(ack.body.acknowledged, 3);
});
});
+107
View File
@@ -0,0 +1,107 @@
/**
* @file server-mode-users.test.js
* @description Server mode's staff views: `GET /api/users` lists each account's
* workspaces, sessions and recent spend to an admin/analyst and refuses a
* member; an admin/analyst reading a member's session is written to the audit
* log (`GET /api/users/audit`), while a member reading their own, or a read of
* an ownerless session, is not.
* @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("node:path");
const fs = require("node:fs");
const os = require("node:os");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "server-mode-users-"));
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
process.env.DASHBOARD_LIVENESS_PROBE = "0";
const { createApp, startServer } = require("../index");
const { db } = require("../db");
const auth = require("../lib/auth");
let server;
let BASE;
const ids = {};
async function call(p, cookie) {
const res = await fetch(BASE + p, { headers: cookie ? { cookie } : {} });
return { status: res.status, body: await res.json().catch(() => null) };
}
async function login(username, password) {
const res = await fetch(BASE + "/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
assert.equal(res.status, 200);
return res.headers.get("set-cookie").split(";")[0];
}
before(async () => {
process.env.AGENTHUB_MODE = "server";
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
ids.boss = (await auth.createUser("boss", "admin-pass-1", "admin")).id;
ids.ana = (await auth.createUser("ana", "analyst-pass-1", "analyst")).id;
ids.mia = (await auth.createUser("mia", "member-pass-1", "member")).id;
const lane = db
.prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES ('/w/mia', 'claude', ?)")
.run(ids.mia).lastInsertRowid;
db.prepare(
"INSERT INTO sessions (id, name, status, cwd, lane_id) VALUES ('sm', 'mia work', 'active', '/w/mia', ?)"
).run(lane);
db.prepare(
"INSERT INTO sessions (id, name, status, cwd) VALUES ('sx', 'host work', 'active', '/w/host')"
).run();
db.prepare(
`INSERT INTO token_usage_hourly (session_id, hour, model, output_tokens)
VALUES ('sm', ?, 'claude-sonnet-4-5-20250929', 1000000)`
).run(new Date().toISOString().slice(0, 13));
});
after(() => {
delete process.env.AGENTHUB_MODE;
server?.close();
fs.rmSync(TMP, { recursive: true, force: true });
});
describe("staff views", () => {
it("lists every account's usage to staff and refuses a member", async () => {
const admin = await login("boss", "admin-pass-1");
const r = await call("/api/users", admin);
assert.equal(r.status, 200);
const mia = r.body.users.find((u) => u.username === "mia");
assert.equal(mia.workspaces, 1);
assert.equal(mia.sessions, 1);
assert.ok(mia.last7d.cost > 0 && mia.last30d.cost >= mia.last7d.cost);
assert.ok(!JSON.stringify(r.body).includes("scrypt"), "no password hash");
assert.equal((await call("/api/users", await login("ana", "analyst-pass-1"))).status, 200);
const member = await call("/api/users", await login("mia", "member-pass-1"));
assert.equal(member.status, 403);
});
it("audits a staff read of a member's session, and nothing else", async () => {
const admin = await login("boss", "admin-pass-1");
const mia = await login("mia", "member-pass-1");
const before = (await call("/api/users/audit", admin)).body.entries.length;
assert.equal((await call("/api/sessions/sm", mia)).status, 200); // own read
assert.equal((await call("/api/sessions/sx", admin)).status, 200); // ownerless
assert.equal((await call("/api/sessions/sm/transcript", admin)).status < 500, true);
await call("/api/sessions/sm", await login("ana", "analyst-pass-1"));
const entries = (await call("/api/users/audit", admin)).body.entries;
assert.equal(entries.length - before, 2);
assert.deepEqual(
entries.slice(0, 2).map((e) => [e.reader, e.owner, e.session_id]),
[
["ana", "mia", "sm"],
["boss", "mia", "sm"],
]
);
});
});
@@ -0,0 +1,175 @@
/**
* @file server-mode-workflows.test.js
* @description Server mode's row ownership on the workflow endpoints
* (server/routes/workflows.js, server/lib/workflow-stats.js run through the
* read worker): GET /api/workflows, /api/workflows/skill-usage and
* /api/workflows/runs count and list only a member's own sessions, another
* member sees theirs, an admin and an analyst see every row, and local mode
* answers exactly what an admin does.
* @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("node:path");
const fs = require("node:fs");
const os = require("node:os");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "server-mode-workflows-"));
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
process.env.DASHBOARD_LIVENESS_PROBE = "0";
const { createApp, startServer } = require("../index");
const { db } = require("../db");
const auth = require("../lib/auth");
let server;
let BASE;
const cookies = {};
async function get(p, cookie) {
const res = await fetch(BASE + p, { headers: cookie ? { cookie } : {} });
return { status: res.status, body: await res.json() };
}
async function loginAs(username, password) {
const res = await fetch(BASE + "/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
assert.equal(res.status, 200);
return res.headers.get("set-cookie").split(";")[0];
}
/** One session with a main agent, a Skill call, a Bash call and a workflow run. */
function seedSession(id, laneId, skill, wfStatus) {
db.prepare(
`INSERT INTO sessions (id, name, status, cwd, lane_id, started_at, ended_at)
VALUES (?, ?, 'completed', '/w', ?, '2026-10-01T00:00:00.000Z', '2026-10-01T00:10:00.000Z')`
).run(id, id, laneId);
db.prepare(
"INSERT INTO agents (id, session_id, name, type, status) VALUES (?, ?, 'Main', 'main', 'completed')"
).run(`${id}-main`, id);
const ev = db.prepare(
"INSERT INTO events (session_id, event_type, tool_name, data) VALUES (?, 'PreToolUse', ?, ?)"
);
ev.run(id, "Skill", JSON.stringify({ tool_input: { skill } }));
ev.run(id, "Bash", null);
db.prepare(
"INSERT INTO workflows (run_id, session_id, name, status, started_at) VALUES (?, ?, ?, ?, '2026-10-01T00:00:00.000Z')"
).run(`wf-${id}`, id, id, wfStatus);
}
before(async () => {
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
const mia = await auth.createUser("mia", "member-pass-1", "member");
const max = await auth.createUser("max", "member-pass-2", "member");
await auth.createUser("boss", "admin-pass-1", "admin");
await auth.createUser("ana", "analyst-pass-1", "analyst");
const lane = db.prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES (?, 'claude', ?)");
const miaLane = lane.run("/w/mia", mia.id).lastInsertRowid;
const maxLane = lane.run("/w/max", max.id).lastInsertRowid;
seedSession("m1", miaLane, "mia:one", "completed");
seedSession("m2", null, "mia:two", "running"); // not stamped: held by mia's run
db.prepare(
"INSERT INTO dashboard_runs (id, session_id, mode, cwd, status, lane_id) VALUES (?, 'm2', 'interactive', '/w', 'running', ?)"
).run(`agenthub-lane-${miaLane}-1`, miaLane);
seedSession("x1", maxLane, "max:one", "failed");
seedSession("h1", null, "host:one", "completed"); // nobody's workspace
});
after(() => {
delete process.env.AGENTHUB_MODE;
server?.close();
fs.rmSync(TMP, { recursive: true, force: true });
});
const ALL = ["h1", "m1", "m2", "x1"];
async function view(cookie) {
const wf = await get("/api/workflows?sections=stats,complexity,modelDelegation", cookie);
const skills = await get("/api/workflows/skill-usage", cookie);
const runs = await get("/api/workflows/runs", cookie);
const byStatus = await get("/api/workflows/runs?status=completed", cookie);
for (const r of [wf, skills, runs, byStatus]) assert.equal(r.status, 200, JSON.stringify(r));
return {
wf: wf.body,
sessions: wf.body.complexity.map((s) => s.id).sort(),
totalSessions: wf.body.stats.totalSessions,
totalAgents: wf.body.stats.totalAgents,
skills: skills.body.skills.map((s) => s.name).sort(),
plugins: skills.body.plugins.map((p) => p.name).sort(),
runs: runs.body.runs.map((r) => r.session_id).sort(),
total: runs.body.total,
counts: runs.body.counts,
completed: byStatus.body.runs.map((r) => r.session_id).sort(),
completedTotal: byStatus.body.total,
};
}
let localView;
describe("local mode", () => {
it("reads every row", async () => {
localView = await view();
assert.deepEqual(localView.sessions, ALL);
assert.equal(localView.totalSessions, 4);
assert.equal(localView.totalAgents, 4);
assert.deepEqual(localView.skills, ["host:one", "max:one", "mia:one", "mia:two"]);
assert.deepEqual(localView.runs, ALL);
assert.equal(localView.total, 4);
assert.deepEqual(localView.counts, { completed: 2, running: 1, failed: 1 });
assert.deepEqual(localView.completed, ["h1", "m1"]);
});
});
describe("server mode", () => {
before(async () => {
process.env.AGENTHUB_MODE = "server";
cookies.mia = await loginAs("mia", "member-pass-1");
cookies.max = await loginAs("max", "member-pass-2");
cookies.boss = await loginAs("boss", "admin-pass-1");
cookies.ana = await loginAs("ana", "analyst-pass-1");
});
it("shows a member only their own sessions in every number", async () => {
const v = await view(cookies.mia);
assert.deepEqual(v.sessions, ["m1", "m2"]);
assert.equal(v.totalSessions, 2);
assert.equal(v.totalAgents, 2);
assert.deepEqual(v.skills, ["mia:one", "mia:two"]);
assert.deepEqual(v.plugins, ["mia"]);
assert.deepEqual(v.runs, ["m1", "m2"]);
assert.equal(v.total, 2);
assert.deepEqual(v.counts, { completed: 1, running: 1 });
assert.deepEqual(v.completed, ["m1"]);
assert.equal(v.completedTotal, 1);
});
it("shows another member theirs", async () => {
const v = await view(cookies.max);
assert.deepEqual(v.sessions, ["x1"]);
assert.equal(v.totalSessions, 1);
assert.deepEqual(v.skills, ["max:one"]);
assert.deepEqual(v.runs, ["x1"]);
assert.deepEqual(v.counts, { failed: 1 });
assert.deepEqual(v.completed, []);
});
it("refuses a member another member's session filter", async () => {
const r = await get("/api/workflows/runs?session_id=x1", cookies.mia);
assert.equal(r.status, 404);
});
it("shows an admin and an analyst exactly what local mode shows", async () => {
for (const who of ["boss", "ana"]) {
const v = await view(cookies[who]);
assert.deepEqual({ ...v, wf: null }, { ...localView, wf: null });
assert.deepEqual(v.wf.stats, localView.wf.stats);
assert.deepEqual(v.wf.modelDelegation, localView.wf.modelDelegation);
}
});
});
@@ -0,0 +1,245 @@
/**
* @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);
});
});