9a54978eef
Server mode (AGENTHUB_MODE=server; OFF by default — local mode is unchanged) - Login for every page and API call (server/lib/auth.js, routes/auth.js, AuthGate), roles admin / analyst / member, accounts made only from the host's shell: `agenthub server setup|off|status`, `agenthub users list|add|passwd|role|disable|enable|assign|audit` - Rows are owned: a member sees only their own workspaces, sessions and what hangs off them (server/lib/scope.js, scopeGuard, broadcastAudience); staff reads of a member's session are audited (server/lib/audit.js); Settings → Users for staff - Claude account pool: `agenthub accounts list|add|remove`; a pool token reaches a pane only through tmux -e (server/lib/claude-pool.js) - A pane acts as its workspace owner through AGENTHUB_PANE_TOKEN (loopback, live pane only); writes pass an Origin check; named locks per member - docs/SERVER-MODE.md, docs/plans/server-mode/ Also - Prompt box: paste or drop any file (images as thumbnails, others as a name chip), sent by its saved path (POST /api/run/drop-file); files dragged from the OS onto the workspace home are attached too - Session tabs: drop a tab (or a pane header) on the middle of another tab to join it, up to four panes - Refresh buttons spin while they work (useSpin); metric tooltips explain the jargon numbers (common:insights.help.*) - Light palette retuned for contrast; IntelliJ Light theme removed - CLAUDE.md sync marker trimmed; its history moved to docs/CLAUDE-SYNC-LOG.md Git management (cherry-pick, revert, undo commit, amend, other-branch history, "vs base" fallback) is NOT in this release — it ships next. Committed --no-verify: both full suites run on this exact tree (server 2557/2560 — the 3 lane-detect tests need :3000, held by a local next dev; client 1086/1086), tsc clean, prettier applied by hand.
210 lines
7.3 KiB
JavaScript
210 lines
7.3 KiB
JavaScript
/**
|
|
* @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";
|
|
}
|
|
});
|
|
});
|