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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user