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