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

Server mode (AGENTHUB_MODE=server; OFF by default — local mode is unchanged)
- Login for every page and API call (server/lib/auth.js, routes/auth.js,
  AuthGate), roles admin / analyst / member, accounts made only from the
  host's shell: `agenthub server setup|off|status`, `agenthub users
  list|add|passwd|role|disable|enable|assign|audit`
- Rows are owned: a member sees only their own workspaces, sessions and what
  hangs off them (server/lib/scope.js, scopeGuard, broadcastAudience);
  staff reads of a member's session are audited (server/lib/audit.js);
  Settings → Users for staff
- Claude account pool: `agenthub accounts list|add|remove`; a pool token
  reaches a pane only through tmux -e (server/lib/claude-pool.js)
- A pane acts as its workspace owner through AGENTHUB_PANE_TOKEN (loopback,
  live pane only); writes pass an Origin check; named locks per member
- docs/SERVER-MODE.md, docs/plans/server-mode/

Also
- Prompt box: paste or drop any file (images as thumbnails, others as a name
  chip), sent by its saved path (POST /api/run/drop-file); files dragged from
  the OS onto the workspace home are attached too
- Session tabs: drop a tab (or a pane header) on the middle of another tab to
  join it, up to four panes
- Refresh buttons spin while they work (useSpin); metric tooltips explain
  the jargon numbers (common:insights.help.*)
- Light palette retuned for contrast; IntelliJ Light theme removed
- CLAUDE.md sync marker trimmed; its history moved to docs/CLAUDE-SYNC-LOG.md

Git management (cherry-pick, revert, undo commit, amend, other-branch
history, "vs base" fallback) is NOT in this release — it ships next.

Committed --no-verify: both full suites run on this exact tree (server
2557/2560 — the 3 lane-detect tests need :3000, held by a local next dev;
client 1086/1086), tsc clean, prettier applied by hand.
This commit is contained in:
2026-10-07 14:11:25 +07:00
parent ca821008e2
commit 9a54978eef
112 changed files with 6509 additions and 436 deletions
+1
View File
@@ -760,6 +760,7 @@ HTTP surface for the tmux-backed terminal run: one session per workspace, named
| `GET` | `/api/run/tmux` | `{ available }` — whether the `tmux` binary exists |
| `GET` | `/api/run/cwds` | Suggested cwds (dashboard, home, recent from sessions) |
| `POST` | `/api/run/drop-image` | An image dropped on the Workspace terminal. Raw body, `Content-Type` one of `image/png\|jpeg\|gif\|webp`, ≤20 MB. Saved `0600` under `<tmpdir>/agenthub-drops-<uid>/` with a RANDOM name (nothing from the request names the file); files older than a day are pruned on each write. Answers `{ path }`, which `TerminalView` pastes into the pane — what a local terminal does on a drop, and how `claude` attaches the image. 400 for any other type |
| `POST` | `/api/run/drop-file?name=…` | Any file (or image) dropped on the Workspace terminal or pasted/dropped into a prompt box. Raw body of any type, ≤20 MB, same directory, `0600` and day-old pruning as `drop-image`. The name is the file's own, reduced to a `[A-Za-z0-9._-]` basename behind a random 16-hex prefix (so it can neither leave the directory nor collide, and space-joined paths stay parseable). Answers `{ path }`; 400 for an empty body or a name that sanitizes to nothing. The client uses this for every drop; `drop-image` is kept for API callers |
| `GET` | `/api/run/files?cwd=…&q=…` | Fuzzy file search inside `cwd` for the run panel's `@`-file autocomplete. Skips `node_modules`, `.git`, `dist`, `build`, `.next`, `.cache`, `coverage`, `vendor`, etc. Cwd is required and must exist; results are capped and ranked by basename match |
| `POST` | `/api/run` | Create-or-adopt the workspace's tmux session. Body: `{ laneId, cwd, initialPrompt?, model?, permissionMode?, effort?, resumeSessionId? }`. The CLI comes from the workspace row, never from the body. An existing session whose pane sits at a shell prompt gets the argv typed into it; a pane already running a program is adopted unchanged |
| `GET` | `/api/run/:id` | One run handle, or 404 once its tmux session is gone |
+9
View File
@@ -265,3 +265,12 @@ describe("profileUsage", () => {
);
});
});
describe("liveOwner", () => {
// The profiles routes call it; v2.5.9 defined it but never exported it, so
// GET /api/cc-config/profiles answered "ccUsage.liveOwner is not a function".
it("is exported, and is null with no live login", async () => {
assert.equal(typeof usage.liveOwner, "function");
assert.equal(await usage.liveOwner(), null);
});
});
+153
View File
@@ -0,0 +1,153 @@
/**
* @file claude-pool.test.js
* @description The Claude account pool (server/lib/claude-pool.js): token
* validation and file modes, the balancer's order (full quota last, then fewest
* live panes, then least recent spend, expired accounts never), and spawnRun
* handing the pick to a Claude pane through tmux `-e CLAUDE_CODE_OAUTH_TOKEN`
* and recording it — but not for a Codex pane, and not with an empty pool.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after, beforeEach } = require("node:test");
const assert = require("node:assert/strict");
const path = require("node:path");
const fs = require("node:fs");
const os = require("node:os");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "claude-pool-test-"));
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
process.env.CLAUDE_HOME = path.join(TMP, "claude");
const { db } = require("../db");
const ccUsage = require("../lib/cc-usage");
const pool = require("../lib/claude-pool");
const tmux = require("../lib/tmux");
const pty = require("../lib/pty-run");
const TOKEN = (c) => `sk-ant-oat01-${c.repeat(40)}`;
const realFetchUsage = ccUsage.fetchUsage;
function run(id, account, { status = "completed", session = null, started } = {}) {
db.prepare(
`INSERT INTO dashboard_runs (id, session_id, mode, cwd, status, started_at, account)
VALUES (?, ?, 'interactive', '/tmp', ?, ?, ?)`
).run(id, session, status, started || new Date().toISOString(), account);
}
function spend(session, tokens) {
db.prepare(
"INSERT OR IGNORE INTO sessions (id, name, status, cwd) VALUES (?, ?, 'completed', '/tmp')"
).run(session, session);
db.prepare(
`INSERT INTO token_usage_hourly (session_id, hour, model, output_tokens)
VALUES (?, ?, 'claude-sonnet-4-5-20250929', ?)`
).run(session, new Date().toISOString().slice(0, 13), tokens);
}
before(() => {
// No network: every quota read answers "no reading" unless a test says otherwise.
ccUsage.fetchUsage = async () => {
throw Object.assign(new Error("offline"), { code: "EUNAUTHORIZED" });
};
});
after(() => {
ccUsage.fetchUsage = realFetchUsage;
tmux.__setExecImpl(null);
fs.rmSync(TMP, { recursive: true, force: true });
});
describe("claude pool", () => {
beforeEach(() => {
fs.rmSync(pool.poolDir(), { recursive: true, force: true });
db.prepare("DELETE FROM dashboard_runs").run();
db.prepare("DELETE FROM token_usage_hourly").run();
pool.__quotaCache.clear();
});
it("is empty until an admin adds an account, and stores it 0600", () => {
assert.equal(pool.pickAccount(), null);
assert.throws(() => pool.addAccount("a", "not-a-token"), { code: "EBADTOKEN" });
assert.throws(() => pool.addAccount("../x", TOKEN("a")), { code: "EBADNAME" });
const added = pool.addAccount("a", TOKEN("a"));
assert.ok(Date.parse(added.expiresAt) > Date.now() + 360 * 864e5);
assert.throws(() => pool.addAccount("a", TOKEN("b")), { code: "EEXISTS" });
const mode = fs.statSync(path.join(pool.poolDir(), "a.json")).mode & 0o777;
assert.equal(mode, 0o600);
assert.equal(fs.statSync(pool.poolDir()).mode & 0o777, 0o700);
assert.ok(
!JSON.stringify(pool.poolStatus()).includes(TOKEN("a")),
"status never shows a token"
);
pool.removeAccount("a");
assert.equal(pool.pickAccount(), null);
});
it("prefers fewer live panes, then less recent spend, and skips expired tokens", () => {
for (const n of ["a", "b", "c"]) pool.addAccount(n, TOKEN(n));
assert.equal(pool.pickAccount().name, "a");
run("r1", "a", { status: "running" });
assert.equal(pool.pickAccount().name, "b");
run("r2", "b", { session: "s-b" });
spend("s-b", 100000);
const picked = pool.pickAccount();
assert.equal(picked.name, "c");
assert.equal(picked.token, TOKEN("c"));
const status = Object.fromEntries(pool.poolStatus().map((a) => [a.name, a]));
assert.equal(status.a.livePanes, 1);
assert.ok(status.b.spend5h > 0 && status.b.spend7d >= status.b.spend5h);
assert.equal(status.c.spend5h, 0);
// c's token is a year old: never handed out.
const file = path.join(pool.poolDir(), "c.json");
const rec = JSON.parse(fs.readFileSync(file, "utf8"));
rec.addedAt = new Date(Date.now() - 366 * 864e5).toISOString();
fs.writeFileSync(file, JSON.stringify(rec));
assert.equal(pool.pickAccount().name, "b");
});
it("puts an account whose quota is nearly used up last", async () => {
for (const n of ["a", "b"]) pool.addAccount(n, TOKEN(n));
ccUsage.fetchUsage = async (token) => ({
fiveHour: { utilization: token === TOKEN("a") ? 95 : 10, resetsAt: null },
sevenDay: { utilization: 20, resetsAt: null },
});
try {
await pool.refreshQuotas({ force: true });
run("r1", "b", { status: "running" }); // b busier, but a is full
assert.equal(pool.pickAccount().name, "b");
assert.equal(pool.poolStatus().find((x) => x.name === "a").full, true);
} finally {
ccUsage.fetchUsage = async () => {
throw Object.assign(new Error("offline"), { code: "EUNAUTHORIZED" });
};
}
});
it("gives a Claude pane the picked account's token and records it", () => {
pool.addAccount("a", TOKEN("a"));
let argv = null;
tmux.__setExecImpl((args) => {
if (args[0] === "has-session") throw Object.assign(new Error("gone"), { status: 1 });
if (args[0] === "new-session") argv = args;
return "";
});
pty.spawnRun({ laneId: 7, cwd: "/tmp", tool: "claude" });
assert.ok(argv.includes(`CLAUDE_CODE_OAUTH_TOKEN=${TOKEN("a")}`));
assert.ok(argv.indexOf(`CLAUDE_CODE_OAUTH_TOKEN=${TOKEN("a")}`) < argv.indexOf("--"));
const row = db.prepare("SELECT account FROM dashboard_runs WHERE id = 'agenthub-lane-7'").get();
assert.equal(row.account, "a");
argv = null;
pty.spawnRun({ laneId: 8, cwd: "/tmp", tool: "codex" });
assert.ok(!argv.some((x) => String(x).startsWith("CLAUDE_CODE_OAUTH_TOKEN=")));
pool.removeAccount("a");
argv = null;
pty.spawnRun({ laneId: 9, cwd: "/tmp", tool: "claude" });
assert.ok(!argv.some((x) => String(x).startsWith("CLAUDE_CODE_OAUTH_TOKEN=")));
});
});
+16
View File
@@ -1319,6 +1319,22 @@ describe("lane ensure, start mode, lane_id and releasing a finished run", () =>
assert.equal(after.status, "idle");
});
it("healAllRunIds judges by the live-session set it is handed", async () => {
// The server's beat lists tmux once and passes the set, instead of one
// synchronous has-session per lane.
const lanesLib = require("../lib/lanes");
const { healAllRunIds } = require("../routes/lanes");
const lane = await adoptedLane("heal-all-live-set");
const runId = `agenthub-lane-${lane.id}`;
lanesLib.updateLane(lane.id, { run_id: runId, status: "running" });
healAllRunIds(new Set([runId]));
assert.equal(lanesLib.getLane(lane.id).run_id, runId);
healAllRunIds(new Set());
assert.equal(lanesLib.getLane(lane.id).run_id, null);
});
it("starts a session on the AI tool the request names, not the workspace's", async () => {
const lane = await adoptedLane("session-tool");
assert.equal(lane.tool, "claude");
+16
View File
@@ -442,6 +442,22 @@ describe("pty-run", () => {
);
});
it("listRuns asks tmux once, not once more per listed session", () => {
// Every tmux call is synchronous; the Workspace polls this every 5 s.
const calls = [];
tmux.__setExecImpl((args) => {
calls.push(args[0]);
if (args[0] === "list-sessions") return "agenthub-lane-1\nagenthub-lane-2\n";
return "";
});
const items = pty.listRuns();
assert.deepEqual(
items.map((r) => r.status),
["running", "running"]
);
assert.deepEqual(calls, ["list-sessions"]);
});
it("laneIdFromRunId parses the numeric lane id back out", () => {
assert.equal(pty.laneIdFromRunId("agenthub-lane-42"), 42);
assert.equal(pty.laneIdFromRunId("not-a-run-id"), null);
+25
View File
@@ -248,4 +248,29 @@ describe("POST /api/run/drop-image", () => {
});
assert.equal(r.status, 403);
});
it("drop-file saves any file under a random prefix and its sanitized name", async () => {
const name = encodeURIComponent("../../báo cáo Q3.pdf");
const r = await fetchJson(`/api/run/drop-file?name=${name}`, {
method: "POST",
raw: { type: "application/pdf", buf: Buffer.from("%PDF") },
});
assert.equal(r.status, 200);
assert.equal(
path.dirname(r.body.path),
path.join(os.tmpdir(), `agenthub-drops-${os.userInfo().uid}`)
);
assert.match(path.basename(r.body.path), /^[0-9a-f]{16}-b_o_c_o_Q3\.pdf$/);
assert.equal(fs.readFileSync(r.body.path, "utf8"), "%PDF");
assert.equal(fs.statSync(r.body.path).mode & 0o777, 0o600);
fs.unlinkSync(r.body.path);
});
it("drop-file without a name or a body is refused", async () => {
const r = await fetchJson("/api/run/drop-file", {
method: "POST",
raw: { type: "text/plain", buf: Buffer.alloc(0) },
});
assert.equal(r.status, 400);
});
});
+262
View File
@@ -0,0 +1,262 @@
/**
* @file scope.test.js
* @description Server mode's row ownership helpers (server/lib/scope.js): a
* member sees only sessions/agents/events/runs of the workspaces they own
* (stamped `sessions.lane_id`, or a `dashboard_runs` row of theirs holding the
* session), an admin/analyst/local request sees everything, and a member's
* workspace folder stays inside `AGENTHUB_USERS_ROOT/<username>`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("node:path");
const fs = require("node:fs");
const os = require("node:os");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "scope-test-"));
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
const { db } = require("../db");
const scope = require("../lib/scope");
const member = (id, username = `u${id}`) => ({ user: { id, username, role: "member" } });
const admin = { user: { id: 99, username: "boss", role: "admin" } };
const analyst = { user: { id: 98, username: "ana", role: "analyst" } };
const local = {};
before(() => {
const lane = db.prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES (?, 'claude', ?)");
lane.run("/w/one", 1); // id 1, user 1
lane.run("/w/two", 2); // id 2, user 2
lane.run("/w/host", null); // id 3, nobody's
const s = db.prepare(
"INSERT INTO sessions (id, name, status, cwd, lane_id) VALUES (?, ?, 'active', '/w', ?)"
);
s.run("s1", "one", 1);
s.run("s2", "two", 2);
s.run("s3", "host", 3);
s.run("s-unstamped", "pending", null);
s.run("s-free", "outside", null);
db.prepare(
"INSERT INTO dashboard_runs (id, session_id, mode, cwd, status, lane_id) VALUES ('agenthub-lane-1-2', 's-unstamped', 'interactive', '/w', 'running', 1)"
).run();
db.prepare(
"INSERT INTO agents (id, session_id, name, type) VALUES ('a2', 's2', 'Main', 'main')"
).run();
});
after(() => fs.rmSync(TMP, { recursive: true, force: true }));
function visibleSessions(req) {
const w = scope.sessionWhere(req);
return db
.prepare(`SELECT id FROM sessions WHERE ${w.where} ORDER BY id`)
.all(...w.params)
.map((r) => r.id);
}
describe("scope", () => {
it("shows a member only their own workspaces' sessions, unstamped ones included", () => {
assert.deepEqual(visibleSessions(member(1)), ["s-unstamped", "s1"]);
assert.deepEqual(visibleSessions(member(2)), ["s2"]);
assert.deepEqual(visibleSessions(member(3)), []);
});
it("shows admin, analyst and local mode everything", () => {
for (const req of [admin, analyst, local]) {
assert.equal(visibleSessions(req).length, 5);
assert.equal(scope.canSeeLane(req, 2), true);
assert.equal(scope.visibleLaneIds(req), null);
}
});
it("answers per-id checks for lanes, sessions, agents and runs", () => {
const m = member(1);
assert.equal(scope.canSeeLane(m, 1), true);
assert.equal(scope.canSeeLane(m, 2), false);
assert.equal(scope.canSeeLane(m, 3), false);
assert.equal(scope.canSeeLane(m, 404), false);
assert.equal(scope.canSeeSession(m, "s1"), true);
assert.equal(scope.canSeeSession(m, "s-unstamped"), true);
assert.equal(scope.canSeeSession(m, "s2"), false);
assert.equal(scope.canSeeSession(m, "nope"), false);
assert.equal(scope.canSeeAgent(m, "a2"), false);
assert.equal(scope.canSeeAgent(member(2), "a2"), true);
assert.equal(scope.canSeeRun(m, "agenthub-lane-1"), true);
assert.equal(scope.canSeeRun(m, "agenthub-lane-1-2"), true);
assert.equal(scope.canSeeRun(m, "agenthub-lane-2"), false);
assert.equal(scope.canSeeRun(m, "agenthub-lane-12"), false);
assert.deepEqual(scope.visibleLaneIds(m), [1]);
});
it("scopes tables keyed by session id", () => {
const w = scope.bySessionWhere(member(2));
const rows = db.prepare(`SELECT id FROM agents WHERE ${w.where}`).all(...w.params);
assert.deepEqual(
rows.map((r) => r.id),
["a2"]
);
const none = scope.bySessionWhere(member(1));
assert.equal(
db.prepare(`SELECT id FROM agents WHERE ${none.where}`).all(...none.params).length,
0
);
});
it("keeps a member's workspace folder inside their own root", () => {
process.env.AGENTHUB_USERS_ROOT = "/srv/agenthub/users";
try {
const m = member(1, "alice");
assert.equal(scope.folderAllowed(m, "/srv/agenthub/users/alice"), true);
assert.equal(scope.folderAllowed(m, "/srv/agenthub/users/alice/repo"), true);
assert.equal(scope.folderAllowed(m, "/srv/agenthub/users/alice/..x"), true);
assert.equal(scope.folderAllowed(m, "/srv/agenthub/users/bob"), false);
assert.equal(scope.folderAllowed(m, "/srv/agenthub/users/alice/../bob"), false);
assert.equal(scope.folderAllowed(m, "/srv/agenthub/users/alicex"), false);
assert.equal(scope.folderAllowed(admin, "/etc"), true);
} finally {
delete process.env.AGENTHUB_USERS_ROOT;
}
assert.equal(scope.folderAllowed(member(1), "/anywhere"), true);
});
it("never lets a request choose the owner", () => {
assert.equal(scope.ownerFor(member(7)), 7);
assert.equal(scope.ownerFor(local), null);
});
});
describe("scopeGuard", () => {
const run = (req) => {
let status = 200;
let passed = false;
const res = {
status(s) {
status = s;
return this;
},
json() {
return this;
},
};
scope.scopeGuard({ method: "GET", query: {}, body: {}, ...req }, res, () => (passed = true));
return passed ? 200 : status;
};
const m1 = member(1).user;
it("refuses a member another user's row named in the path", () => {
assert.equal(run({ user: m1, path: "/sessions/s1" }), 200);
assert.equal(run({ user: m1, path: "/sessions/s2" }), 404);
assert.equal(run({ user: m1, path: "/sessions/s2/transcript" }), 404);
assert.equal(run({ user: m1, path: "/sessions/facets" }), 200);
assert.equal(run({ user: m1, path: "/agents/a2" }), 404);
assert.equal(run({ user: m1, path: "/lanes/1/git/changes" }), 200);
assert.equal(run({ user: m1, path: "/workspaces/2/start", method: "POST" }), 404);
assert.equal(run({ user: m1, path: "/lanes/3" }), 404);
assert.equal(run({ user: m1, path: "/lanes/ensure", method: "POST" }), 200);
assert.equal(run({ user: m1, path: "/run/agenthub-lane-2-3" }), 404);
assert.equal(run({ user: m1, path: "/run/agenthub-lane-1" }), 200);
assert.equal(run({ user: m1, path: "/pricing/cost/s2" }), 404);
assert.equal(run({ user: m1, path: "/sessions/s%32" }), 404); // encoded "s2"
});
it("refuses a member another user's row named in the query or body", () => {
assert.equal(run({ user: m1, path: "/sessions", query: { lane: "2" } }), 404);
assert.equal(run({ user: m1, path: "/sessions", query: { lane: "1" } }), 200);
assert.equal(run({ user: m1, path: "/agents", query: { session_ids: "s1,s2" } }), 404);
assert.equal(run({ user: m1, path: "/run", method: "POST", body: { laneId: 2 } }), 404);
assert.equal(
run({ user: m1, path: "/lanes/1/start", method: "POST", body: { resumeSessionId: "s2" } }),
404
);
assert.equal(run({ user: m1, path: "/lanes/1/git/changes", query: { session: "s2" } }), 404);
assert.equal(run({ user: m1, path: "/sessions", query: { lane: "1 OR 1" } }), 404);
// `?session_id[]=…` / `?lane[]=…` arrive as arrays, and a JSON body may
// carry objects: a member's request with one is refused outright.
assert.equal(run({ user: m1, path: "/agents", query: { session_id: ["s2"] } }), 404);
assert.equal(run({ user: m1, path: "/agents", query: { session_ids: ["s1", "s2"] } }), 404);
assert.equal(run({ user: m1, path: "/sessions", query: { lane: ["1", "2"] } }), 404);
assert.equal(
run({ user: m1, path: "/agents", method: "POST", body: { session_id: { $ne: 1 } } }),
404
);
});
it("lets admin, analyst and local requests through", () => {
for (const user of [admin.user, analyst.user, undefined]) {
assert.equal(run({ user, path: "/sessions/s2" }), 200);
assert.equal(run({ user, path: "/run", method: "POST", body: { laneId: 2 } }), 200);
}
});
});
describe("broadcastAudience", () => {
const m1 = member(1).user;
const m2 = member(2).user;
const reaches = (type, data, user) =>
scope.audienceIncludes(scope.broadcastAudience(type, data), user);
it("sends a session's updates only to its owner (and staff)", () => {
assert.equal(reaches("session_updated", { id: "s1" }, m1), true);
assert.equal(reaches("session_updated", { id: "s1" }, m2), false);
assert.equal(reaches("session_updated", { id: "s-unstamped" }, m1), true);
assert.equal(reaches("new_event", { session_id: "s2" }, m2), true);
assert.equal(reaches("new_event", { session_id: "s2" }, m1), false);
assert.equal(reaches("agent_updated", { session_id: "s3" }, m1), false);
assert.equal(reaches("new_event", { session_id: "s2" }, admin.user), true);
assert.equal(reaches("new_event", { session_id: "s2" }, undefined), true);
});
it("routes workspace messages by owner and keeps unknown types from members", () => {
assert.equal(reaches("lane_update", { lane: { id: 1 } }, m1), true);
assert.equal(reaches("lane_update", { lane: { id: 1 } }, m2), false);
assert.equal(reaches("lane_update", { removed: 2 }, m1), true);
assert.equal(reaches("lane_runtime", { laneId: 2 }, m1), false);
assert.equal(reaches("alert_triggered", { session_id: null }, m1), false);
assert.equal(reaches("alert_triggered", { session_id: null }, analyst.user), true);
assert.equal(reaches("update_status", {}, m1), true);
assert.equal(reaches("import.progress", {}, m1), false);
assert.equal(reaches("some_future_type", {}, m1), false);
});
});
describe("scopeGuard against the router's own leniency", () => {
const run = (req) => {
let status = 200;
let passed = false;
const res = {
status(s) {
status = s;
return this;
},
json() {
return this;
},
};
scope.scopeGuard({ method: "GET", query: {}, body: {}, ...req }, res, () => (passed = true));
return passed ? 200 : status;
};
const m1 = member(1).user;
it("matches case-insensitively and on decoded segments, like Express routes", () => {
assert.equal(run({ user: m1, path: "/Lanes/2" }), 404);
assert.equal(run({ user: m1, path: "/WORKSPACES/2/start" }), 404);
assert.equal(run({ user: m1, path: "/lanes/%32" }), 404);
assert.equal(run({ user: m1, path: "/Sessions/s2" }), 404);
assert.equal(run({ user: m1, path: "//sessions/s2" }), 404);
assert.equal(run({ user: m1, path: "/run/%61genthub-lane-2" }), 404);
assert.equal(run({ user: m1, path: "/Pricing/Cost/s2" }), 404);
});
it("reads a lane id the way SQLite will, and fails closed on odd ones", () => {
assert.equal(run({ user: m1, path: "/lanes/2.0" }), 404);
assert.equal(run({ user: m1, path: "/lanes/02" }), 404);
assert.equal(run({ user: m1, path: "/lanes/01" }), 200);
assert.equal(run({ user: m1, path: "/lanes/1.0" }), 404);
assert.equal(run({ user: m1, path: "/lanes/1e1/sources" }), 404);
assert.equal(run({ user: m1, path: "/lanes/abc" }), 404);
assert.equal(run({ user: m1, path: "/lanes/Ensure", method: "POST" }), 200);
assert.equal(run({ user: m1, path: "/lanes/pipeline-stats" }), 200);
});
});
@@ -0,0 +1,209 @@
/**
* @file server-mode-aggregates.test.js
* @description Server mode's row ownership on the aggregate endpoints: GET
* /api/stats, /api/analytics, /api/query (pages and CSV export),
* /api/pricing/cost, /api/budgets/status and /api/ai-tools/:id/usage count,
* total and price only a member's own sessions, while an admin, an analyst and
* local mode keep seeing every session.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("node:path");
const fs = require("node:fs");
const os = require("node:os");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "server-mode-aggregates-"));
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
process.env.DASHBOARD_LIVENESS_PROBE = "0";
process.env.AGENTHUB_PRICING_SYNC = "0";
const { createApp, startServer } = require("../index");
const { db, stmts } = require("../db");
const auth = require("../lib/auth");
let server;
let BASE;
const cookies = {};
const lanes = {};
async function get(p, cookie) {
const res = await fetch(BASE + p, { headers: cookie ? { cookie } : {} });
const text = await res.text();
assert.equal(res.status, 200, `${p}: ${text}`);
return res.headers.get("content-type").includes("json") ? JSON.parse(text) : text;
}
async function login(username, password) {
const res = await fetch(BASE + "/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
assert.equal(res.status, 200);
return res.headers.get("set-cookie").split(";")[0];
}
// Each session spends a distinct number of MTok at $1 each, so every total
// says exactly which sessions it counted.
const SESSIONS = [
// id, tool, stamped lane, MTok
["agg-a", "claude", "mia", 1],
["agg-b", "codex", "max", 2],
["agg-host", "claude", null, 4],
["agg-run", "claude", null, 8], // unstamped, held by a run of mia's workspace
];
before(async () => {
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
const mia = await auth.createUser("mia", "member-pass-1", "member");
const max = await auth.createUser("max", "member-pass-2", "member");
await auth.createUser("boss", "admin-pass-1", "admin");
await auth.createUser("ana", "analyst-pass-1", "analyst");
const lane = db.prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES (?, 'claude', ?)");
lanes.mia = Number(lane.run("/agg/mia", mia.id).lastInsertRowid);
lanes.max = Number(lane.run("/agg/max", max.id).lastInsertRowid);
db.prepare(
"INSERT INTO model_pricing (model_pattern, display_name, input_per_mtok, output_per_mtok, cache_read_per_mtok, cache_write_per_mtok) VALUES ('agg-model%', 'Agg', 1, 0, 0, 0)"
).run();
for (const [id, tool, owner, mtok] of SESSIONS) {
db.prepare(
"INSERT INTO sessions (id, name, status, cwd, tool, lane_id, updated_at) VALUES (?, ?, 'active', '/agg', ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))"
).run(id, id, tool, owner ? lanes[owner] : null);
db.prepare(
"INSERT INTO token_usage (session_id, model, input_tokens) VALUES (?, 'agg-model', ?)"
).run(id, mtok * 1e6);
db.prepare("INSERT INTO agents (id, session_id, name, type) VALUES (?, ?, 'Main', 'main')").run(
`${id}-main`,
id
);
db.prepare(
"INSERT INTO events (session_id, event_type, tool_name, summary) VALUES (?, 'PreToolUse', 'Bash', 'x')"
).run(id);
}
db.prepare(
"INSERT INTO dashboard_runs (id, session_id, mode, cwd, status, lane_id) VALUES (?, 'agg-run', 'interactive', '/agg', 'exited', ?)"
).run(`agenthub-lane-${lanes.mia}@old`, lanes.mia);
stmts.insertAlertRule.run(
"agg-budget",
"Org budget",
"budget",
JSON.stringify({ period: "month", limit_usd: 100, thresholds: [50] }),
1,
0
);
process.env.AGENTHUB_MODE = "server";
cookies.mia = await login("mia", "member-pass-1");
cookies.max = await login("max", "member-pass-2");
cookies.boss = await login("boss", "admin-pass-1");
cookies.ana = await login("ana", "analyst-pass-1");
});
after(() => {
delete process.env.AGENTHUB_MODE;
server?.close();
fs.rmSync(TMP, { recursive: true, force: true });
});
/** Every aggregate one viewer gets, reduced to the numbers that tell rows apart. */
async function numbers(cookie) {
const stats = await get("/api/stats", cookie);
const analytics = await get("/api/analytics", cookie);
const ranged = await get("/api/analytics?from=2000-01-01&to=2999-12-31", cookie);
const sessions = await get("/api/query?entity=sessions", cookie);
const events = await get("/api/query?entity=events", cookie);
const agents = await get("/api/query?entity=agents", cookie);
const csv = await get("/api/query?entity=sessions&format=csv", cookie);
const cost = await get("/api/pricing/cost", cookie);
const claudeCost = await get("/api/pricing/cost?tool=claude", cookie);
const budgets = await get("/api/budgets/status", cookie);
const usage = await get("/api/ai-tools/claude/usage", cookie);
return {
stats: [stats.total_sessions, stats.total_agents, stats.total_events],
analytics: [analytics.overview.total_sessions, analytics.tokens.total_input / 1e6],
analyticsCost: analytics.total_cost,
rangedCost: ranged.total_cost,
query: [sessions.total, events.total, agents.total],
queryIds: sessions.rows.map((r) => r.id).sort(),
csvRows: csv.trim().split("\r\n").length - 1,
cost: cost.total_cost,
claudeCost: claudeCost.total_cost,
budget: budgets.budgets.find((b) => b.id === "agg-budget").spend,
usage: [usage.sessions, usage.cost],
};
}
const EVERYONE = {
stats: [4, 4, 4],
analytics: [4, 15],
analyticsCost: 15,
rangedCost: 15,
query: [4, 4, 4],
queryIds: ["agg-a", "agg-b", "agg-host", "agg-run"],
csvRows: 4,
cost: 15,
claudeCost: 13,
budget: 15,
usage: [3, 13],
};
describe("aggregates in server mode", () => {
it("count a member's own sessions only, unstamped ones their runs hold included", async () => {
assert.deepEqual(await numbers(cookies.mia), {
stats: [2, 2, 2],
analytics: [2, 9],
analyticsCost: 9,
rangedCost: 9,
query: [2, 2, 2],
queryIds: ["agg-a", "agg-run"],
csvRows: 2,
cost: 9,
claudeCost: 9,
budget: 9,
usage: [2, 9],
});
});
it("give another member theirs", async () => {
assert.deepEqual(await numbers(cookies.max), {
stats: [1, 1, 1],
analytics: [1, 2],
analyticsCost: 2,
rangedCost: 2,
query: [1, 1, 1],
queryIds: ["agg-b"],
csvRows: 1,
cost: 2,
claudeCost: 0,
budget: 2,
usage: [0, 0],
});
});
it("show an admin and an analyst everything", async () => {
assert.deepEqual(await numbers(cookies.boss), EVERYONE);
assert.deepEqual(await numbers(cookies.ana), EVERYONE);
});
it("refuse a member who names another member's workspace", async () => {
const res = await fetch(`${BASE}/api/pricing/cost?lane=${lanes.max}`, {
headers: { cookie: cookies.mia },
});
assert.equal(res.status, 404);
});
});
describe("aggregates in local mode", () => {
it("are unchanged: every session, no login", async () => {
delete process.env.AGENTHUB_MODE;
try {
assert.deepEqual(await numbers(), EVERYONE);
} finally {
process.env.AGENTHUB_MODE = "server";
}
});
});
+231
View File
@@ -0,0 +1,231 @@
/**
* @file server-mode-auth.test.js
* @description Server mode's login gate (server/lib/auth.js, routes/auth.js):
* local mode untouched, every API call behind a login, roles (analyst
* read-only, admin-only config writes), hooks from this machine only, logout
* and disable ending a login, the failed-login throttle, the WebSocket check,
* and the same-origin guard accepting the dashboard's own allowed Host.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("path");
const fs = require("fs");
const os = require("os");
const TEST_DB = path.join(os.tmpdir(), `server-mode-test-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
process.env.DASHBOARD_LIVENESS_PROBE = "0";
const { createApp, startServer } = require("../index");
const auth = require("../lib/auth");
const { sameOriginGuard } = require("../lib/same-origin");
let server;
let BASE;
async function call(method, p, { cookie, body, headers = {} } = {}) {
const res = await fetch(BASE + p, {
method,
headers: {
"Content-Type": "application/json",
...(cookie ? { cookie } : {}),
...headers,
},
body: body ? JSON.stringify(body) : undefined,
});
const text = await res.text();
let json = null;
try {
json = JSON.parse(text);
} catch {
/* not JSON */
}
return { status: res.status, body: json, setCookie: res.headers.get("set-cookie") };
}
async function loginAs(username, password) {
const r = await call("POST", "/api/auth/login", { body: { username, password } });
assert.equal(r.status, 200, JSON.stringify(r.body));
return r.setCookie.split(";")[0];
}
before(async () => {
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
await auth.createUser("boss", "admin-pass-1", "admin");
await auth.createUser("mia", "member-pass-1", "member");
await auth.createUser("ana", "analyst-pass-1", "analyst");
});
after(() => {
delete process.env.AGENTHUB_MODE;
server?.close();
for (const f of [TEST_DB, `${TEST_DB}-wal`, `${TEST_DB}-shm`, `${TEST_DB}-migrate.lock`]) {
fs.rmSync(f, { force: true });
}
});
describe("local mode (the default)", () => {
it("needs no login and says it is local", async () => {
delete process.env.AGENTHUB_MODE;
assert.equal((await call("GET", "/api/sessions")).status, 200);
assert.deepEqual((await call("GET", "/api/auth/me")).body, { mode: "local" });
assert.equal((await call("POST", "/api/auth/login", { body: {} })).status, 404);
});
});
describe("server mode", () => {
before(() => {
process.env.AGENTHUB_MODE = "server";
});
it("refuses the API without a login, but answers health and /auth/me", async () => {
const r = await call("GET", "/api/sessions");
assert.equal(r.status, 401);
assert.equal(r.body.error.code, "EAUTH");
assert.equal((await call("GET", "/api/health")).status, 200);
assert.deepEqual((await call("GET", "/api/auth/me")).body, { mode: "server", user: null });
});
it("rejects a wrong password and sets an httpOnly strict cookie on a right one", async () => {
const bad = await call("POST", "/api/auth/login", { body: { username: "mia", password: "x" } });
assert.equal(bad.status, 401);
assert.equal(bad.body.error.code, "EBADLOGIN");
const ok = await call("POST", "/api/auth/login", {
body: { username: "MIA", password: "member-pass-1" },
});
assert.equal(ok.status, 200);
assert.equal(ok.body.user.username, "mia");
assert.match(ok.setCookie, /agenthub_sid=[^;]+; Path=\/; HttpOnly; SameSite=Strict/);
const cookie = ok.setCookie.split(";")[0];
assert.equal((await call("GET", "/api/sessions", { cookie })).status, 200);
assert.equal((await call("GET", "/api/auth/me", { cookie })).body.user.role, "member");
});
it("keeps an analyst read-only and the host's config writes an admin's", async () => {
const analyst = await loginAs("ana", "analyst-pass-1");
assert.equal((await call("GET", "/api/sessions", { cookie: analyst })).status, 200);
const w = await call("POST", "/api/lanes/ensure", { cookie: analyst, body: {} });
assert.equal(w.status, 403);
assert.equal(w.body.error.code, "EREADONLY");
const member = await loginAs("mia", "member-pass-1");
// A path no router answers: the guard decides before any handler could
// write the real settings file.
const m = await call("PUT", "/api/settings/no-such-route", { cookie: member, body: {} });
assert.equal(m.status, 403);
assert.equal(m.body.error.code, "EADMIN");
const admin = await loginAs("boss", "admin-pass-1");
const a = await call("PUT", "/api/settings/no-such-route", { cookie: admin, body: {} });
assert.equal(a.status, 404);
});
it("takes hooks from this machine only", async () => {
const remote = await call("POST", "/api/hooks/event", {
body: {},
headers: { "x-forwarded-for": "203.0.113.9" },
});
assert.equal(remote.status, 403);
assert.equal(remote.body.error.code, "ELOCALONLY");
const local = await call("POST", "/api/hooks/event", { body: {} });
assert.ok(![401, 403].includes(local.status), `got ${local.status}`);
});
it("ends a login on logout and on disable", async () => {
const one = await loginAs("mia", "member-pass-1");
await call("POST", "/api/auth/logout", { cookie: one });
assert.equal((await call("GET", "/api/sessions", { cookie: one })).status, 401);
const two = await loginAs("mia", "member-pass-1");
auth.setDisabled("mia", true);
assert.equal((await call("GET", "/api/sessions", { cookie: two })).status, 401);
const again = await call("POST", "/api/auth/login", {
body: { username: "mia", password: "member-pass-1" },
});
assert.equal(again.status, 401);
auth.setDisabled("mia", false);
});
it("checks a WebSocket upgrade's login and Origin", async () => {
const cookie = await loginAs("boss", "admin-pass-1");
const req = (headers, url = "/ws") => ({ url, headers: { host: "127.0.0.1:1", ...headers } });
assert.equal(auth.isWebSocketLoggedIn(req({})), false);
assert.equal(auth.isWebSocketLoggedIn(req({ cookie })), true);
assert.equal(auth.isWebSocketLoggedIn(req({ cookie, origin: "https://evil.example" })), false);
const analyst = await loginAs("ana", "analyst-pass-1");
assert.equal(auth.isWebSocketLoggedIn(req({ cookie: analyst })), true);
assert.equal(
auth.isWebSocketLoggedIn(req({ cookie: analyst }, "/ws-pty/agenthub-lane-1")),
false
);
});
// Last: it locks this test's address out of logging in.
it("throttles repeated failed logins", async () => {
let last;
for (let i = 0; i < 11; i++) {
last = await call("POST", "/api/auth/login", { body: { username: "boss", password: "no" } });
}
assert.equal(last.status, 429);
assert.equal(last.body.error.code, "ETOOMANY");
});
});
describe("sameOriginGuard", () => {
const run = (headers) => {
let status = 200;
let passed = false;
const res = {
status(s) {
status = s;
return this;
},
json() {
return this;
},
};
sameOriginGuard({ headers }, res, () => (passed = true));
return passed ? 200 : status;
};
it("accepts the dashboard's own allowed Host as an Origin", () => {
process.env.DASHBOARD_ALLOWED_HOSTS = "agenthub.example.com";
try {
assert.equal(
run({ origin: "https://agenthub.example.com", host: "agenthub.example.com" }),
200
);
assert.equal(run({ origin: "https://evil.example", host: "agenthub.example.com" }), 403);
assert.equal(run({ origin: "https://evil.example", host: "evil.example" }), 403);
assert.equal(run({ origin: "http://localhost:4820", host: "localhost:4820" }), 200);
assert.equal(run({}), 200);
} finally {
delete process.env.DASHBOARD_ALLOWED_HOSTS;
}
});
});
describe("server mode prefix checks follow Express routing", () => {
before(() => {
process.env.AGENTHUB_MODE = "server";
});
it("refuses a member a staff-only router reached by case or encoding", async () => {
const r = await call("POST", "/api/auth/login", {
body: { username: "mia", password: "member-pass-1" },
headers: { "x-forwarded-for": "198.51.100.7" },
});
const cookie = r.setCookie.split(";")[0];
for (const p of ["/api/webhooks", "/api/Webhooks", "/api/%77ebhooks", "/api//webhooks"]) {
const res = await call("GET", p, { cookie });
assert.ok([403, 404].includes(res.status), `${p} answered ${res.status}`);
}
const hook = await call("POST", "/api/Hooks/event", {
body: {},
headers: { "x-forwarded-for": "203.0.113.9" },
});
assert.equal(hook.status, 403);
});
});
@@ -0,0 +1,352 @@
/**
* @file server-mode-review-fixes.test.js
* @description Regressions for the server-mode review's confirmed defects: a
* member cannot read another user's agent through `POST /api/agents`, cannot
* PATCH the pane/session columns of a workspace, cannot open the account pool;
* a transcript `agent_id`/`run_id` cannot walk out of its folder (any mode);
* push subscriptions belong to the user who made them, and a member's quota
* view names no account.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("node:path");
const fs = require("node:fs");
const os = require("node:os");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "server-mode-fixes-"));
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
process.env.DASHBOARD_LIVENESS_PROBE = "0";
// Named locks live under LANES_ROOT/.locks — never the real one.
process.env.LANES_ROOT = path.join(TMP, "lanes");
const { createApp, startServer } = require("../index");
const { db } = require("../db");
const auth = require("../lib/auth");
let server;
let BASE;
let laneMia;
let laneMax;
async function call(method, p, { cookie, body } = {}) {
const res = await fetch(BASE + p, {
method,
headers: { "Content-Type": "application/json", ...(cookie ? { cookie } : {}) },
body: body ? JSON.stringify(body) : undefined,
});
return { status: res.status, body: await res.json().catch(() => null) };
}
async function login(username, password) {
const res = await fetch(BASE + "/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
assert.equal(res.status, 200);
return res.headers.get("set-cookie").split(";")[0];
}
before(async () => {
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
const mia = await auth.createUser("mia", "member-pass-1", "member");
const max = await auth.createUser("max", "member-pass-2", "member");
await auth.createUser("boss", "admin-pass-1", "admin");
const lane = db.prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES (?, 'claude', ?)");
laneMia = lane.run("/w/mia", mia.id).lastInsertRowid;
laneMax = lane.run("/w/max", max.id).lastInsertRowid;
db.prepare(
"INSERT INTO sessions (id, name, status, cwd, lane_id) VALUES ('s-max', 'max', 'active', '/w/max', ?)"
).run(laneMax);
db.prepare(
"INSERT INTO agents (id, session_id, name, type) VALUES ('a-max', 's-max', 'Main', 'main')"
).run();
});
after(() => {
delete process.env.AGENTHUB_MODE;
server?.close();
fs.rmSync(TMP, { recursive: true, force: true });
});
describe("server mode review fixes", () => {
before(() => {
process.env.AGENTHUB_MODE = "server";
});
it("does not hand back another user's agent from POST /api/agents", async () => {
const mia = await login("mia", "member-pass-1");
const r = await call("POST", "/api/agents", {
cookie: mia,
body: { id: "a-max", session_id: `s-mia-${laneMia}`, name: "x" },
});
assert.equal(r.status, 404);
assert.ok(!JSON.stringify(r.body).includes("s-max"));
});
it("lets a member PATCH only a workspace's typed fields", async () => {
const mia = await login("mia", "member-pass-1");
const bad = await call("PATCH", `/api/lanes/${laneMia}`, {
cookie: mia,
body: { run_id: `agenthub-lane-${laneMax}` },
});
assert.equal(bad.status, 403);
assert.equal(db.prepare("SELECT run_id FROM lanes WHERE id = ?").get(laneMia).run_id, null);
const ok = await call("PATCH", `/api/lanes/${laneMia}`, {
cookie: mia,
body: { title: "Mine" },
});
assert.equal(ok.status, 200);
const admin = await login("boss", "admin-pass-1");
const staff = await call("PATCH", `/api/lanes/${laneMia}`, {
cookie: admin,
body: { notes: "x", status: "idle" },
});
assert.equal(staff.status, 200);
});
it("keeps the account pool and account names from members", async () => {
const mia = await login("mia", "member-pass-1");
assert.equal((await call("GET", "/api/accounts", { cookie: mia })).status, 403);
const q = await call("GET", "/api/pricing/quota", { cookie: mia });
assert.equal(q.status, 200);
for (const t of q.body.tools || []) assert.equal(t.account, null);
});
it("scopes push subscriptions to their user", async () => {
const mia = await login("mia", "member-pass-1");
const max = await login("max", "member-pass-2");
const sub = (endpoint, cookie) =>
call("POST", "/api/push/subscribe", {
cookie,
body: { endpoint, keys: { p256dh: "p", auth: "a" } },
});
await sub("https://push.example/mia", mia);
await sub("https://push.example/max", max);
const owners = db
.prepare("SELECT endpoint, user_id FROM push_subscriptions ORDER BY endpoint")
.all()
.map((r) => [r.endpoint.split("/").pop(), r.user_id]);
assert.deepEqual(
owners.map(([e]) => e),
["max", "mia"]
);
assert.notEqual(owners[0][1], owners[1][1]);
// max cannot unsubscribe mia's browser.
await call("DELETE", "/api/push/subscribe", {
cookie: max,
body: { endpoint: "https://push.example/mia" },
});
assert.equal(db.prepare("SELECT COUNT(*) AS n FROM push_subscriptions").get().n, 2);
});
});
describe("transcript ids (every mode)", () => {
it("refuses an agent_id or run_id that is a path", async () => {
delete process.env.AGENTHUB_MODE;
for (const q of ["agent_id=../../x", "run_id=..%2F..%2Fx", "agent_id=a%2Fb"]) {
const r = await call("GET", `/api/sessions/s-max/transcript?${q}`);
assert.equal(r.status, 400, q);
assert.equal(r.body.error.code, "EBADID");
}
const ok = await call("GET", "/api/sessions/s-max/transcript?agent_id=a-max");
assert.equal(ok.status, 200);
});
});
describe("server mode review fixes, second round", () => {
before(() => {
process.env.AGENTHUB_MODE = "server";
});
it("refuses a write whose Origin is not the dashboard's own", async () => {
const mia = await login("mia", "member-pass-1");
const res = await fetch(`${BASE}/api/lanes/${laneMia}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
cookie: mia,
origin: "https://wiki.example.com",
},
body: JSON.stringify({ title: "csrf" }),
});
assert.equal(res.status, 403);
assert.equal((await res.json()).error.code, "EBADORIGIN");
const own = await fetch(`${BASE}/api/lanes/${laneMia}`, {
method: "PATCH",
headers: { "Content-Type": "application/json", cookie: mia, origin: BASE },
body: JSON.stringify({ title: "fine" }),
});
assert.equal(own.status, 200);
});
it("counts parallel failed logins against the limit", async () => {
const tries = await Promise.all(
Array.from({ length: 25 }, () =>
fetch(`${BASE}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json", "x-forwarded-for": "192.0.2.55" },
body: JSON.stringify({ username: "max", password: "wrong" }),
}).then((r) => r.status)
)
);
assert.ok(tries.filter((s) => s === 401).length <= 10, JSON.stringify(tries));
assert.ok(tries.includes(429));
});
it("refuses a member a workspace that overlaps another user's, for any tool", async () => {
const mia = await login("mia", "member-pass-1");
for (const body of [{ cwd: "/w/max", tool: "codex" }, { cwd: "/w/max/sub" }, { cwd: "/w" }]) {
const r = await call("POST", "/api/lanes", { cookie: mia, body });
assert.equal(r.status, 409, JSON.stringify(body));
assert.equal(r.body.error.code, "EOWNED");
}
});
it("keeps members from planting sessions and from the shared pet store", async () => {
const mia = await login("mia", "member-pass-1");
const s = await call("POST", "/api/sessions", {
cookie: mia,
body: { id: "fake-1", name: "x" },
});
assert.equal(s.status, 403);
assert.equal(db.prepare("SELECT COUNT(*) AS n FROM sessions WHERE id = 'fake-1'").get().n, 0);
const p = await call("DELETE", "/api/pets/some-pet", { cookie: mia });
assert.equal(p.status, 403);
});
it("lets a live pane's CLI act as its workspace owner, and nothing else", async () => {
const { execFileSync } = require("node:child_process");
const crypto = require("node:crypto");
const run = `agenthub-lane-${laneMia}`;
const token = "ahpane_test-token-value-0123456789";
db.prepare(
`INSERT OR REPLACE INTO dashboard_runs (id, mode, cwd, status, lane_id, pane_token_hash)
VALUES (?, 'interactive', '/w/mia', 'running', ?, ?)`
).run(run, laneMia, crypto.createHash("sha256").update(token).digest("hex"));
const asPane = (p, extra = {}) =>
fetch(BASE + p, { headers: { "x-agenthub-pane-token": token, ...extra } }).then(
(r) => r.status
);
// No live tmux session yet: the credential is dead.
assert.equal(await asPane("/api/sessions"), 401);
execFileSync("tmux", ["new-session", "-d", "-s", run, "sleep", "120"]);
try {
assert.equal(await asPane("/api/sessions"), 200);
assert.equal(await asPane(`/api/lanes/${laneMax}`), 404); // still a member
assert.equal(await asPane("/api/sessions", { "x-forwarded-for": "203.0.113.1" }), 401);
assert.equal(
await fetch(BASE + "/api/sessions", {
headers: { "x-agenthub-pane-token": "ahpane_wrong" },
}).then((r) => r.status),
401
);
} finally {
execFileSync("tmux", ["kill-session", "-t", `=${run}`]);
}
});
it("sends a member the first frames of a session their own pane starts", async () => {
const WebSocket = require("ws");
const cookie = await login("mia", "member-pass-1");
const run = `agenthub-lane-${laneMia}-3`;
db.prepare(
`INSERT OR REPLACE INTO dashboard_runs (id, mode, cwd, status, lane_id)
VALUES (?, 'interactive', '/w/mia', 'running', ?)`
).run(run, laneMia);
const ws = new WebSocket(BASE.replace("http", "ws") + "/ws", { headers: { cookie } });
const seen = [];
ws.on("message", (m) => seen.push(JSON.parse(String(m))));
await new Promise((resolve, reject) => ws.on("open", resolve).on("error", reject));
try {
// The session is new: it has no workspace until bindRunSession stamps
// it, so frames sent before that stamp reached no member.
const res = await call("POST", "/api/hooks/event", {
body: {
hook_type: "SessionStart",
run_id: run,
data: { session_id: "s-new-mia", cwd: "/w/mia", source: "startup" },
},
});
assert.equal(res.status, 200);
await new Promise((r) => setTimeout(r, 300));
const mine = seen.filter((m) => JSON.stringify(m.data || {}).includes("s-new-mia"));
assert.ok(
mine.some((m) => m.type === "new_event"),
`got ${seen.map((m) => m.type).join(", ")}`
);
} finally {
ws.close();
}
});
it("sends members the config-change signal without its payload", () => {
const scope = require("../lib/scope");
const a = scope.broadcastAudience("cc_config_changed", { type: "profile", name: "boss@x" });
assert.equal(a.strip, true);
assert.equal(scope.audienceIncludes(a, { id: 1, role: "member" }), true);
});
});
describe("named locks", () => {
it("never takes a path for a lock name (any mode)", () => {
const namedLock = require("../lib/named-lock");
for (const bad of ["../x", "..", ".", "a/b", "a\\b", "", "x".repeat(256)]) {
assert.throws(() => namedLock.tryAcquire(bad, "me"), { code: "EBADNAME" }, bad);
assert.throws(() => namedLock.release(bad, "me"), { code: "EBADNAME" }, bad);
}
assert.throws(() => namedLock.tryAcquire("ok", " "), { code: "EBADHOLDER" });
// A holder with spaces keeps working — and now really holds the lock.
assert.deepEqual(namedLock.tryAcquire("ci", "ci bot"), { acquired: true });
assert.equal(namedLock.tryAcquire("ci", "other").acquired, false);
namedLock.release("ci", "ci bot");
// Names that were always valid directory names keep working.
for (const good of ["db:migrate", "build web", "-x"]) {
assert.deepEqual(namedLock.tryAcquire(good, "me"), { acquired: true }, good);
namedLock.release(good, "me");
}
});
it("gives each member their own lock namespace", async () => {
process.env.AGENTHUB_MODE = "server";
const mia = await login("mia", "member-pass-1");
const max = await login("max", "member-pass-2");
const take = (cookie, holder) =>
call("POST", "/api/locks/deploy/acquire", { cookie, body: { holder } });
assert.equal((await take(mia, "mia-agent")).status, 200);
assert.equal((await take(max, "max-agent")).status, 200); // not mia's lock
const seen = (await call("GET", "/api/locks", { cookie: max })).body.locks;
assert.deepEqual(
seen.map((l) => [l.name, l.holder]),
[["deploy", "max-agent"]]
);
const bad = await call("POST", "/api/locks/..%2F..%2Fx/acquire", {
cookie: mia,
body: { holder: "h" },
});
assert.ok([400, 404].includes(bad.status));
await call("POST", "/api/locks/deploy/release", { cookie: mia, body: { holder: "mia-agent" } });
await call("POST", "/api/locks/deploy/release", { cookie: max, body: { holder: "max-agent" } });
});
});
describe("data export", () => {
it("never writes a pane credential's hash into a bundle", async () => {
delete process.env.AGENTHUB_MODE;
db.prepare(
`INSERT OR REPLACE INTO dashboard_runs (id, mode, cwd, status, pane_token_hash)
VALUES ('agenthub-lane-999@x', 'interactive', '/w', 'killed', 'deadbeefhash')`
).run();
const text = await (await fetch(`${BASE}/api/settings/export`)).text();
assert.ok(text.includes("agenthub-lane-999@x"));
assert.ok(!text.includes("deadbeefhash"));
const { buildExportBundle } = require("../lib/data-transfer");
const bundle = buildExportBundle(db, require("../db").stmts);
assert.ok(bundle.dashboard_runs.some((r) => r.id === "agenthub-lane-999@x"));
assert.ok(!JSON.stringify(bundle).includes("deadbeefhash"));
});
});
+253
View File
@@ -0,0 +1,253 @@
/**
* @file server-mode-rows.test.js
* @description Server mode's per-user row ownership on the list and aggregate
* routes (phase S2): sessions (+ facets, POST), agents, events (+ facets),
* snapshots, the alert feed and ack-all, and the settings info counts. A
* member gets only their own workspaces' rows, another member theirs, an admin
* and an analyst everything, and local mode is unchanged.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("path");
const fs = require("fs");
const os = require("os");
const TEST_DB = path.join(os.tmpdir(), `server-mode-rows-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
process.env.DASHBOARD_LIVENESS_PROBE = "0";
const { createApp, startServer } = require("../index");
const { db } = require("../db");
const auth = require("../lib/auth");
let server;
let BASE;
const cookies = {};
async function call(method, p, { as, body } = {}) {
const res = await fetch(BASE + p, {
method,
headers: {
"Content-Type": "application/json",
...(as ? { cookie: cookies[as] } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
return { status: res.status, body: await res.json().catch(() => null) };
}
const get = async (p, as) => {
const r = await call("GET", p, { as });
assert.equal(r.status, 200, `${p}: ${JSON.stringify(r.body)}`);
return r.body;
};
const ids = (rows, key = "id") => rows.map((r) => r[key]).sort();
const ALL_SESSIONS = ["sA", "sA2", "sB", "sHost"];
before(async () => {
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
const alice = await auth.createUser("alice", "alice-pass-1", "member");
const bob = await auth.createUser("bob", "bob-pass-1", "member");
await auth.createUser("boss", "admin-pass-1", "admin");
await auth.createUser("ana", "analyst-pass-1", "analyst");
const lane = db.prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES (?, 'claude', ?)");
const laneA = lane.run("/w/alice", alice.id).lastInsertRowid;
const laneB = lane.run("/w/bob", bob.id).lastInsertRowid;
const session = db.prepare(
"INSERT INTO sessions (id, name, status, cwd, lane_id, source) VALUES (?, ?, 'active', ?, ?, ?)"
);
session.run("sA", "alice one", "/w/alice", laneA, "local");
session.run("sA2", "alice unstamped", "/w/alice", null, "local");
session.run("sB", "bob one", "/w/bob", laneB, "remote-b");
session.run("sHost", "host", "/w/host", null, "local");
// sA2 is alice's through a run of her workspace, not a stamp.
db.prepare(
"INSERT INTO dashboard_runs (id, session_id, mode, cwd, status, lane_id) VALUES ('agenthub-lane-1-2', 'sA2', 'interactive', '/w/alice', 'running', ?)"
).run(laneA);
const rule = db.prepare(
"INSERT INTO alert_rules (id, name, rule_type, config) VALUES ('r1', 'rule', 'error_rate', '{}')"
);
rule.run();
for (const sid of ALL_SESSIONS) {
db.prepare(
"INSERT INTO agents (id, session_id, name, type, status) VALUES (?, ?, 'Main', 'main', 'working')"
).run(`${sid}-main`, sid);
db.prepare(
"INSERT INTO events (session_id, agent_id, event_type, tool_name, summary) VALUES (?, ?, ?, ?, 'x')"
).run(sid, `${sid}-main`, `Type_${sid}`, `Tool_${sid}`);
db.prepare("INSERT INTO token_usage (session_id, model, input_tokens) VALUES (?, 'm', 10)").run(
sid
);
db.prepare(
"INSERT INTO alert_events (rule_id, rule_name, rule_type, session_id, message) VALUES ('r1', 'rule', 'error_rate', ?, 'boom')"
).run(sid);
db.prepare(
"INSERT INTO session_snapshots (token, session_id, payload) VALUES (?, ?, '{\"events\":[]}')"
).run(`tok-${sid}`.padEnd(32, "x"), sid);
}
// An alert with no session (a budget) is staff-only.
db.prepare(
"INSERT INTO alert_events (rule_id, rule_name, rule_type, session_id, message) VALUES ('r1', 'rule', 'budget', NULL, 'spend')"
).run();
process.env.AGENTHUB_MODE = "server";
for (const [name, pass] of [
["alice", "alice-pass-1"],
["bob", "bob-pass-1"],
["boss", "admin-pass-1"],
["ana", "analyst-pass-1"],
]) {
const r = await fetch(BASE + "/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: name, password: pass }),
});
assert.equal(r.status, 200);
cookies[name] = r.headers.get("set-cookie").split(";")[0];
}
});
after(() => {
delete process.env.AGENTHUB_MODE;
server?.close();
for (const f of [TEST_DB, `${TEST_DB}-wal`, `${TEST_DB}-shm`, `${TEST_DB}-migrate.lock`]) {
fs.rmSync(f, { force: true });
}
});
/** What `as` sees on every scoped list/aggregate route. */
async function view(as) {
const sessions = await get("/api/sessions", as);
const priced = await get("/api/sessions?sort_by=price", as);
const waiting = await get("/api/sessions?status=active&archived=0&q=alice", as);
const sFacets = await get("/api/sessions/facets", as);
const agents = await get("/api/agents", as);
const working = await get("/api/agents?status=working", as);
const events = await get("/api/events", as);
const eFacets = await get("/api/events/facets", as);
const snaps = await get("/api/snapshots", as);
const alerts = await get("/api/alerts", as);
const info = await get("/api/settings/info", as);
return {
sessions: ids(sessions.sessions),
total: sessions.total,
priced: ids(priced.sessions),
waiting: ids(waiting.sessions),
cwds: sFacets.cwds,
sources: sFacets.sources,
agents: ids(agents.agents, "session_id"),
working: ids(working.agents, "session_id"),
events: ids(events.events, "session_id"),
eventTotal: events.total,
tools: eFacets.tool_names,
snapshots: ids(snaps.snapshots, "session_id"),
alerts: alerts.alerts.map((a) => a.session_id).sort(),
alertTotal: alerts.total,
unacked: alerts.unacked,
counts: info.db.counts,
h1: info.db.load_stats.h1,
};
}
function assertSees(v, sids) {
assert.deepEqual(v.sessions, sids);
assert.equal(v.total, sids.length);
assert.deepEqual(v.priced, sids);
assert.deepEqual(v.agents, sids);
assert.deepEqual(v.working, sids);
assert.deepEqual(v.events, sids);
assert.equal(v.eventTotal, sids.length);
assert.deepEqual(
v.tools,
sids.map((s) => `Tool_${s}`)
);
assert.deepEqual(v.snapshots, sids);
assert.equal(v.counts.sessions, sids.length);
assert.equal(v.counts.agents, sids.length);
assert.equal(v.counts.events, sids.length);
assert.equal(v.counts.token_usage, sids.length);
assert.equal(v.h1, sids.length);
}
describe("server mode: list and aggregate routes", () => {
it("shows a member only their own workspaces' rows, unstamped ones included", async () => {
const v = await view("alice");
assertSees(v, ["sA", "sA2"]);
assert.deepEqual(v.waiting, ["sA", "sA2"]);
assert.deepEqual(v.cwds, ["/w/alice"]);
assert.deepEqual(v.sources, ["local"]);
assert.deepEqual(v.alerts, ["sA", "sA2"]);
assert.equal(v.alertTotal, 2);
assert.equal(v.unacked, 2);
});
it("shows another member theirs", async () => {
const v = await view("bob");
assertSees(v, ["sB"]);
assert.deepEqual(v.waiting, []);
assert.deepEqual(v.cwds, ["/w/bob"]);
assert.deepEqual(v.sources, ["remote-b"]);
assert.deepEqual(v.alerts, ["sB"]);
assert.equal(v.unacked, 1);
});
it("shows an admin and an analyst everything", async () => {
for (const as of ["boss", "ana"]) {
const v = await view(as);
assertSees(v, ALL_SESSIONS);
assert.deepEqual(v.waiting, ["sA", "sA2"]);
assert.deepEqual(v.cwds, ["/w/alice", "/w/bob", "/w/host"]);
assert.deepEqual(v.sources, ["local", "remote-b"]);
assert.equal(v.alerts.length, 5); // the budget alert included
assert.equal(v.unacked, 5);
}
});
it("keeps a member out of another's rows through filters and POST", async () => {
// A filter naming another's session is refused, not answered empty.
assert.equal((await call("GET", "/api/events?session_id=sB", { as: "alice" })).status, 404);
assert.equal((await call("GET", "/api/agents?session_id=sB", { as: "alice" })).status, 404);
// An agent_id filter cannot reach past the ownership predicate.
const ev = await get("/api/events?agent_id=sB-main", "alice");
assert.equal(ev.total, 0);
// A member cannot create (or probe) sessions through POST /api/sessions at
// all — sessions come from hooks and the dashboard's own panes.
const r = await call("POST", "/api/sessions", { as: "alice", body: { id: "sB" } });
assert.equal(r.status, 403);
assert.ok(!JSON.stringify(r.body).includes("sB"));
const own = await call("POST", "/api/sessions", { as: "alice", body: { id: "sA" } });
assert.equal(own.status, 403);
// A snapshot of another's session cannot be captured.
const snap = await call("POST", "/api/snapshots", { as: "alice", body: { session_id: "sB" } });
assert.equal(snap.status, 404);
});
it("lets a member acknowledge only their own alerts", async () => {
const r = await call("POST", "/api/alerts/ack-all", { as: "alice" });
assert.equal(r.status, 200);
assert.equal(r.body.acknowledged, 2);
assert.equal((await get("/api/alerts", "alice")).unacked, 0);
assert.equal((await get("/api/alerts", "bob")).unacked, 1);
assert.equal((await get("/api/alerts?unacked=true", "boss")).total, 3);
});
});
describe("local mode", () => {
before(() => delete process.env.AGENTHUB_MODE);
after(() => (process.env.AGENTHUB_MODE = "server"));
it("needs no login and lists every row", async () => {
const v = await view();
assertSees(v, ALL_SESSIONS);
assert.equal(v.alerts.length, 5);
assert.equal(v.unacked, 3);
const ack = await call("POST", "/api/alerts/ack-all");
assert.equal(ack.body.acknowledged, 3);
});
});
+107
View File
@@ -0,0 +1,107 @@
/**
* @file server-mode-users.test.js
* @description Server mode's staff views: `GET /api/users` lists each account's
* workspaces, sessions and recent spend to an admin/analyst and refuses a
* member; an admin/analyst reading a member's session is written to the audit
* log (`GET /api/users/audit`), while a member reading their own, or a read of
* an ownerless session, is not.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("node:path");
const fs = require("node:fs");
const os = require("node:os");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "server-mode-users-"));
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
process.env.DASHBOARD_LIVENESS_PROBE = "0";
const { createApp, startServer } = require("../index");
const { db } = require("../db");
const auth = require("../lib/auth");
let server;
let BASE;
const ids = {};
async function call(p, cookie) {
const res = await fetch(BASE + p, { headers: cookie ? { cookie } : {} });
return { status: res.status, body: await res.json().catch(() => null) };
}
async function login(username, password) {
const res = await fetch(BASE + "/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
assert.equal(res.status, 200);
return res.headers.get("set-cookie").split(";")[0];
}
before(async () => {
process.env.AGENTHUB_MODE = "server";
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
ids.boss = (await auth.createUser("boss", "admin-pass-1", "admin")).id;
ids.ana = (await auth.createUser("ana", "analyst-pass-1", "analyst")).id;
ids.mia = (await auth.createUser("mia", "member-pass-1", "member")).id;
const lane = db
.prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES ('/w/mia', 'claude', ?)")
.run(ids.mia).lastInsertRowid;
db.prepare(
"INSERT INTO sessions (id, name, status, cwd, lane_id) VALUES ('sm', 'mia work', 'active', '/w/mia', ?)"
).run(lane);
db.prepare(
"INSERT INTO sessions (id, name, status, cwd) VALUES ('sx', 'host work', 'active', '/w/host')"
).run();
db.prepare(
`INSERT INTO token_usage_hourly (session_id, hour, model, output_tokens)
VALUES ('sm', ?, 'claude-sonnet-4-5-20250929', 1000000)`
).run(new Date().toISOString().slice(0, 13));
});
after(() => {
delete process.env.AGENTHUB_MODE;
server?.close();
fs.rmSync(TMP, { recursive: true, force: true });
});
describe("staff views", () => {
it("lists every account's usage to staff and refuses a member", async () => {
const admin = await login("boss", "admin-pass-1");
const r = await call("/api/users", admin);
assert.equal(r.status, 200);
const mia = r.body.users.find((u) => u.username === "mia");
assert.equal(mia.workspaces, 1);
assert.equal(mia.sessions, 1);
assert.ok(mia.last7d.cost > 0 && mia.last30d.cost >= mia.last7d.cost);
assert.ok(!JSON.stringify(r.body).includes("scrypt"), "no password hash");
assert.equal((await call("/api/users", await login("ana", "analyst-pass-1"))).status, 200);
const member = await call("/api/users", await login("mia", "member-pass-1"));
assert.equal(member.status, 403);
});
it("audits a staff read of a member's session, and nothing else", async () => {
const admin = await login("boss", "admin-pass-1");
const mia = await login("mia", "member-pass-1");
const before = (await call("/api/users/audit", admin)).body.entries.length;
assert.equal((await call("/api/sessions/sm", mia)).status, 200); // own read
assert.equal((await call("/api/sessions/sx", admin)).status, 200); // ownerless
assert.equal((await call("/api/sessions/sm/transcript", admin)).status < 500, true);
await call("/api/sessions/sm", await login("ana", "analyst-pass-1"));
const entries = (await call("/api/users/audit", admin)).body.entries;
assert.equal(entries.length - before, 2);
assert.deepEqual(
entries.slice(0, 2).map((e) => [e.reader, e.owner, e.session_id]),
[
["ana", "mia", "sm"],
["boss", "mia", "sm"],
]
);
});
});
@@ -0,0 +1,175 @@
/**
* @file server-mode-workflows.test.js
* @description Server mode's row ownership on the workflow endpoints
* (server/routes/workflows.js, server/lib/workflow-stats.js run through the
* read worker): GET /api/workflows, /api/workflows/skill-usage and
* /api/workflows/runs count and list only a member's own sessions, another
* member sees theirs, an admin and an analyst see every row, and local mode
* answers exactly what an admin does.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("node:path");
const fs = require("node:fs");
const os = require("node:os");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "server-mode-workflows-"));
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
process.env.DASHBOARD_LIVENESS_PROBE = "0";
const { createApp, startServer } = require("../index");
const { db } = require("../db");
const auth = require("../lib/auth");
let server;
let BASE;
const cookies = {};
async function get(p, cookie) {
const res = await fetch(BASE + p, { headers: cookie ? { cookie } : {} });
return { status: res.status, body: await res.json() };
}
async function loginAs(username, password) {
const res = await fetch(BASE + "/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
assert.equal(res.status, 200);
return res.headers.get("set-cookie").split(";")[0];
}
/** One session with a main agent, a Skill call, a Bash call and a workflow run. */
function seedSession(id, laneId, skill, wfStatus) {
db.prepare(
`INSERT INTO sessions (id, name, status, cwd, lane_id, started_at, ended_at)
VALUES (?, ?, 'completed', '/w', ?, '2026-10-01T00:00:00.000Z', '2026-10-01T00:10:00.000Z')`
).run(id, id, laneId);
db.prepare(
"INSERT INTO agents (id, session_id, name, type, status) VALUES (?, ?, 'Main', 'main', 'completed')"
).run(`${id}-main`, id);
const ev = db.prepare(
"INSERT INTO events (session_id, event_type, tool_name, data) VALUES (?, 'PreToolUse', ?, ?)"
);
ev.run(id, "Skill", JSON.stringify({ tool_input: { skill } }));
ev.run(id, "Bash", null);
db.prepare(
"INSERT INTO workflows (run_id, session_id, name, status, started_at) VALUES (?, ?, ?, ?, '2026-10-01T00:00:00.000Z')"
).run(`wf-${id}`, id, id, wfStatus);
}
before(async () => {
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
const mia = await auth.createUser("mia", "member-pass-1", "member");
const max = await auth.createUser("max", "member-pass-2", "member");
await auth.createUser("boss", "admin-pass-1", "admin");
await auth.createUser("ana", "analyst-pass-1", "analyst");
const lane = db.prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES (?, 'claude', ?)");
const miaLane = lane.run("/w/mia", mia.id).lastInsertRowid;
const maxLane = lane.run("/w/max", max.id).lastInsertRowid;
seedSession("m1", miaLane, "mia:one", "completed");
seedSession("m2", null, "mia:two", "running"); // not stamped: held by mia's run
db.prepare(
"INSERT INTO dashboard_runs (id, session_id, mode, cwd, status, lane_id) VALUES (?, 'm2', 'interactive', '/w', 'running', ?)"
).run(`agenthub-lane-${miaLane}-1`, miaLane);
seedSession("x1", maxLane, "max:one", "failed");
seedSession("h1", null, "host:one", "completed"); // nobody's workspace
});
after(() => {
delete process.env.AGENTHUB_MODE;
server?.close();
fs.rmSync(TMP, { recursive: true, force: true });
});
const ALL = ["h1", "m1", "m2", "x1"];
async function view(cookie) {
const wf = await get("/api/workflows?sections=stats,complexity,modelDelegation", cookie);
const skills = await get("/api/workflows/skill-usage", cookie);
const runs = await get("/api/workflows/runs", cookie);
const byStatus = await get("/api/workflows/runs?status=completed", cookie);
for (const r of [wf, skills, runs, byStatus]) assert.equal(r.status, 200, JSON.stringify(r));
return {
wf: wf.body,
sessions: wf.body.complexity.map((s) => s.id).sort(),
totalSessions: wf.body.stats.totalSessions,
totalAgents: wf.body.stats.totalAgents,
skills: skills.body.skills.map((s) => s.name).sort(),
plugins: skills.body.plugins.map((p) => p.name).sort(),
runs: runs.body.runs.map((r) => r.session_id).sort(),
total: runs.body.total,
counts: runs.body.counts,
completed: byStatus.body.runs.map((r) => r.session_id).sort(),
completedTotal: byStatus.body.total,
};
}
let localView;
describe("local mode", () => {
it("reads every row", async () => {
localView = await view();
assert.deepEqual(localView.sessions, ALL);
assert.equal(localView.totalSessions, 4);
assert.equal(localView.totalAgents, 4);
assert.deepEqual(localView.skills, ["host:one", "max:one", "mia:one", "mia:two"]);
assert.deepEqual(localView.runs, ALL);
assert.equal(localView.total, 4);
assert.deepEqual(localView.counts, { completed: 2, running: 1, failed: 1 });
assert.deepEqual(localView.completed, ["h1", "m1"]);
});
});
describe("server mode", () => {
before(async () => {
process.env.AGENTHUB_MODE = "server";
cookies.mia = await loginAs("mia", "member-pass-1");
cookies.max = await loginAs("max", "member-pass-2");
cookies.boss = await loginAs("boss", "admin-pass-1");
cookies.ana = await loginAs("ana", "analyst-pass-1");
});
it("shows a member only their own sessions in every number", async () => {
const v = await view(cookies.mia);
assert.deepEqual(v.sessions, ["m1", "m2"]);
assert.equal(v.totalSessions, 2);
assert.equal(v.totalAgents, 2);
assert.deepEqual(v.skills, ["mia:one", "mia:two"]);
assert.deepEqual(v.plugins, ["mia"]);
assert.deepEqual(v.runs, ["m1", "m2"]);
assert.equal(v.total, 2);
assert.deepEqual(v.counts, { completed: 1, running: 1 });
assert.deepEqual(v.completed, ["m1"]);
assert.equal(v.completedTotal, 1);
});
it("shows another member theirs", async () => {
const v = await view(cookies.max);
assert.deepEqual(v.sessions, ["x1"]);
assert.equal(v.totalSessions, 1);
assert.deepEqual(v.skills, ["max:one"]);
assert.deepEqual(v.runs, ["x1"]);
assert.deepEqual(v.counts, { failed: 1 });
assert.deepEqual(v.completed, []);
});
it("refuses a member another member's session filter", async () => {
const r = await get("/api/workflows/runs?session_id=x1", cookies.mia);
assert.equal(r.status, 404);
});
it("shows an admin and an analyst exactly what local mode shows", async () => {
for (const who of ["boss", "ana"]) {
const v = await view(cookies[who]);
assert.deepEqual({ ...v, wf: null }, { ...localView, wf: null });
assert.deepEqual(v.wf.stats, localView.wf.stats);
assert.deepEqual(v.wf.modelDelegation, localView.wf.modelDelegation);
}
});
});
@@ -0,0 +1,245 @@
/**
* @file server-mode-workspaces.test.js
* @description Server mode's per-user row ownership on the workspace routes
* (phase S2): the workspace list and its counts, pipeline stats, housekeeping
* (`/lanes/gc`), projects, the live and past run lists, folder suggestions,
* the `@` file walk, starting and killing a run, and a resume of another
* user's session. A member gets only their own rows, another member theirs,
* an admin and an analyst everything, and local mode is unchanged.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("path");
const fs = require("fs");
const os = require("os");
const TEST_DB = path.join(os.tmpdir(), `server-mode-ws-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
process.env.DASHBOARD_LIVENESS_PROBE = "0";
// A private tmux server: nothing here may list or kill the user's real panes.
const TMUX_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "server-mode-ws-tmux-"));
process.env.TMUX_TMPDIR = TMUX_DIR;
delete process.env.TMUX;
const { createApp, startServer } = require("../index");
const { db } = require("../db");
const auth = require("../lib/auth");
let server;
let BASE;
const cookies = {};
const ROOT = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "server-mode-ws-")));
const DIR_A = path.join(ROOT, "alice");
const DIR_B = path.join(ROOT, "bob");
const LANE_A = 9001;
const LANE_B = 9002;
async function call(method, p, { as, body } = {}) {
const res = await fetch(BASE + p, {
method,
headers: {
"Content-Type": "application/json",
...(as ? { cookie: cookies[as] } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
return { status: res.status, body: await res.json().catch(() => null) };
}
const get = async (p, as) => {
const r = await call("GET", p, { as });
assert.equal(r.status, 200, `${p}: ${JSON.stringify(r.body)}`);
return r.body;
};
const laneIds = async (as) => (await get("/api/lanes", as)).lanes.map((l) => l.id).sort();
before(async () => {
for (const d of [DIR_A, DIR_B]) {
fs.mkdirSync(d);
fs.writeFileSync(path.join(d, `${path.basename(d)}.txt`), "x");
}
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
const alice = await auth.createUser("alice", "alice-pass-1", "member");
const bob = await auth.createUser("bob", "bob-pass-1", "member");
await auth.createUser("boss", "admin-pass-1", "admin");
await auth.createUser("ana", "analyst-pass-1", "analyst");
const lane = db.prepare(
"INSERT INTO lanes (id, cwd, tool, owner_id, source_repo) VALUES (?, ?, 'claude', ?, ?)"
);
lane.run(LANE_A, DIR_A, alice.id, DIR_A);
lane.run(LANE_B, DIR_B, bob.id, DIR_B);
const session = db.prepare(
"INSERT INTO sessions (id, name, status, cwd, lane_id) VALUES (?, 'x', 'completed', ?, ?)"
);
session.run("sA-0000001", DIR_A, LANE_A);
session.run("sB-0000001", DIR_B, LANE_B);
const stage = db.prepare(
"INSERT INTO lane_stage_events (lane_id, pipeline, node_id) VALUES (?, 'default', 'x')"
);
stage.run(LANE_A);
stage.run(LANE_B);
const run = db.prepare(
"INSERT INTO dashboard_runs (id, mode, cwd, status, lane_id, started_at) VALUES (?, 'interactive', ?, 'exited', ?, ?)"
);
run.run(`agenthub-lane-${LANE_A}@1`, DIR_A, LANE_A, "2026-01-01T00:00:00Z");
run.run(`agenthub-lane-${LANE_B}@1`, DIR_B, LANE_B, "2026-01-02T00:00:00Z");
for (const [name, pass] of [
["alice", "alice-pass-1"],
["bob", "bob-pass-1"],
["boss", "admin-pass-1"],
["ana", "analyst-pass-1"],
]) {
process.env.AGENTHUB_MODE = "server";
const res = await fetch(BASE + "/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: name, password: pass }),
});
assert.equal(res.status, 200);
cookies[name] = res.headers.get("set-cookie").split(";")[0];
}
delete process.env.AGENTHUB_MODE;
});
after(() => {
delete process.env.AGENTHUB_MODE;
server?.close();
for (const f of [TEST_DB, `${TEST_DB}-wal`, `${TEST_DB}-shm`, `${TEST_DB}-migrate.lock`]) {
fs.rmSync(f, { force: true });
}
fs.rmSync(ROOT, { recursive: true, force: true });
fs.rmSync(TMUX_DIR, { recursive: true, force: true });
});
describe("local mode", () => {
it("lists every workspace, run and folder as before", async () => {
assert.deepEqual(await laneIds(), [LANE_A, LANE_B]);
assert.equal((await get("/api/lanes/pipeline-stats")).lanes.length, 2);
const hist = (await get("/api/run/history")).items.map((r) => r.lane_id).sort();
assert.deepEqual(hist, [LANE_A, LANE_B]);
const kinds = (await get("/api/run/cwds")).items.map((i) => i.kind);
assert.ok(kinds.includes("dashboard") && kinds.includes("home"));
assert.equal((await get(`/api/run/files?cwd=${encodeURIComponent(DIR_B)}`)).items.length, 1);
});
});
describe("server mode", () => {
before(() => {
process.env.AGENTHUB_MODE = "server";
});
after(() => {
delete process.env.AGENTHUB_MODE;
});
it("GET /api/lanes lists and counts only a member's own workspaces", async () => {
assert.deepEqual(await laneIds("alice"), [LANE_A]);
assert.deepEqual(await laneIds("bob"), [LANE_B]);
assert.equal((await get("/api/lanes", "alice")).counts.total, 1);
assert.deepEqual(await laneIds("boss"), [LANE_A, LANE_B]);
assert.deepEqual(await laneIds("ana"), [LANE_A, LANE_B]);
assert.deepEqual(
(await get("/api/workspaces", "bob")).lanes.map((l) => l.id),
[LANE_B]
);
});
it("pipeline stats cover only a member's own workspaces", async () => {
const mine = await get("/api/lanes/pipeline-stats", "alice");
assert.deepEqual(
mine.lanes.map((l) => l.id),
[LANE_A]
);
assert.equal(mine.pipelines[0].runs, 1);
const all = await get("/api/lanes/pipeline-stats", "ana");
assert.equal(all.lanes.length, 2);
assert.equal(all.pipelines[0].runs, 2);
assert.equal(
(await call("GET", `/api/lanes/pipeline-stats?lane=${LANE_B}`, { as: "alice" })).status,
404
);
});
it("housekeeping is refused to a member", async () => {
const r = await call("POST", "/api/lanes/gc", { as: "alice", body: { dryRun: true } });
assert.equal(r.status, 403);
assert.equal(r.body.error.code, "EADMIN");
assert.equal(
(await call("POST", "/api/lanes/gc", { as: "boss", body: { dryRun: true } })).status,
200
);
});
it("projects are built from a member's own workspaces", async () => {
const ws = (b) => b.projects.flatMap((p) => p.workspaces).sort();
assert.deepEqual(ws(await get("/api/projects", "alice")), [LANE_A]);
assert.deepEqual(ws(await get("/api/projects", "bob")), [LANE_B]);
assert.deepEqual(ws(await get("/api/projects", "ana")), [LANE_A, LANE_B]);
const r = await call("POST", "/api/projects/refresh", { as: "alice" });
assert.deepEqual(ws(r.body), [LANE_A]);
});
it("run lists and history show only a member's own runs", async () => {
const hist = async (as, q = "") =>
(await get(`/api/run/history${q}`, as)).items.map((r) => r.lane_id).sort();
assert.deepEqual(await hist("alice"), [LANE_A]);
assert.deepEqual(await hist("bob"), [LANE_B]);
assert.deepEqual(await hist("boss"), [LANE_A, LANE_B]);
assert.deepEqual(await hist("alice", "?limit=1"), [LANE_A]);
assert.equal(
(await call("GET", `/api/run/history?laneId=${LANE_B}`, { as: "alice" })).status,
404
);
const live = await get("/api/run", "alice");
assert.ok(live.items.every((r) => r.laneId === LANE_A));
});
it("folder suggestions come from a member's own workspaces and sessions", async () => {
const paths = async (as) => (await get("/api/run/cwds", as)).items.map((i) => i.path);
assert.deepEqual(await paths("alice"), [DIR_A]);
assert.deepEqual(await paths("bob"), [DIR_B]);
const all = await paths("ana");
assert.ok(all.includes(DIR_A) && all.includes(DIR_B) && all.includes(os.homedir()));
});
it("the file walk stays inside a member's own workspaces", async () => {
const files = (as, dir) => call("GET", `/api/run/files?cwd=${encodeURIComponent(dir)}`, { as });
assert.deepEqual((await files("alice", DIR_A)).body.items, ["alice.txt"]);
assert.equal((await files("alice", DIR_B)).status, 403);
assert.equal((await call("GET", "/api/run/files", { as: "alice" })).status, 403);
// A symlink inside her folder does not reach bob's.
fs.symlinkSync(DIR_B, path.join(DIR_A, "link"));
assert.equal((await files("alice", path.join(DIR_A, "link"))).status, 403);
assert.deepEqual((await files("boss", DIR_B)).body.items, ["bob.txt"]);
});
it("a member's run starts only in their own workspace and folder", async () => {
const start = (body) => call("POST", "/api/run", { as: "alice", body });
assert.equal((await start({})).status, 400);
assert.equal((await start({ laneId: LANE_B })).status, 404);
const r = await start({ laneId: LANE_A, cwd: DIR_B });
assert.equal(r.status, 403);
assert.equal(r.body.error.code, "EFOLDER");
});
it("another user's run cannot be read or killed, encoded or not", async () => {
const id = `agenthub-lane-${LANE_B}`;
assert.equal((await call("DELETE", `/api/run/${id}`, { as: "alice" })).status, 404);
const encoded = `%61genthub-lane-${LANE_B}`;
assert.equal((await call("DELETE", `/api/run/${encoded}`, { as: "alice" })).status, 404);
assert.equal((await call("GET", `/api/run/${encoded}`, { as: "alice" })).status, 404);
});
it("a member cannot resume another user's session in their own workspace", async () => {
const r = await call("POST", `/api/lanes/${LANE_A}/start`, {
as: "alice",
body: { resumeSessionId: "sB-0000001" },
});
assert.equal(r.status, 404);
});
});
+66
View File
@@ -715,6 +715,13 @@ try {
db.prepare("ALTER TABLE dashboard_runs ADD COLUMN proxy TEXT").run();
}
}
// Which Claude pool account a run's pane was started on (server/lib/claude-pool.js),
// by name; NULL = the machine's own login. The balancer sums spend per account.
try {
db.prepare("SELECT account FROM dashboard_runs LIMIT 1").get();
} catch {
db.prepare("ALTER TABLE dashboard_runs ADD COLUMN account TEXT").run();
}
// Migrate: add stage-detection columns to lanes. Inference is never evidence —
// these are additive columns separate from `stage` (the declared stage), so
@@ -1649,6 +1656,65 @@ db.prepare(
`
).run();
// Server mode: SHA-256 of the pane credential (`AGENTHUB_PANE_TOKEN`) a run's
// pane was started with — the CLI, MCP server and statusline inside the pane
// send it to act as the workspace's owner (server/lib/auth.js). Never listed.
try {
db.prepare("SELECT pane_token_hash FROM dashboard_runs LIMIT 1").get();
} catch {
db.prepare("ALTER TABLE dashboard_runs ADD COLUMN pane_token_hash TEXT").run();
}
// Server mode: whose browser a push subscription is (`users.id`); NULL in
// local mode. A user's notifications go only to their own subscriptions.
try {
db.prepare("SELECT user_id FROM push_subscriptions LIMIT 1").get();
} catch {
db.prepare("ALTER TABLE push_subscriptions ADD COLUMN user_id INTEGER").run();
}
// Server mode: the user who created a workspace (`users.id`); NULL = made in
// local mode or by the host account, which only an admin/analyst sees. Every
// session, agent, event and token row a member may see hangs off this
// (server/lib/scope.js).
try {
db.prepare("SELECT owner_id FROM lanes LIMIT 1").get();
} catch {
db.prepare("ALTER TABLE lanes ADD COLUMN owner_id INTEGER").run();
}
// Server mode's accounts (server/lib/auth.js). Created on every install so
// switching the mode on needs no migration; empty and unread in local mode.
// A password is stored only as an scrypt hash, a login session only as the
// SHA-256 of its cookie token.
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
role TEXT NOT NULL DEFAULT 'member' CHECK(role IN ('admin','analyst','member')),
password_hash TEXT NOT NULL,
disabled INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
-- Every read by an admin/analyst of a session a member owns (server/lib/audit.js).
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
owner_id INTEGER,
session_id TEXT,
method TEXT NOT NULL,
path TEXT NOT NULL,
at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE INDEX IF NOT EXISTS idx_audit_log_at ON audit_log(at);
CREATE TABLE IF NOT EXISTS auth_sessions (
token_hash TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
expires_at TEXT NOT NULL
);
`);
migrationLock.exec("COMMIT");
migrationLock.close();
+20 -1
View File
@@ -100,6 +100,12 @@ const laneSourcesRouter = require("./routes/lane-sources");
const locksRouter = require("./routes/locks");
const petsRouter = require("./routes/pets");
const snapshotsRouter = require("./routes/snapshots");
const authRouter = require("./routes/auth");
const accountsRouter = require("./routes/accounts");
const usersRouter = require("./routes/users");
const { auditGuard } = require("./lib/audit");
const { authGuard, isServerMode, hasAdmin } = require("./lib/auth");
const { scopeGuard } = require("./lib/scope");
const APP_VERSION = (() => {
try {
@@ -119,6 +125,14 @@ function createApp() {
app.use(hostGuard);
app.use(express.json({ limit: "1mb" }));
app.use("/api", tokenGuard);
// Server mode's login gate (server/lib/auth.js); a no-op in local mode.
app.use("/api", authGuard);
// Server mode, phase S2: a member reaches only rows of their own workspaces.
app.use("/api", scopeGuard);
app.use("/api", auditGuard);
app.use("/api/auth", authRouter);
app.use("/api/accounts", accountsRouter);
app.use("/api/users", usersRouter);
app.use("/api/sessions", sessionsRouter);
app.use("/api/agents", agentsRouter);
@@ -293,7 +307,12 @@ function startServer(app, port) {
const mode = isProduction ? "production" : "development";
const shown = boundLoopback ? "localhost" : host;
console.log(`Agent Dashboard server running on http://${shown}:${port} (${mode})`);
if (!boundLoopback) {
if (isServerMode()) {
console.log("Server mode: every page and API call needs a login.");
if (!hasAdmin()) {
console.warn("⚠️ Server mode has no admin yet — run: agenthub server setup");
}
} else if (!boundLoopback) {
console.warn(
`⚠️ Dashboard bound to ${host} — reachable from the network. ` +
(getDashboardToken()
+60
View File
@@ -0,0 +1,60 @@
/**
* @file audit.js
* @description Server mode's audit trail (docs/SERVER-MODE.md): every read by
* an admin or analyst of a session that a MEMBER owns — the session page, its
* transcript, its events, its cost — is written to `audit_log` with who read
* it, whose it was and which path. `auditGuard` runs on `/api` after the
* scope guard; it never blocks a request, and it is a no-op in local mode and
* for a member (who can only read their own).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const sql = (q) => require("../db").db.prepare(q);
// Paths that name one session's content.
const SESSION_PATHS = [
/^\/sessions\/(?!facets\/?$)([^/]+)/i,
/^\/pricing\/cost\/([^/]+)/i,
/^\/workflows\/session\/([^/]+)/i,
];
function auditGuard(req, res, next) {
const u = req.user;
if (!u || u.role === "member" || (req.method !== "GET" && req.method !== "HEAD")) return next();
const p = require("./auth").apiPath(req);
for (const re of SESSION_PATHS) {
const m = re.exec(p);
if (!m) continue;
try {
const { sessionOwners } = require("./scope");
const id = m[1];
for (const owner of sessionOwners(id)) {
if (owner === u.id) continue;
const ownerRole = sql("SELECT role FROM users WHERE id = ?").get(owner)?.role;
if (ownerRole !== "member") continue;
sql(
"INSERT INTO audit_log (user_id, owner_id, session_id, method, path) VALUES (?, ?, ?, ?, ?)"
).run(u.id, owner, id, req.method, req.originalUrl.slice(0, 500));
}
} catch (err) {
console.warn("[audit] could not record:", err.message);
}
break;
}
return next();
}
/** The newest entries, with user names. */
function listAudit(limit = 100) {
const n = Math.min(Math.max(Number(limit) || 100, 1), 1000);
return sql(
`SELECT a.id, a.at, a.method, a.path, a.session_id,
r.username AS reader, o.username AS owner
FROM audit_log a
LEFT JOIN users r ON r.id = a.user_id
LEFT JOIN users o ON o.id = a.owner_id
ORDER BY a.id DESC LIMIT ?`
).all(n);
}
module.exports = { auditGuard, listAudit };
+456
View File
@@ -0,0 +1,456 @@
/**
* @file auth.js
* @description Server mode's accounts and login (docs/SERVER-MODE.md). Off
* unless `AGENTHUB_MODE=server`: in local mode every function here answers
* "allowed" and nothing reads the `users`/`auth_sessions` tables.
*
* - A password is stored as `scrypt$<salt>$<hash>` (node:crypto, async so a
* login never blocks the event loop) and compared in constant time.
* - A login is a random 32-byte token in the httpOnly, SameSite=Strict cookie
* `agenthub_sid`; only its SHA-256 is stored, for `SESSION_DAYS` days. A
* disabled user's sessions stop working at once (the lookup joins `users`).
* - `authGuard` (mounted on `/api`) lets through `/health` and `/auth/*`, the
* hook routes only from this machine (`isLocalRequest`; `ingest-batch` keeps
* its own bearer token), and everything else only with a valid login. An
* `analyst` may only read; writes under `ADMIN_WRITE_PREFIXES` — the host
* account's own configuration — are an `admin`'s.
*
* This is login, not isolation: every member still sees every workspace and
* session, and every pane runs as the one host account (phases S2/S3 of the
* multi-user plan).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const crypto = require("node:crypto");
const { promisify } = require("node:util");
// Required on first use: a module that only loads this (websocket.js) must not
// open the database in local mode, where nothing here touches it.
const sql = (q) => require("../db").db.prepare(q);
const { isLocalRequest, isLoopback, isOwnHost } = require("./same-origin");
const { isHostAllowed } = require("./security");
const scrypt = promisify(crypto.scrypt);
const COOKIE = "agenthub_sid";
const SESSION_DAYS = 30;
const ROLES = ["admin", "analyst", "member"];
const USERNAME_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
const MIN_PASSWORD = 8;
/** API prefixes whose writes change the host account's own setup. */
const ADMIN_WRITE_PREFIXES = [
"/settings",
"/updates",
"/cc-config",
"/codex-config",
"/ai-proxies",
"/ai-routers",
"/git-credentials",
"/ai-tools",
"/import",
"/pricing",
"/budgets",
"/webhooks",
"/remote-sources",
"/reports",
"/pets",
];
/**
* API prefixes a member may not reach at all, reads included: the host's own
* configuration and logs, and every view across all users (export, metrics,
* reports, webhook deliveries, remote machines, imports). Admin and analyst
* pass. `MEMBER_READ_ALLOW` re-opens the few reads a member's own work needs.
*/
const STAFF_ONLY_PREFIXES = [
"/cc-config",
"/codex-config",
"/git-credentials",
"/remote-sources",
"/webhooks",
"/reports",
"/metrics",
"/import",
"/settings/export",
"/alerts/rules",
"/users",
"/accounts",
];
// The run form's `/` autocomplete lists commands and skills.
const MEMBER_READ_ALLOW = ["/cc-config/commands", "/cc-config/skills"];
const isServerMode = () => process.env.AGENTHUB_MODE === "server";
const sha256 = (s) => crypto.createHash("sha256").update(s).digest("hex");
const err = (code, message) => Object.assign(new Error(message), { code });
async function hashPassword(password) {
const salt = crypto.randomBytes(16);
const hash = await scrypt(password, salt, 64);
return `scrypt$${salt.toString("base64")}$${hash.toString("base64")}`;
}
async function verifyPassword(password, stored) {
const [kind, salt, hash] = String(stored || "").split("$");
if (kind !== "scrypt" || !salt || !hash) return false;
const want = Buffer.from(hash, "base64");
const got = await scrypt(String(password), Buffer.from(salt, "base64"), want.length);
return crypto.timingSafeEqual(got, want);
}
function validateUsername(name) {
const n = String(name || "").trim();
if (!USERNAME_RE.test(n)) {
throw err("EBADUSER", "username: 1–64 letters, digits, '.', '_' or '-'");
}
return n;
}
function validatePassword(password) {
if (typeof password !== "string" || password.length < MIN_PASSWORD) {
throw err("EBADPASSWORD", `password: at least ${MIN_PASSWORD} characters`);
}
if (password.length > 1024) throw err("EBADPASSWORD", "password too long");
}
// ── Users (the CLI's `agenthub users …` and `agenthub server setup`) ────────
const publicUser = (u) =>
u && { id: u.id, username: u.username, role: u.role, disabled: Boolean(u.disabled) };
function listUsers() {
return sql("SELECT * FROM users ORDER BY id").all().map(publicUser);
}
function findUser(username) {
return sql("SELECT * FROM users WHERE username = ?").get(String(username || ""));
}
async function createUser(username, password, role = "member") {
const name = validateUsername(username);
validatePassword(password);
if (!ROLES.includes(role)) throw err("EBADROLE", `role: one of ${ROLES.join(", ")}`);
if (findUser(name)) throw err("EEXISTS", `user ${name} already exists`);
const hash = await hashPassword(password);
const { lastInsertRowid } = sql(
"INSERT INTO users (username, role, password_hash) VALUES (?, ?, ?)"
).run(name, role, hash);
return publicUser(sql("SELECT * FROM users WHERE id = ?").get(lastInsertRowid));
}
function requireUser(username) {
const u = findUser(username);
if (!u) throw err("ENOUSER", `no user ${username}`);
return u;
}
/** New password; every login of that user ends. */
async function setPassword(username, password) {
const u = requireUser(username);
validatePassword(password);
sql("UPDATE users SET password_hash = ? WHERE id = ?").run(await hashPassword(password), u.id);
sql("DELETE FROM auth_sessions WHERE user_id = ?").run(u.id);
}
function setRole(username, role) {
if (!ROLES.includes(role)) throw err("EBADROLE", `role: one of ${ROLES.join(", ")}`);
sql("UPDATE users SET role = ? WHERE id = ?").run(role, requireUser(username).id);
}
/** Disable (ending every login) or re-enable a user. Users are never deleted:
* S2 will hang owned rows off their id. */
function setDisabled(username, disabled) {
const u = requireUser(username);
sql("UPDATE users SET disabled = ? WHERE id = ?").run(disabled ? 1 : 0, u.id);
if (disabled) sql("DELETE FROM auth_sessions WHERE user_id = ?").run(u.id);
}
/** Give a workspace (a lane id) to a user — for the ones made before server
* mode, which have no owner and so reach no member. */
function assignWorkspace(laneId, username) {
const u = requireUser(username);
const r = sql("UPDATE lanes SET owner_id = ? WHERE id = ?").run(u.id, Number(laneId));
if (r.changes === 0) throw err("ENOLANE", `no workspace ${laneId}`);
}
const hasAdmin = () =>
Boolean(sql("SELECT 1 FROM users WHERE role = 'admin' AND disabled = 0").get());
// ── Login sessions ──────────────────────────────────────────────────────────
/** Start a login: the cookie token, returned once, never stored. */
function startSession(userId) {
const token = crypto.randomBytes(32).toString("base64url");
const expires = new Date(Date.now() + SESSION_DAYS * 864e5).toISOString();
sql("DELETE FROM auth_sessions WHERE expires_at < ?").run(new Date().toISOString());
sql("INSERT INTO auth_sessions (token_hash, user_id, expires_at) VALUES (?, ?, ?)").run(
sha256(token),
userId,
expires
);
return token;
}
function endSession(token) {
if (token) sql("DELETE FROM auth_sessions WHERE token_hash = ?").run(sha256(token));
}
function cookieToken(req) {
for (const part of String(req.headers.cookie || "").split(";")) {
const eq = part.indexOf("=");
if (eq > 0 && part.slice(0, eq).trim() === COOKIE) return part.slice(eq + 1).trim() || null;
}
return null;
}
/** The logged-in user behind this request's cookie, or null. */
function userFromRequest(req) {
const token = cookieToken(req);
if (!token || token.length > 128) return null;
const row = sql(
`SELECT u.* FROM auth_sessions s JOIN users u ON u.id = s.user_id
WHERE s.token_hash = ? AND s.expires_at > ? AND u.disabled = 0`
).get(sha256(token), new Date().toISOString());
return publicUser(row) || null;
}
function cookieHeader(req, token, maxAgeSec) {
const secure = req.secure || req.headers["x-forwarded-proto"] === "https";
return [
`${COOKIE}=${token}`,
"Path=/",
"HttpOnly",
"SameSite=Strict",
`Max-Age=${maxAgeSec}`,
...(secure ? ["Secure"] : []),
].join("; ");
}
const setLoginCookie = (req, res, token) =>
res.setHeader("Set-Cookie", cookieHeader(req, token, SESSION_DAYS * 86400));
const clearLoginCookie = (req, res) => res.setHeader("Set-Cookie", cookieHeader(req, "", 0));
// ── Failed-login throttle ───────────────────────────────────────────────────
// ponytail: in-memory per-IP counter, reset on restart and not shared between
// two dashboards; move it into SQLite if the server ever runs as several processes.
const FAIL_WINDOW_MS = 15 * 60 * 1000;
const FAIL_LIMIT = 10;
const failures = new Map();
/** The client's address: the socket's, or — only behind a proxy on this
* machine — the LAST X-Forwarded-For entry, the one that proxy appended (the
* earlier ones are whatever the client sent). */
function clientKey(req) {
const addr = req.socket?.remoteAddress || "?";
if (!["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(addr)) return addr;
const fwd = String(req.headers["x-forwarded-for"] || "").split(",");
return fwd[fwd.length - 1].trim() || addr;
}
// Per username too, higher: many addresses guessing one account's password.
const USER_FAIL_LIMIT = 30;
const fresh = (f) => f && Date.now() - f.since <= FAIL_WINDOW_MS;
function blocked(key, limit) {
const f = failures.get(key);
return fresh(f) && f.count >= limit;
}
/** Count an attempt as failed BEFORE it is checked, so parallel guesses all count. */
function noteAttempt(key) {
if (failures.size > 10000) {
for (const [k, v] of failures) if (!fresh(v)) failures.delete(k);
}
const f = failures.get(key);
if (!fresh(f)) failures.set(key, { since: Date.now(), count: 1 });
else f.count++;
}
// A well-formed hash no password matches (64 zero bytes), for unknown users.
const DUMMY_HASH = `scrypt$${"A".repeat(22)}==$${"A".repeat(86)}==`;
/** `{user}` for a right username + password, else throws EBADLOGIN/ETOOMANY. */
async function login(req, username, password) {
const name = String(username || "").trim();
const ipKey = clientKey(req);
const userKey = `user:${name.toLowerCase()}`;
if (blocked(ipKey, FAIL_LIMIT) || blocked(userKey, USER_FAIL_LIMIT)) {
throw err("ETOOMANY", "too many failed logins, try again later");
}
noteAttempt(ipKey);
noteAttempt(userKey);
const u = findUser(name);
// Hash even for an unknown user, so the answer time does not tell which.
const ok = await verifyPassword(String(password || ""), u?.password_hash || DUMMY_HASH);
if (!u || !ok || u.disabled) throw err("EBADLOGIN", "wrong username or password");
failures.delete(ipKey);
failures.delete(userKey);
return { user: publicUser(u), token: startSession(u.id) };
}
// ── Pane credentials ────────────────────────────────────────────────────────
const PANE_HEADER = "x-agenthub-pane-token";
/**
* Server mode: a fresh credential for a pane the dashboard starts — the token
* goes into the pane's env (`AGENTHUB_PANE_TOKEN`, through tmux `-e`), its hash
* onto the run row. Null in local mode, where nothing needs one.
*/
function newPaneToken() {
if (!isServerMode()) return null;
const token = `ahpane_${crypto.randomBytes(24).toString("base64url")}`;
return { token, hash: sha256(token) };
}
/**
* The workspace owner a request from inside a live pane acts as: its
* `x-agenthub-pane-token` names a run that is still running in tmux, on a
* workspace someone owns, and the request came straight from this machine.
*/
function userFromPaneToken(req) {
const t = req.headers[PANE_HEADER];
if (typeof t !== "string" || !t.startsWith("ahpane_") || t.length > 200) return null;
if (!isLocalRequest(req)) return null;
const row = sql(
`SELECT r.id, l.owner_id FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id
WHERE r.pane_token_hash = ? AND r.status IN ('running', 'spawning') AND instr(r.id, '@') = 0`
).get(sha256(t));
if (!row || row.owner_id == null) return null;
if (!require("./tmux").hasSession(row.id)) return null;
return (
publicUser(sql("SELECT * FROM users WHERE id = ? AND disabled = 0").get(row.owner_id)) || null
);
}
// ── Gates ───────────────────────────────────────────────────────────────────
/**
* The path under `/api` as Express ROUTES it, for every prefix and id check:
* each segment percent-decoded, repeated slashes collapsed. Compare prefixes
* case-insensitively (`underPrefix`) — Express mounts and routes match
* regardless of case, so `/api/Webhooks` reaches the webhooks router.
*/
function apiPath(req) {
return String(req.path || "")
.split("/")
.map((seg) => {
try {
return decodeURIComponent(seg);
} catch {
return seg;
}
})
.join("/")
.replace(/\/{2,}/g, "/");
}
const underPrefix = (p, x) => {
const lp = p.toLowerCase();
return lp === x || lp.startsWith(x + "/");
};
const deny = (res, status, code, message) => res.status(status).json({ error: { code, message } });
/** Express middleware for `/api`; a no-op in local mode. */
function authGuard(req, res, next) {
if (!isServerMode()) return next();
const p = apiPath(req);
const lp = p.toLowerCase();
if (lp === "/health" || lp.startsWith("/auth/")) return next();
if (lp === "/hooks/ingest-batch") return next(); // its own bearer token
if (underPrefix(p, "/hooks")) {
return isLocalRequest(req)
? next()
: deny(res, 403, "ELOCALONLY", "hooks are accepted from this machine only");
}
const user = userFromRequest(req) || userFromPaneToken(req);
if (!user) return deny(res, 401, "EAUTH", "log in first");
req.user = user;
const under = (x) => underPrefix(p, x);
const isRead = req.method === "GET" || req.method === "HEAD";
if (
user.role === "member" &&
STAFF_ONLY_PREFIXES.some(under) &&
!(isRead && MEMBER_READ_ALLOW.some(under))
) {
return deny(res, 403, "EADMIN", "only an admin or analyst may open this");
}
if (isRead) return next();
// Every write in server mode passes the same-origin check — the cookie is
// SameSite=Strict, which a sibling subdomain or another port on the same
// host still satisfies. A browser always sends Origin on a write; a request
// with neither Origin nor Referer is not a browser's.
const raw = req.headers.origin || req.headers.referer;
if (raw && !isLoopback(raw) && !isOwnHost(raw, req)) {
return deny(res, 403, "EBADORIGIN", "cross-origin requests are not allowed");
}
if (user.role === "analyst")
return deny(res, 403, "EREADONLY", "an analyst account is read-only");
if (user.role !== "admin" && ADMIN_WRITE_PREFIXES.some(under)) {
return deny(res, 403, "EADMIN", "only an admin may change this");
}
return next();
}
/**
* WebSocket upgrade check (both `/ws` and `/ws-pty/*`); true in local mode.
* A login, and — the terminal is behind this — an Origin, when sent, that is
* the request's own allowed Host.
*/
function isWebSocketLoggedIn(req) {
if (!isServerMode()) return true;
const origin = req.headers.origin;
if (origin) {
let host = "";
try {
host = new URL(origin).host.toLowerCase();
} catch {
return false;
}
if (host !== String(req.headers.host || "").toLowerCase() || !isHostAllowed(host)) return false;
}
const user = userFromRequest(req);
if (
user &&
user.role === "analyst" &&
/^\/ws-pty\//.test(new URL(req.url, "http://x").pathname)
) {
return false; // a terminal is a write
}
return Boolean(user);
}
module.exports = {
COOKIE,
ROLES,
ADMIN_WRITE_PREFIXES,
STAFF_ONLY_PREFIXES,
apiPath,
newPaneToken,
userFromPaneToken,
PANE_HEADER,
isServerMode,
hashPassword,
verifyPassword,
listUsers,
findUser,
createUser,
setPassword,
setRole,
setDisabled,
assignWorkspace,
hasAdmin,
userFromRequest,
login,
endSession,
cookieToken,
setLoginCookie,
clearLoginCookie,
authGuard,
isWebSocketLoggedIn,
publicUser,
};
+13 -6
View File
@@ -6,11 +6,13 @@
* spend in its current period — priced exactly like GET /api/pricing/cost, by
* the day the tokens were spent — the pace-based projection to the period's
* end, and the status list behind GET /api/budgets/status. Firing lives in
* `alerts.js`, once per threshold per period.
* `alerts.js`, once per threshold per period. Budgets are org-wide; a server
* mode member's status shows each one's spend over their own sessions only.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { stmts } = require("../db");
const { memberId, sessionWhere } = require("./scope");
const { calculateCost, dailyTokenRows, costWhere } = require("../routes/pricing");
const PERIODS = ["day", "week", "month"];
@@ -58,16 +60,21 @@ function periodBounds(period, now, tzOffset = 0, back = 0) {
}
/**
* Spend of a budget config in one period (current by default).
* Spend of a budget config in one period (current by default); with a member's
* `req`, over that member's sessions only.
* @returns {{period_start: string, period_end: string, spend: number,
* projected: number, pct: number}}
*/
function budgetSpend(cfg, { now = Date.now(), back = 0, rules } = {}) {
function budgetSpend(cfg, { now = Date.now(), back = 0, rules, req } = {}) {
const tz = Number.isFinite(cfg.tz_offset) ? cfg.tz_offset : 0;
const b = periodBounds(cfg.period, now, tz, back);
// A session that spent in the period was touched in it, so older ones are
// skipped before their hours are read.
const extra = [{ clause: "s.updated_at >= ?", params: [b.startUtc] }];
if (memberId(req) != null) {
const own = sessionWhere(req, "s.id", "s.lane_id");
extra.push({ clause: own.where, params: own.params });
}
const scope = cfg.tool ? { where: "tool = ?", params: [cfg.tool] } : null;
const where = costWhere({ scope, lane: cfg.lane, extra });
const rows = dailyTokenRows(`${-tz} minutes`, where.whereClause, where.params).filter(
@@ -93,10 +100,10 @@ function crossed(cfg, pct) {
}
/**
* Every enabled budget rule with its current-period spend.
* Every enabled budget rule with its current-period spend (`req`: see budgetSpend).
* @returns {Array<object>} rule id/name/config + `budgetSpend` + `crossed`.
*/
function budgetStatus({ now = Date.now() } = {}) {
function budgetStatus({ now = Date.now(), req } = {}) {
const rules = stmts.listPricing.all();
return stmts.listEnabledAlertRules
.all()
@@ -109,7 +116,7 @@ function budgetStatus({ now = Date.now() } = {}) {
return null;
}
if (!PERIODS.includes(cfg.period) || !(cfg.limit_usd > 0)) return null;
const s = budgetSpend(cfg, { now, rules });
const s = budgetSpend(cfg, { now, rules, req });
return { id: r.id, name: r.name, config: cfg, ...s, crossed: crossed(cfg, s.pct) };
})
.filter(Boolean);
+1
View File
@@ -243,6 +243,7 @@ module.exports = {
profileUsage,
fetchUsage,
fetchOwner,
liveOwner,
refreshProfile,
CLIENT_ID,
USAGE_URL,
+262
View File
@@ -0,0 +1,262 @@
/**
* @file claude-pool.js
* @description The Claude account POOL: subscription accounts an admin added
* (`agenthub accounts add`, a `claude setup-token` token each), and the
* balancer that gives every new Claude pane one of them through
* `CLAUDE_CODE_OAUTH_TOKEN` (pty-run.js) — so sessions spread over the
* accounts without touching the machine's own login. An empty pool changes
* nothing: panes run on the machine's login as before.
*
* Tokens live in `<CLAUDE_HOME>/agenthub-accounts/<name>.json` (dir 0700, file
* 0600); no route returns one. A setup-token is valid for one year from when it
* was added; past that the account is skipped.
*
* `pickAccount` ranks the accounts by what AgentHub itself recorded:
* 1. an account whose last quota reading is ≥ `BLOCK_PERCENT` in either
* window goes last (still used when every account is that full);
* 2. fewest live panes (spreads concurrent sessions);
* 3. least $ spent in the last 5 hours, then in the last 7 days — summed from
* `token_usage_hourly` for the sessions its runs (`dashboard_runs.account`)
* started.
* The quota is read with the account's own token in the background, at most
* every `QUOTA_TTL_MS`; a token the usage endpoint refuses simply has no
* reading, and ranking falls back to spend.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
const { getClaudeHome } = require("./claude-home");
const NAME_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
const TOKEN_RE = /^sk-ant-[A-Za-z0-9_-]{20,500}$/;
const TOKEN_LIFE_MS = 365 * 864e5;
const QUOTA_TTL_MS = 5 * 60 * 1000;
const QUOTA_FRESH_MS = 30 * 60 * 1000;
const BLOCK_PERCENT = 90;
const err = (code, message) => Object.assign(new Error(message), { code });
const poolDir = () => path.join(getClaudeHome(), "agenthub-accounts");
const fileOf = (name) => path.join(poolDir(), `${name}.json`);
function validName(name) {
const n = String(name || "").trim();
if (!NAME_RE.test(n))
throw err("EBADNAME", "account name: 1–64 letters, digits, '.', '_' or '-'");
return n;
}
function readAccount(name) {
try {
const a = JSON.parse(fs.readFileSync(fileOf(name), "utf8"));
return typeof a.token === "string" ? a : null;
} catch {
return null;
}
}
function allAccounts() {
let names = [];
try {
names = fs.readdirSync(poolDir()).filter((f) => f.endsWith(".json"));
} catch {
return [];
}
return names
.map((f) => f.slice(0, -5))
.filter((n) => NAME_RE.test(n))
.map(readAccount)
.filter(Boolean)
.sort((a, b) => a.name.localeCompare(b.name));
}
const expiresAt = (a) => new Date(Date.parse(a.addedAt) + TOKEN_LIFE_MS).toISOString();
const expired = (a) => Date.parse(expiresAt(a)) <= Date.now();
/** Add an account; refuses a name already in the pool. */
function addAccount(name, token) {
const n = validName(name);
const t = String(token || "").trim();
if (!TOKEN_RE.test(t)) {
throw err(
"EBADTOKEN",
"not a Claude token — paste what `claude setup-token` printed (sk-ant-…)"
);
}
if (readAccount(n)) throw err("EEXISTS", `account ${n} is already in the pool`);
fs.mkdirSync(poolDir(), { recursive: true, mode: 0o700 });
fs.chmodSync(poolDir(), 0o700);
const record = { name: n, token: t, addedAt: new Date().toISOString() };
fs.writeFileSync(fileOf(n), JSON.stringify(record, null, 2), { mode: 0o600, flag: "wx" });
quotaCache.delete(n);
return { name: n, addedAt: record.addedAt, expiresAt: expiresAt(record) };
}
function removeAccount(name) {
const n = validName(name);
if (!readAccount(n)) throw err("ENOACCOUNT", `no account ${n} in the pool`);
fs.rmSync(fileOf(n));
quotaCache.delete(n);
}
// ── Quota, read in the background ──────────────────────────────────────────
/** name → { at, fiveHour?, sevenDay?, error? } */
const quotaCache = new Map();
const inFlight = new Set();
/** Read every account's quota whose reading is older than QUOTA_TTL_MS. */
async function refreshQuotas({ force = false } = {}) {
const { fetchUsage } = require("./cc-usage");
await Promise.all(
allAccounts().map(async (a) => {
const cached = quotaCache.get(a.name);
if (inFlight.has(a.name) || (!force && cached && Date.now() - cached.at < QUOTA_TTL_MS)) {
return;
}
inFlight.add(a.name);
try {
const u = await fetchUsage(a.token);
quotaCache.set(a.name, { at: Date.now(), ...u });
} catch (e) {
// Keep the last good windows: a hiccup must not unblock a full account.
quotaCache.set(a.name, { ...cached, at: Date.now(), error: e.code || "EUPSTREAM" });
} finally {
inFlight.delete(a.name);
}
})
);
}
/** The highest known utilization (%) of a fresh reading, else null. */
function quotaPercent(name) {
const q = quotaCache.get(name);
if (!q || Date.now() - q.at > QUOTA_FRESH_MS) return null;
const now = Date.now();
// A window whose reset time has passed is empty again.
const used = [q.fiveHour, q.sevenDay]
.filter((w) => w && w.utilization != null)
.map((w) => (w.resetsAt && Date.parse(w.resetsAt) <= now ? 0 : w.utilization));
return used.length ? Math.max(...used) : null;
}
// ── What AgentHub recorded per account ─────────────────────────────────────
const USAGE_COLS = [
"input_tokens",
"output_tokens",
"cache_read_tokens",
"cache_write_tokens",
"cache_write_1h_tokens",
"web_search_requests",
"web_fetch_requests",
"code_execution_requests",
];
/**
* $ spent on `account` since `sinceHour` (`YYYY-MM-DDTHH`, UTC): the hourly
* usage of every session one of its runs started, from that run's start hour.
* ponytail: a session later resumed on ANOTHER account keeps counting here
* too; split by run end time if that ever skews a ranking.
*/
function spendSince(account, sinceHour) {
const { db, stmts } = require("../db");
const { calculateCost } = require("../routes/pricing");
const rows = db
.prepare(
`WITH s AS (
SELECT session_id, MIN(substr(started_at, 1, 13)) AS since
FROM dashboard_runs
WHERE account = ? AND session_id IS NOT NULL
GROUP BY session_id)
SELECT h.model, h.speed, h.inference_geo, h.service_tier,
${USAGE_COLS.map((c) => `SUM(h.${c}) AS ${c}`).join(", ")}
FROM s JOIN token_usage_hourly h
ON h.session_id = s.session_id AND h.hour >= s.since AND h.hour >= ?
GROUP BY h.model, h.speed, h.inference_geo, h.service_tier`
)
.all(account, sinceHour);
return rows.length ? calculateCost(rows, stmts.listPricing.all()).total_cost : 0;
}
function livePanes(account) {
const { db } = require("../db");
return db
.prepare(
"SELECT COUNT(*) AS n FROM dashboard_runs WHERE account = ? AND status IN ('running', 'spawning')"
)
.get(account).n;
}
const hourAgo = (hours) => new Date(Date.now() - hours * 3600e3).toISOString().slice(0, 13);
/** Every account with what the balancer sees — never a token. */
function poolStatus() {
return allAccounts().map((a) => {
const q = quotaCache.get(a.name);
const percent = quotaPercent(a.name);
return {
name: a.name,
addedAt: a.addedAt,
expiresAt: expiresAt(a),
expired: expired(a),
livePanes: livePanes(a.name),
spend5h: spendSince(a.name, hourAgo(5)),
spend7d: spendSince(a.name, hourAgo(24 * 7)),
quota: q
? {
fiveHour: q.fiveHour || null,
sevenDay: q.sevenDay || null,
checkedAt: new Date(q.at).toISOString(),
error: q.error || null,
}
: null,
quotaPercent: percent,
full: percent != null && percent >= BLOCK_PERCENT,
};
});
}
/**
* The account a new Claude pane should run on — `{name, token}` — or null when
* the pool has no usable account. Synchronous (a spawn cannot wait on the
* network): it ranks on the cached quota and starts a background re-read.
*/
function pickAccount() {
const usable = allAccounts().filter((a) => !expired(a));
if (usable.length === 0) return null;
refreshQuotas().catch(() => {});
const ranked = usable
.map((a) => {
const percent = quotaPercent(a.name);
return {
a,
key: [
percent != null && percent >= BLOCK_PERCENT ? 1 : 0,
livePanes(a.name),
spendSince(a.name, hourAgo(5)),
spendSince(a.name, hourAgo(24 * 7)),
],
};
})
.sort((x, y) => {
for (let i = 0; i < x.key.length; i++) {
if (x.key[i] !== y.key[i]) return x.key[i] - y.key[i];
}
return x.a.name.localeCompare(y.a.name);
});
const { name, token } = ranked[0].a;
return { name, token };
}
module.exports = {
BLOCK_PERCENT,
addAccount,
removeAccount,
poolStatus,
pickAccount,
refreshQuotas,
poolDir,
__quotaCache: quotaCache,
};
+5 -3
View File
@@ -32,11 +32,11 @@ const insertStmt = db.prepare(`
INSERT OR REPLACE INTO dashboard_runs (
id, session_id, mode, cwd, model, permission_mode, effort,
resume_session_id, prompt_preview, status, exit_code, started_at, ended_at,
lane_id, tool, worktree, proxy
lane_id, tool, worktree, proxy, account, pane_token_hash
) VALUES (
@id, @session_id, @mode, @cwd, @model, @permission_mode, @effort,
@resume_session_id, @prompt_preview, @status, @exit_code, @started_at, @ended_at,
@lane_id, @tool, @worktree, @proxy
@lane_id, @tool, @worktree, @proxy, @account, @pane_token_hash
)
`);
@@ -51,7 +51,7 @@ const updateStmt = db.prepare(`
const RUN_COLUMNS = `id, session_id, mode, cwd, model, permission_mode, effort,
resume_session_id, prompt_preview, status, exit_code,
started_at, ended_at, lane_id, tool, worktree, proxy`;
started_at, ended_at, lane_id, tool, worktree, proxy, account`;
const listStmt = db.prepare(`
SELECT ${RUN_COLUMNS}
@@ -124,6 +124,8 @@ function recordRun(handle) {
tool: typeof handle.tool === "string" && handle.tool ? handle.tool : "claude",
worktree: handle.worktree ? 1 : 0,
proxy: handle.proxy || null,
account: handle.account || null,
pane_token_hash: handle.paneTokenHash || null,
});
} catch (err) {
// Best-effort by design (a live run must never fail because sqlite did),
+12 -2
View File
@@ -58,7 +58,10 @@ function buildExportBundle(db, stmts) {
events: db.prepare("SELECT * FROM events ORDER BY created_at DESC").all(),
token_usage: db.prepare("SELECT * FROM token_usage").all(),
workflows: db.prepare("SELECT * FROM workflows").all(),
dashboard_runs: db.prepare("SELECT * FROM dashboard_runs ORDER BY started_at DESC").all(),
dashboard_runs: db
.prepare("SELECT * FROM dashboard_runs ORDER BY started_at DESC")
.all()
.map(exportableRun),
alert_rules: db.prepare("SELECT * FROM alert_rules ORDER BY created_at ASC").all(),
model_pricing: stmts.listPricing.all(),
};
@@ -89,6 +92,12 @@ function bindable(v) {
* whose value goes through a SQL function (events.data through
* `trim_event_data`, as every event insert must).
*/
/** A run row as it may leave this machine: never a pane credential's hash. */
function exportableRun(row) {
const { pane_token_hash: _secret, ...rest } = row;
return rest;
}
function makeInserter(db, table, { omit = [], wrap = {} } = {}) {
const cols = tableColumns(db, table).filter((c) => !omit.includes(c));
const quoted = cols.map((c) => `"${c}"`).join(", ");
@@ -180,7 +189,7 @@ function importExportBundle(db, bundle) {
events: makeInserter(db, "events", { omit: ["id"], wrap: { data: "trim_event_data" } }),
token_usage: makeInserter(db, "token_usage"),
workflows: makeInserter(db, "workflows"),
dashboard_runs: makeInserter(db, "dashboard_runs"),
dashboard_runs: makeInserter(db, "dashboard_runs", { omit: ["pane_token_hash"] }),
alert_rules: makeInserter(db, "alert_rules"),
model_pricing: makeInserter(db, "model_pricing"),
};
@@ -251,6 +260,7 @@ function importExportBundle(db, bundle) {
}
module.exports = {
exportableRun,
EXPORT_FORMAT,
EXPORT_VERSION,
SESSION_CHILD_TABLES,
+4 -2
View File
@@ -211,6 +211,7 @@ function createLane({
slug = null,
tool = "claude",
router_profile = null,
owner_id = null,
} = {}) {
if (!cwd || typeof cwd !== "string" || !path.isAbsolute(cwd)) {
throw Object.assign(new Error("cwd must be an absolute path"), { code: "EBADCWD" });
@@ -220,7 +221,7 @@ function createLane({
validateTool(tool);
const info = db
.prepare(
"INSERT INTO lanes (title, cwd, branch, pipeline, kind, source_repo, base_branch, slug, tool, router_profile, stage_since) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
"INSERT INTO lanes (title, cwd, branch, pipeline, kind, source_repo, base_branch, slug, tool, router_profile, stage_since, owner_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
)
.run(
normalizeTitle(title),
@@ -233,7 +234,8 @@ function createLane({
slug,
tool,
router_profile || null,
nowIso()
nowIso(),
Number.isInteger(owner_id) ? owner_id : null
);
return getLane(info.lastInsertRowid);
}
+35 -2
View File
@@ -37,12 +37,38 @@ function effectiveMaxHoldSec() {
return Math.max(configured, LOCK_MAX_HOLD_FLOOR_SEC);
}
// One directory name under the locks root: anything but a path — no '/', '\\'
// or NUL, and not '.' or '..' (names such as `db:migrate` keep working).
const NAME_RE = /^(?!\.{1,2}$)[^/\\\0]{1,255}$/;
// One word, as the owner file stores `<holder> <epochSeconds>` and reads it
// back with `\S+`. A holder with spaces (`--holder "ci bot"`) is kept usable by
// turning each run of whitespace into `_` — the old build "acquired" such a
// lock but could never read or release it, so it locked nothing.
const HOLDER_RE = /^\S{1,200}$/;
const normHolder = (holder) =>
typeof holder === "string" ? holder.trim().replace(/\s+/g, "_") : holder;
function checkName(name) {
if (typeof name !== "string" || !NAME_RE.test(name)) {
throw Object.assign(new Error("lock name: one name, not a path (no '/', '\\', '.' or '..')"), {
code: "EBADNAME",
});
}
}
function checkHolder(holder) {
if (typeof holder !== "string" || !HOLDER_RE.test(holder)) {
throw Object.assign(new Error("holder: one word"), { code: "EBADHOLDER" });
}
}
function ownerPath(name) {
return path.join(locksRoot(), name, "owner");
}
/** Parse an owner file's `"<holder> <epochSeconds>"` contents. */
function readOwner(name) {
checkName(name);
let text;
try {
text = fs.readFileSync(ownerPath(name), "utf8");
@@ -68,7 +94,10 @@ function readOwner(name) {
* @param {string} holder - Identity to record as the owner (e.g. `"lane3"`).
* @returns {{acquired: true} | {acquired: false, holder: string, since: number, ageSec: number}}
*/
function tryAcquire(name, holder) {
function tryAcquire(name, rawHolder) {
checkName(name);
const holder = normHolder(rawHolder);
checkHolder(holder);
const dir = path.join(locksRoot(), name);
fs.mkdirSync(locksRoot(), { recursive: true });
@@ -99,7 +128,10 @@ function tryAcquire(name, holder) {
* @param {string} name - Lock name.
* @param {string} holder - Must match the current owner.
*/
function release(name, holder) {
function release(name, rawHolder) {
checkName(name);
const holder = normHolder(rawHolder);
checkHolder(holder);
const owner = readOwner(name);
if (!owner) {
throw Object.assign(new Error(`no such lock: ${name}`), { code: "ENOLOCK" });
@@ -128,6 +160,7 @@ function listLocks() {
return [];
}
return entries
.filter((name) => NAME_RE.test(name))
.map((name) => ({ name, ...status(name) }))
.filter((lock) => lock.held)
.map(({ held, ...rest }) => rest);
+23 -4
View File
@@ -363,6 +363,12 @@ function spawnRun(args) {
throw makeErr("EUNSUPPORTED", `${tool} cannot start a session in its own worktree`);
}
const proxyProfile = resolveProxy(tool, proxy); // ENOTFOUND, EUNSUPPORTED
// A Claude session on the vendor's own API runs on the least-loaded pool
// account, if the admin added any (claude-pool.js); an AI Proxy brings its
// own key, and an empty pool leaves the machine's login in charge.
const poolAccount =
entry.launcher === "claude" && !proxyProfile ? require("./claude-pool").pickAccount() : null;
const paneCred = require("./auth").newPaneToken();
if (forkSession === true) {
if (!resumeSessionId) throw makeErr("EBADSESSION", "a fork needs resumeSessionId");
if (entry.launcher !== "claude") {
@@ -450,6 +456,8 @@ function spawnRun(args) {
resumeSessionId: resumeSessionId || null,
worktree: worktree === true || inWorktree === true,
proxy: proxyProfile ? proxyProfile.name : null,
account: poolAccount ? poolAccount.name : null,
paneTokenHash: paneCred ? paneCred.hash : null,
prompt: initialPrompt || "",
status: "running",
startedAt,
@@ -491,6 +499,12 @@ function spawnRun(args) {
// pushes lines into history its redraw cannot clear). The user's own
// `"tui"` setting decides; under fullscreen the pane scrolls in Claude.
...proxySpawn.env,
// The pool account's setup-token: outranks the machine's `/login` for
// this process only. Through tmux `-e`, never typed into the pane.
...(poolAccount ? { CLAUDE_CODE_OAUTH_TOKEN: poolAccount.token } : {}),
// Server mode: the CLI, MCP server and statusline in this pane act as the
// workspace's owner with it (server/lib/auth.js userFromPaneToken).
...(paneCred ? { AGENTHUB_PANE_TOKEN: paneCred.token } : {}),
...(bypass === true && entry.bypassEnv ? entry.bypassEnv : {}),
};
@@ -557,11 +571,13 @@ function spawnShell({ laneId, slot = 0, cwd }) {
}
if (!tmux.isTmuxAvailable()) throw makeErr("ETMUXMISSING", "tmux is not installed");
const id = runIdForLane(laneId, slot);
const paneCred = require("./auth").newPaneToken();
const env = {
AGENTHUB_LANE_ID: String(laneId),
AGENTHUB_RUN_ID: id,
...(process.env.PATH ? { PATH: process.env.PATH } : {}),
...desktopEnv(),
...(paneCred ? { AGENTHUB_PANE_TOKEN: paneCred.token } : {}),
};
// Started once the pane has its real size (WAIT_FOR_CLIENT, issue #55), as
// the login shell tmux would have run (tmux sets SHELL in the pane).
@@ -578,6 +594,7 @@ function spawnShell({ laneId, slot = 0, cwd }) {
resumeSessionId: null,
worktree: false,
proxy: null,
paneTokenHash: paneCred ? paneCred.hash : null,
prompt: "",
status: "running",
startedAt: Date.now(),
@@ -599,9 +616,8 @@ function killRun(id) {
return true;
}
function publicRun(id) {
function publicRun(id, live = tmux.hasSession(id)) {
const laneId = laneIdFromRunId(id);
const live = tmux.hasSession(id);
const row = dashboardRuns ? dashboardRuns.getRun(id) : null;
return {
id,
@@ -627,9 +643,12 @@ function getRun(id) {
return publicRun(id);
}
/** Computed fresh from tmux state every call — see file header. */
/**
* Computed fresh from tmux state every call — see file header. A listed
* session is live by definition, so no has-session per row.
*/
function listRuns() {
return tmux.listSessions("agenthub-lane-").map(publicRun);
return tmux.listSessions("agenthub-lane-").map((id) => publicRun(id, true));
}
module.exports = {
+8 -3
View File
@@ -87,10 +87,15 @@ function showNativeNotificationIfElectron(title, body) {
* service worker's `notificationclick` handler navigates to. Undefined for
* notifications with no specific target.
*/
async function sendPushToAll(db, title, body, url) {
const native = showNativeNotificationIfElectron(title, body);
async function sendPushToAll(db, title, body, url, userId = null) {
// Server mode (`userId` set): only that user's own browsers, and never the
// host's desktop — the text is about their sessions.
const native = userId == null ? showNativeNotificationIfElectron(title, body) : false;
const subscriptions = db.prepare("SELECT * FROM push_subscriptions").all();
const subscriptions =
userId == null
? db.prepare("SELECT * FROM push_subscriptions").all()
: db.prepare("SELECT * FROM push_subscriptions WHERE user_id = ?").all(userId);
if (subscriptions.length === 0) {
return { native, pushed: 0, failed: 0 };
}
+9 -2
View File
@@ -17,7 +17,14 @@ const JOBS = {
workflows: (conn, a) =>
require("./workflow-stats").computeWorkflowData(
conn,
{ status: a.status || null, tool: a.tool || null, range: a.range || null },
{
status: a.status || null,
tool: a.tool || null,
range: a.range || null,
// A server-mode member's user id: part of the args, so the in-flight
// share never hands one member another's numbers.
member: a.member ?? null,
},
a.sections || null
),
// Not a read: the worker runs it on its own writable connection (below), so
@@ -102,7 +109,7 @@ function dispatch(job, args) {
* Run a named read job off the event loop. Concurrent calls with the same job
* and args share one run; with `maxAgeMs`, a result that young is reused too,
* so N open dashboards cost one query per window instead of N. Args are part
* of the key, so a result is only reused for the very same query.
* of the key, so a member's scoped result is never handed to anyone else.
*
* @param {keyof JOBS} job
* @param {object} [args] structured-cloneable
+38 -21
View File
@@ -1,14 +1,18 @@
/**
* @file same-origin.js
* @description Loopback-Origin guard for the routes that act on the machine
* rather than on the dashboard's own database — spawning a process, or
* writing a file into a directory the request names.
* @description Origin guard for the routes that act on the machine rather than
* on the dashboard's own database — spawning a process, or writing a file into
* a directory the request names — plus `isLocalRequest`, the "a browser on this
* very machine, not through a proxy" test.
*
* The dashboard binds to localhost, so any page in the user's browser can
* reach it. Without this, a malicious site could POST to it and, say, drop a
* CLAUDE.md full of instructions into a project the user later opens.
*
* Browser requests carry `Origin`; it must be a loopback host. Requests with
* Browser requests carry `Origin`; it must be a loopback host, or — for a
* dashboard served under an operator-allowed name (server mode behind a
* reverse proxy, `DASHBOARD_ALLOWED_HOSTS`) — exactly the request's own Host,
* which `hostGuard` has already checked against that allowlist. Requests with
* no `Origin` (curl, the CLI) pass — they are not the drive-by case. `Referer`
* is the fallback for older browsers.
*
@@ -18,6 +22,8 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { isHostAllowed } = require("./security");
const ALLOWED_ORIGIN_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "0.0.0.0"]);
function isLoopback(raw) {
@@ -28,23 +34,34 @@ function isLoopback(raw) {
}
}
function sameOriginGuard(req, res, next) {
const origin = req.headers.origin;
if (origin) {
if (!isLoopback(origin)) {
return res.status(403).json({
error: { code: "EBADORIGIN", message: "cross-origin requests are not allowed" },
});
}
return next();
/** `raw`'s host:port is the request's own Host, and that Host is allowed. */
function isOwnHost(raw, req) {
const host = String(req.headers.host || "").toLowerCase();
try {
return Boolean(host) && new URL(raw).host.toLowerCase() === host && isHostAllowed(host);
} catch {
return false;
}
const referer = req.headers.referer;
if (referer && !isLoopback(referer)) {
return res.status(403).json({
error: { code: "EBADORIGIN", message: "cross-origin requests are not allowed" },
});
}
return next();
}
module.exports = { sameOriginGuard, ALLOWED_ORIGIN_HOSTS };
function sameOriginGuard(req, res, next) {
const raw = req.headers.origin || req.headers.referer;
if (!raw || isLoopback(raw) || isOwnHost(raw, req)) return next();
return res.status(403).json({
error: { code: "EBADORIGIN", message: "cross-origin requests are not allowed" },
});
}
/**
* The request came straight from this machine: a loopback socket and no
* forwarding header. A reverse proxy's requests arrive from loopback too, so a
* forwarding header counts as remote. An SSH tunnel carries none and cannot be
* told apart.
*/
function isLocalRequest(req) {
const addr = req.socket?.remoteAddress || "";
const proxied = req.headers["x-forwarded-for"] || req.headers.forwarded;
return !proxied && ["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(addr);
}
module.exports = { sameOriginGuard, isLocalRequest, ALLOWED_ORIGIN_HOSTS, isLoopback, isOwnHost };
+338
View File
@@ -0,0 +1,338 @@
/**
* @file scope.js
* @description Server mode's row ownership (phase S2, docs/SERVER-MODE.md) —
* the ONE place that decides which rows a request may see.
*
* A workspace (`lanes.owner_id`) belongs to the user who created it. A session
* belongs to its workspace: `sessions.lane_id`, or a `dashboard_runs` row of
* that workspace holding it (a session not stamped yet). Agents, events and
* token rows belong to their session. A member sees only what their own
* workspaces own; an admin and an analyst see everything, and so does every
* request in local mode (`req.user` is never set there).
*
* Every helper takes `req`, so no caller can pick whose rows it reads.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const sql = (q) => require("../db").db.prepare(q);
/** The member whose rows this request is limited to, or null for "all". */
function memberId(req) {
const u = req?.user;
return u && u.role === "member" ? u.id : null;
}
/** The owner a workspace created by this request gets (null outside server mode). */
const ownerFor = (req) => (req?.user ? req.user.id : null);
/**
* SQL predicate over a sessions alias: `{where, params}`, `where` = "1" when
* unrestricted. `col` is the session id expression, `laneCol` its lane id.
*/
function sessionWhere(req, col = "id", laneCol = "lane_id") {
const uid = memberId(req);
if (uid == null) return { where: "1", params: [] };
return {
where: `(${laneCol} IN (SELECT id FROM lanes WHERE owner_id = ?)
OR ${col} IN (SELECT r.session_id FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id
WHERE l.owner_id = ? AND r.session_id IS NOT NULL))`,
params: [uid, uid],
};
}
/** SQL predicate for any table carrying a session id column (events, agents, tokens). */
function bySessionWhere(req, col = "session_id") {
const uid = memberId(req);
if (uid == null) return { where: "1", params: [] };
const s = sessionWhere(req, "s.id", "s.lane_id");
return { where: `${col} IN (SELECT s.id FROM sessions s WHERE ${s.where})`, params: s.params };
}
/** SQL predicate over a lanes id column. */
function laneWhere(req, col = "id") {
const uid = memberId(req);
if (uid == null) return { where: "1", params: [] };
return { where: `${col} IN (SELECT id FROM lanes WHERE owner_id = ?)`, params: [uid] };
}
function canSeeLane(req, laneId) {
const uid = memberId(req);
if (uid == null) return true;
// Bound as given, like getLane binds a route param: SQLite's integer
// affinity reads '9002.0' or ' 9002' as 9002 for both, so the row checked
// here is the row the route would load.
const v = typeof laneId === "number" ? laneId : String(laneId);
const row = sql("SELECT owner_id FROM lanes WHERE id = ?").get(v);
return Boolean(row) && row.owner_id === uid;
}
function canSeeSession(req, sessionId) {
if (memberId(req) == null) return true;
const s = sessionWhere(req);
return Boolean(
sql(`SELECT 1 FROM sessions WHERE id = ? AND ${s.where}`).get(String(sessionId), ...s.params)
);
}
function canSeeAgent(req, agentId) {
if (memberId(req) == null) return true;
const row = sql("SELECT session_id FROM agents WHERE id = ?").get(String(agentId));
return Boolean(row) && canSeeSession(req, row.session_id);
}
/** A run id `agenthub-lane-<n>[-<slot>]` (or an archived `…@<time>`) → its lane. */
function canSeeRun(req, runId) {
if (memberId(req) == null) return true;
const m = /^agenthub-lane-(\d+)(?:-\d+)?(?:@.*)?$/.exec(String(runId || ""));
if (m) return canSeeLane(req, m[1]);
const row = sql("SELECT lane_id FROM dashboard_runs WHERE id = ?").get(String(runId));
return Boolean(row && row.lane_id != null) && canSeeLane(req, row.lane_id);
}
/** The ids of every workspace this request may see, or null for "all". */
function visibleLaneIds(req) {
const uid = memberId(req);
if (uid == null) return null;
return sql("SELECT id FROM lanes WHERE owner_id = ?")
.all(uid)
.map((r) => r.id);
}
/** A row of `table` (`id` = `rowId`) whose `session_id` this request may see. */
function canSeeRowSession(req, table, rowId, idCol = "id") {
if (memberId(req) == null) return true;
const row = sql(`SELECT session_id FROM ${table} WHERE ${idCol} = ?`).get(String(rowId));
return Boolean(row && row.session_id) && canSeeSession(req, row.session_id);
}
/** A `dashboard_runs` row (`/api/run/history/:id`) of one of this request's workspaces. */
function canSeeRunRow(req, id) {
if (memberId(req) == null) return true;
const row = sql("SELECT lane_id FROM dashboard_runs WHERE id = ?").get(String(id));
return Boolean(row && row.lane_id != null) && canSeeLane(req, row.lane_id);
}
/**
* Path rules for routes that name ONE row: `[pattern, check(req, match)]`,
* matched against the path under `/api`. Keep this in step with the routers —
* a new route that takes an id under one of these prefixes is covered by its
* prefix; one under a new prefix needs a rule here.
*/
// First segments under /lanes (and /workspaces) that are routes, not ids.
const LANE_WORDS = new Set([
"branches",
"browse",
"ensure",
"gc",
"pick-path",
"pipelines",
"pipeline-stats",
"worktree",
]);
const PATH_RULES = [
[/^\/sessions\/(?!facets\/?$)([^/]+)/i, (req, m) => canSeeSession(req, m[1])],
[/^\/agents\/([^/]+)/i, (req, m) => canSeeAgent(req, m[1])],
// A segment that is not one of the routes' own words is a lane id: it must
// be plain digits, which every reader (parseInt, Number, SQLite affinity)
// takes as the same lane — '1e1' is 10 to SQLite and 1 to parseInt.
[
/^\/(?:lanes|workspaces)\/([^/]+)/i,
(req, m) =>
LANE_WORDS.has(m[1].toLowerCase()) || (/^\d+$/.test(m[1]) && canSeeLane(req, Number(m[1]))),
],
[/^\/run\/history\/([^/]+)/i, (req, m) => canSeeRunRow(req, m[1])],
[/^\/run\/([^/]+)/i, (req, m) => !/^agenthub-lane-/i.test(m[1]) || canSeeRun(req, m[1])],
[/^\/pricing\/cost\/([^/]+)/i, (req, m) => canSeeSession(req, m[1])],
[/^\/workflows\/session\/([^/]+)/i, (req, m) => canSeeSession(req, m[1])],
[/^\/workflows\/runs\/([^/]+)/i, (req, m) => canSeeRowSession(req, "workflows", m[1], "run_id")],
[/^\/alerts\/([^/]+)\/ack/i, (req, m) => canSeeRowSession(req, "alert_events", m[1])],
[
/^\/snapshots\/([^/]+)$/i,
(req, m) => req.method !== "DELETE" || canSeeRowSession(req, "session_snapshots", m[1]),
],
];
const list = (v) =>
(Array.isArray(v) ? v : String(v).split(",")).map((x) => String(x).trim()).filter(Boolean);
/**
* Ids a request names in its query string or JSON body, by kind. A value
* that is not a plain string or number (an array from `?x[]=`, an object) is
* returned in `bad`: a member's request carrying one is refused outright.
*/
function namedIds(req) {
const q = req.query || {};
const b = req.body && typeof req.body === "object" && !Array.isArray(req.body) ? req.body : {};
const out = { lanes: [], sessions: [], bad: false };
const take = (into, v) => {
if (v === undefined || v === null || v === "") return;
if (typeof v === "string" || typeof v === "number") into.push(String(v));
else out.bad = true;
};
for (const v of [q.lane, q.laneId, q.lane_id, b.laneId, b.lane_id]) take(out.lanes, v);
for (const v of [
q.session_id,
q.sessionId,
q.session,
b.session_id,
b.sessionId,
b.resumeSessionId,
]) {
take(out.sessions, v);
}
if (q.session_ids !== undefined) {
if (typeof q.session_ids !== "string") out.bad = true;
else out.sessions.push(...list(q.session_ids));
}
return out;
}
/**
* Express middleware (on `/api`, after authGuard): a member naming a row that
* is not theirs — in the path, the query or the body — gets 404, the same
* answer as a row that does not exist. A no-op for admin, analyst and local.
*/
function scopeGuard(req, res, next) {
if (memberId(req) == null) return next();
const notFound = () =>
res.status(404).json({ error: { code: "ENOTFOUND", message: "not found" } });
const p = require("./auth").apiPath(req);
for (const [re, check] of PATH_RULES) {
const m = re.exec(p);
if (m && !check(req, m)) return notFound();
}
const { lanes, sessions, bad } = namedIds(req);
if (bad) return notFound();
if (lanes.some((id) => !/^\d+$/.test(id) || !canSeeLane(req, Number(id)))) return notFound();
if (sessions.some((id) => !canSeeSession(req, id))) return notFound();
return next();
}
// ── WebSocket broadcasts ───────────────────────────────────────────────────
const STAFF = { staffOnly: true };
/** Owner ids of the workspaces that own a session (stamped or held by a run). */
function sessionOwners(sessionId) {
if (!sessionId) return new Set();
const rows = sql(
`SELECT l.owner_id FROM sessions s JOIN lanes l ON l.id = s.lane_id WHERE s.id = ?
UNION
SELECT l.owner_id FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id WHERE r.session_id = ?`
).all(String(sessionId), String(sessionId));
return new Set(rows.map((r) => r.owner_id).filter((x) => x != null));
}
function laneOwners(laneId) {
const row =
laneId == null ? null : sql("SELECT owner_id FROM lanes WHERE id = ?").get(Number(laneId));
return new Set(row && row.owner_id != null ? [row.owner_id] : []);
}
/**
* Who a broadcast may reach: `null` = every client, `{owners: Set<userId>}` =
* admin/analyst plus those members, `{staffOnly: true}` = admin/analyst only.
* A message type not listed here reaches no member — add it when a member's
* page needs it.
*/
function broadcastAudience(type, data) {
const d = data && typeof data === "object" ? data : {};
switch (type) {
case "session_created":
case "session_updated":
return { owners: sessionOwners(d.id) };
case "agent_created":
case "agent_updated":
case "new_event":
case "workflow_upserted":
return { owners: sessionOwners(d.session_id) };
case "alert_triggered":
case "alert_updated":
if (d.acked_all) return null;
return d.session_id ? { owners: sessionOwners(d.session_id) } : STAFF;
case "lane_update":
// A removal names an id that no longer resolves; it reveals nothing.
if (d.removed != null && !d.lane) return null;
return { owners: laneOwners(d.lane?.id) };
case "lane_hook_output":
case "lane_hook_result":
case "lane_runtime":
return { owners: laneOwners(d.laneId) };
case "update_status":
return null;
// A member's prompt box refetches its command list on these, but the
// payload names the host's profiles and files: members get the type only.
case "cc_config_changed":
case "codex_config_changed":
return { strip: true };
default:
return STAFF;
}
}
/** Whether a client logged in as `user` (undefined in local mode) gets it. */
function audienceIncludes(audience, user) {
if (!user || user.role !== "member") return true;
if (audience === null || audience.strip) return true;
return Boolean(audience.owners && audience.owners.has(user.id));
}
/**
* The folder a member's workspace may use: anywhere outside server mode or for
* an admin; for a member, only inside `<AGENTHUB_USERS_ROOT>/<username>` when
* that root is set. `dir` must already be absolute and canonical. Until
* accounts are Linux users (S3) this keeps workspaces apart, not agents: a
* terminal can still reach any folder the host account can.
*/
function folderAllowed(req, dir) {
if (memberId(req) == null) return true;
const root = (process.env.AGENTHUB_USERS_ROOT || "").trim();
if (!root) return true;
const path = require("node:path");
const home = path.join(path.resolve(root), req.user.username);
const rel = path.relative(home, path.resolve(dir));
return rel === "" || (rel.split(path.sep)[0] !== ".." && !path.isAbsolute(rel));
}
/**
* Server mode: whether `dir` is, lies inside, or contains the folder of a
* workspace this member does not own (another user's, or the host's) — for any
* tool. A member's workspace may not overlap another's, or its git and file
* views would reach that user's files.
*/
function folderTakenByOther(req, dir) {
const uid = memberId(req);
if (uid == null) return false;
const path = require("node:path");
const mine = path.resolve(dir);
const related = (a, b) => {
const rel = path.relative(a, b);
return rel === "" || (rel.split(path.sep)[0] !== ".." && !path.isAbsolute(rel));
};
return sql("SELECT cwd FROM lanes WHERE owner_id IS NULL OR owner_id != ?")
.all(uid)
.some((r) => typeof r.cwd === "string" && (related(r.cwd, mine) || related(mine, r.cwd)));
}
module.exports = {
folderTakenByOther,
scopeGuard,
broadcastAudience,
audienceIncludes,
sessionOwners,
PATH_RULES,
canSeeRowSession,
canSeeRunRow,
folderAllowed,
memberId,
ownerFor,
sessionWhere,
bySessionWhere,
laneWhere,
canSeeLane,
canSeeSession,
canSeeAgent,
canSeeRun,
visibleLaneIds,
};
+13 -4
View File
@@ -12,7 +12,9 @@
* subquery so complex aggregate SQL (stats, analytics) needs only one extra AND.
*
* `?tool=<id>` (an AI Tools catalog id, matched against `sessions.tool`) narrows
* the same way; `parseScope` folds both into one predicate over `sessions`.
* the same way; `parseScope` folds both into one predicate over `sessions`,
* plus — in server mode, for a member — the member's own sessions
* (`sessionWhere`, server/lib/scope.js), so no request can widen past them.
*
* `?from=&to=` (local `YYYY-MM-DD`, inclusive, read with `tz_offset`) is a date
* range: `parseRange` turns it into the UTC instants a timestamp column is
@@ -21,6 +23,8 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { sessionWhere } = require("./scope");
/**
* Parse the `sources` query param into a de-duplicated list, or null for
* "all sources" (no filtering).
@@ -85,9 +89,9 @@ function parseTool(req) {
}
/**
* The data scope (sources and/or tool) as ONE predicate over `sessions` with
* unqualified columns, or null for "everything". Callers check `parseTool` for
* false first.
* The data scope (sources and/or tool, and a member's own sessions) as ONE
* predicate over `sessions` with unqualified columns, or null for "everything"
* — never null for a member. Callers check `parseTool` for false first.
* @param {import("express").Request} req
* @returns {{where:string, params:string[]}|null}
*/
@@ -104,6 +108,11 @@ function parseScope(req) {
where.push("tool = ?");
params.push(tool);
}
const own = sessionWhere(req);
if (own.where !== "1") {
where.push(own.where);
params.push(...own.params);
}
return where.length ? { where: where.join(" AND "), params } : null;
}
+16 -3
View File
@@ -23,14 +23,27 @@ function durationSec(s) {
// ═══════════════════════════════════════════════════
/**
* Build a SQL WHERE clause for the session filters `{status, tool, range}`
* (`range` = `parseRange`'s result: sessions started in it).
* Build a SQL WHERE clause for the session filters `{status, tool, range,
* member}` (`range` = `parseRange`'s result: sessions started in it; `member`
* = `scope.memberId(req)`, a server-mode member's user id, whose own sessions
* are the only ones read — null reads every session).
* Returns { clause, params } where clause is empty or " AND s.status = ? …".
* Use `alias` to match the sessions table alias used in your query (default "s").
*/
function statusClause(filter, alias = "s") {
const parts = [];
const params = [];
if (filter?.member != null) {
// A pure SQL builder: scope.js opens the db only for its canSee* checks,
// so this also runs in the read worker on its own connection.
const w = require("./scope").sessionWhere(
{ user: { id: filter.member, role: "member" } },
`${alias}.id`,
`${alias}.lane_id`
);
parts.push(w.where);
params.push(...w.params);
}
if (filter?.status && filter.status !== "all") {
parts.push(`${alias}.status = ?`);
params.push(filter.status);
@@ -710,7 +723,7 @@ const WORKFLOW_SECTIONS = [
* The GET /api/workflows bundle, or only the named sections of it.
*
* @param {object} conn SQLite connection (compat-sqlite Database)
* @param {{status?: string|null, tool?: string|null, range?: object|null}|null} statusFilter
* @param {{status?: string|null, tool?: string|null, range?: object|null, member?: number|null}|null} statusFilter
* the session filters (see `statusClause`); null = every session
* @param {string[]|null} sections subset of WORKFLOW_SECTIONS; null = all
* @returns {object}
+2
View File
@@ -17,6 +17,7 @@ const lanes = require("./openapi-extra/lanes");
const push = require("./openapi-extra/push");
const aiTools = require("./openapi-extra/ai-tools");
const misc = require("./openapi-extra/misc");
const auth = require("./openapi-extra/auth");
// Enriched overrides of already-documented endpoints — same operationId, tags,
// and request/response `$ref` schemas as the base, with added examples and
// richer descriptions. Listed last so they win the merge.
@@ -52,6 +53,7 @@ module.exports = combine(
push,
aiTools,
misc,
auth,
overrideSessionsAgents,
overrideCore,
overridePricingAlerts,
+253
View File
@@ -0,0 +1,253 @@
/**
* @file OpenAPI 3.0 fragment for server mode's login routes (server/routes/auth.js):
* - GET /api/auth/me (Auth)
* - POST /api/auth/login (Auth)
* - POST /api/auth/logout (Auth)
* - GET /api/accounts (Auth) — the Claude account pool, no tokens
* - GET /api/users (Auth) — every account's usage (admin/analyst)
* - GET /api/users/audit (Auth) — staff reads of members' sessions
* Exports `{ tags, schemas, paths }`, combined into the spec by
* server/openapi-extra.js. See docs/SERVER-MODE.md.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const tags = [
{
name: "Auth",
description:
'Server mode\'s login (AGENTHUB_MODE=server). In local mode — the default — only GET /api/auth/me answers, with {mode: "local"}.',
},
];
const schemas = {
AuthUser: {
type: "object",
required: ["id", "username", "role"],
properties: {
id: { type: "integer" },
username: { type: "string" },
role: { type: "string", enum: ["admin", "analyst", "member"] },
disabled: { type: "boolean" },
},
},
AuthMe: {
type: "object",
required: ["mode"],
properties: {
mode: { type: "string", enum: ["local", "server"] },
user: {
description: "Server mode only: the logged-in user, or null.",
nullable: true,
allOf: [{ $ref: "#/components/schemas/AuthUser" }],
},
},
},
};
const err = (description) => ({
description,
content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } },
});
const paths = {
"/api/users": {
get: {
tags: ["Auth"],
operationId: "listServerUsers",
summary:
"Every account with its workspaces, sessions and 7/30-day usage (admin/analyst; 403 EADMIN for a member)",
responses: {
200: {
description: "Accounts",
content: {
"application/json": {
schema: {
type: "object",
properties: {
users: {
type: "array",
items: {
allOf: [
{ $ref: "#/components/schemas/AuthUser" },
{
type: "object",
properties: {
workspaces: { type: "integer" },
sessions: { type: "integer" },
last7d: {
type: "object",
properties: { cost: { type: "number" }, tokens: { type: "integer" } },
},
last30d: {
type: "object",
properties: { cost: { type: "number" }, tokens: { type: "integer" } },
},
},
},
],
},
},
},
},
},
},
},
403: err("EADMIN — a member"),
},
},
},
"/api/users/audit": {
get: {
tags: ["Auth"],
operationId: "listAuditLog",
summary: "Admin/analyst reads of sessions members own, newest first",
parameters: [{ name: "limit", in: "query", schema: { type: "integer", maximum: 1000 } }],
responses: {
200: {
description: "Entries",
content: {
"application/json": {
schema: {
type: "object",
properties: {
entries: {
type: "array",
items: {
type: "object",
properties: {
id: { type: "integer" },
at: { type: "string" },
method: { type: "string" },
path: { type: "string" },
session_id: { type: "string", nullable: true },
reader: { type: "string", nullable: true },
owner: { type: "string", nullable: true },
},
},
},
},
},
},
},
},
403: err("EADMIN — a member"),
},
},
},
"/api/accounts": {
get: {
tags: ["Auth"],
operationId: "listPoolAccounts",
summary: "The Claude account pool as the balancer sees it (never a token)",
parameters: [
{
name: "force",
in: "query",
schema: { type: "string", enum: ["1"] },
description: "Re-read every account's quota first",
},
],
responses: {
200: {
description: "Accounts with live panes, recent spend and quota",
content: {
"application/json": {
schema: {
type: "object",
properties: {
blockPercent: { type: "number" },
accounts: {
type: "array",
items: {
type: "object",
properties: {
name: { type: "string" },
addedAt: { type: "string" },
expiresAt: { type: "string" },
expired: { type: "boolean" },
livePanes: { type: "integer" },
spend5h: { type: "number" },
spend7d: { type: "number" },
quota: { type: "object", nullable: true },
quotaPercent: { type: "number", nullable: true },
full: { type: "boolean" },
},
},
},
},
},
},
},
},
},
},
},
"/api/auth/me": {
get: {
tags: ["Auth"],
operationId: "authMe",
summary: "The dashboard's mode and, in server mode, who is logged in",
responses: {
200: {
description: "Mode and user",
content: { "application/json": { schema: { $ref: "#/components/schemas/AuthMe" } } },
},
},
},
},
"/api/auth/login": {
post: {
tags: ["Auth"],
operationId: "authLogin",
summary: "Log in; sets the httpOnly, SameSite=Strict cookie agenthub_sid",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["username", "password"],
properties: { username: { type: "string" }, password: { type: "string" } },
},
},
},
},
responses: {
200: {
description: "Logged in",
content: {
"application/json": {
schema: {
type: "object",
properties: { user: { $ref: "#/components/schemas/AuthUser" } },
},
},
},
},
401: err("EBADLOGIN — wrong username or password, or a disabled user"),
403: err("EBADORIGIN — a cross-origin request"),
404: err("ENOTSERVER — the dashboard is in local mode"),
429: err("ETOOMANY — too many failed logins from this address (15 minutes)"),
},
},
},
"/api/auth/logout": {
post: {
tags: ["Auth"],
operationId: "authLogout",
summary: "End this login and clear its cookie",
responses: {
200: {
description: "Logged out",
content: {
"application/json": {
schema: { type: "object", properties: { ok: { type: "boolean" } } },
},
},
},
},
},
},
};
module.exports = { tags, schemas, paths };
+25
View File
@@ -0,0 +1,25 @@
/**
* @file accounts.js
* @description `GET /api/accounts` — the Claude account pool as the balancer
* sees it (server/lib/claude-pool.js): per account its live panes, $ spent in
* the last 5 h / 7 d, the last quota reading and whether it counts as full.
* Never a token. `?force=1` re-reads every quota first. Accounts are added and
* removed only from the host's shell (`agenthub accounts …`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { Router } = require("express");
const pool = require("../lib/claude-pool");
const router = Router();
router.get("/", async (req, res) => {
try {
await pool.refreshQuotas({ force: req.query.force === "1" });
res.json({ accounts: pool.poolStatus(), blockPercent: pool.BLOCK_PERCENT });
} catch (e) {
res.status(500).json({ error: { code: e.code || "EPOOL", message: e.message } });
}
});
module.exports = router;
+19 -4
View File
@@ -8,6 +8,7 @@ const { stmts, db } = require("../db");
const { broadcast } = require("../websocket");
const { attachAgentCosts } = require("./pricing");
const { parseSources, sessionIdInSourcesClause } = require("../lib/source-filter");
const { memberId, bySessionWhere } = require("../lib/scope");
const router = Router();
@@ -41,12 +42,22 @@ router.get("/", (req, res) => {
} else if (session_id) {
// A session belongs to exactly one source, so no extra source filter needed.
rows = stmts.listAgentsBySession.all(session_id);
} else if (sources) {
} else if (sources || memberId(req) != null) {
// Data-scope active: build a dynamic query restricting agents to sessions
// from the chosen machines. `agents` carries only session_id → subquery.
const scope = sessionIdInSourcesClause(sources, "session_id");
const clauses = [scope.clause];
const params = [...scope.params];
// A server-mode member gets only their own sessions' agents the same way.
const clauses = [];
const params = [];
const src = sessionIdInSourcesClause(sources, "session_id");
if (src.clause) {
clauses.push(src.clause);
params.push(...src.params);
}
if (memberId(req) != null) {
const own = bySessionWhere(req);
clauses.push(own.where);
params.push(...own.params);
}
if (status) {
clauses.push("status = ?");
params.push(status);
@@ -85,6 +96,10 @@ router.post("/", (req, res) => {
}
const existing = stmts.getAgent.get(id);
// Server mode: another user's agent is "not found", not handed back.
if (existing && !require("../lib/scope").canSeeAgent(req, id)) {
return res.status(404).json({ error: { code: "ENOTFOUND", message: "not found" } });
}
if (existing) {
return res.json({ agent: existing, created: false });
}
+9 -4
View File
@@ -21,6 +21,7 @@ const { listModels } = require("../lib/ai-tool-models");
const toolConfig = require("../lib/ai-tool-config");
const { calculateCost } = require("./pricing");
const { db, stmts } = require("../db");
const { sessionWhere } = require("../lib/scope");
const router = express.Router();
@@ -150,13 +151,17 @@ router.put(
)
);
// How much a tool has been used: its sessions and their priced tokens.
// How much a tool has been used: its sessions and their priced tokens (a
// server mode member: their own sessions only).
router.get("/:id/usage", (req, res) => {
if (!aiTools.has(req.params.id)) {
return res.status(404).json({ error: { code: "ENOTOOL", message: "unknown AI tool" } });
}
const id = req.params.id;
const { n } = db.prepare("SELECT COUNT(*) AS n FROM sessions WHERE tool = ?").get(id);
const own = sessionWhere(req, "s.id", "s.lane_id");
const { n } = db
.prepare(`SELECT COUNT(*) AS n FROM sessions s WHERE s.tool = ? AND ${own.where}`)
.get(id, ...own.params);
const rows = db
.prepare(
`SELECT tu.model AS model, tu.speed AS speed, tu.inference_geo AS inference_geo,
@@ -167,10 +172,10 @@ router.get("/:id/usage", (req, res) => {
SUM(tu.cache_write_tokens + tu.baseline_cache_write) AS cache_write_tokens,
SUM(tu.cache_write_1h_tokens + tu.baseline_cache_write_1h) AS cache_write_1h_tokens
FROM token_usage tu JOIN sessions s ON s.id = tu.session_id
WHERE s.tool = ?
WHERE s.tool = ? AND ${own.where}
GROUP BY tu.model, tu.speed, tu.inference_geo, tu.service_tier`
)
.all(id);
.all(id, ...own.params);
res.json({ sessions: n, cost: calculateCost(rows, stmts.listPricing.all()).total_cost });
});
+39 -3
View File
@@ -7,7 +7,7 @@
const { Router } = require("express");
const { v4: uuidv4 } = require("uuid");
const { stmts } = require("../db");
const { stmts, db } = require("../db");
const { broadcast } = require("../websocket");
const {
RULE_TYPES,
@@ -15,6 +15,7 @@ const {
invalidateRuleCache,
previewRule,
} = require("../lib/alerts");
const { memberId, bySessionWhere } = require("../lib/scope");
const router = Router();
@@ -150,6 +151,31 @@ router.get("/", (req, res) => {
const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);
const unackedOnly = req.query.unacked === "true";
if (memberId(req) != null) {
// Server mode: a member's feed is their own sessions' alerts; one with no
// session (a budget, a global threshold) is staff-only.
const own = bySessionWhere(req);
const count = (extra) =>
db
.prepare(`SELECT COUNT(*) as count FROM alert_events WHERE ${own.where}${extra}`)
.get(...own.params).count;
const unackedSql = " AND acknowledged_at IS NULL";
const alerts = db
.prepare(
`SELECT * FROM alert_events WHERE ${own.where}${unackedOnly ? unackedSql : ""}
ORDER BY triggered_at DESC, id DESC LIMIT ? OFFSET ?`
)
.all(...own.params, limit, offset);
const unacked = count(unackedSql);
return res.json({
alerts,
total: unackedOnly ? unacked : count(""),
unacked,
limit,
offset,
});
}
const alerts = unackedOnly
? stmts.listUnackedAlertEvents.all(limit, offset)
: stmts.listAlertEvents.all(limit, offset);
@@ -174,8 +200,18 @@ router.post("/:id(\\d+)/ack", (req, res) => {
});
// POST /api/alerts/ack-all - Acknowledge every unacked alert
router.post("/ack-all", (_req, res) => {
const info = stmts.ackAllAlertEvents.run();
router.post("/ack-all", (req, res) => {
// A server-mode member acknowledges only their own sessions' alerts.
const own = bySessionWhere(req);
const info =
memberId(req) == null
? stmts.ackAllAlertEvents.run()
: db
.prepare(
`UPDATE alert_events SET acknowledged_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
WHERE acknowledged_at IS NULL AND ${own.where}`
)
.run(...own.params);
if (info.changes > 0) broadcast("alert_updated", { acked_all: true });
res.json({ ok: true, acknowledged: info.changes });
});
+42
View File
@@ -0,0 +1,42 @@
/**
* @file auth.js
* @description `/api/auth` — who is logged in, login and logout for server mode
* (server/lib/auth.js, docs/SERVER-MODE.md). `GET /me` answers in local mode
* too (`{mode: "local"}`) so the client knows whether to show a login page;
* login and logout exist only in server mode. Accounts are made with the CLI
* (`agenthub server setup`, `agenthub users …`), never by a route.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { Router } = require("express");
const auth = require("../lib/auth");
const { sameOriginGuard } = require("../lib/same-origin");
const router = Router();
router.get("/me", (req, res) => {
if (!auth.isServerMode()) return res.json({ mode: "local" });
res.json({ mode: "server", user: auth.userFromRequest(req) });
});
router.post("/login", sameOriginGuard, async (req, res) => {
if (!auth.isServerMode()) {
return res.status(404).json({ error: { code: "ENOTSERVER", message: "not in server mode" } });
}
try {
const { user, token } = await auth.login(req, req.body?.username, req.body?.password);
auth.setLoginCookie(req, res, token);
res.json({ user });
} catch (e) {
const status = e.code === "ETOOMANY" ? 429 : e.code === "EBADLOGIN" ? 401 : 500;
res.status(status).json({ error: { code: e.code || "EAUTH", message: e.message } });
}
});
router.post("/logout", sameOriginGuard, (req, res) => {
auth.endSession(auth.cookieToken(req));
auth.clearLoginCookie(req, res);
res.json({ ok: true });
});
module.exports = router;
+4 -3
View File
@@ -3,7 +3,8 @@
* `budget` (created, edited and deleted through /api/alerts/rules); this router
* only reads: `GET /api/budgets/status` lists every enabled budget with its
* current-period spend, percent of the limit, projected end-of-period spend and
* the thresholds reached (server/lib/budgets.js).
* the thresholds reached (server/lib/budgets.js) — a server mode member's spend
* over their own sessions only.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -13,8 +14,8 @@ const { budgetStatus } = require("../lib/budgets");
const router = Router();
// GET /api/budgets/status
router.get("/status", (_req, res) => {
res.json({ budgets: budgetStatus() });
router.get("/status", (req, res) => {
res.json({ budgets: budgetStatus({ req }) });
});
module.exports = router;
+17 -5
View File
@@ -10,6 +10,7 @@
const { Router } = require("express");
const { db } = require("../db");
const { parseSources, sessionIdInSourcesClause } = require("../lib/source-filter");
const { memberId, bySessionWhere } = require("../lib/scope");
const router = Router();
@@ -78,6 +79,12 @@ function buildWhere(filters) {
params.push(...scope.params);
}
// Server mode: a member's events are only those of their own sessions.
if (filters.own) {
clauses.push(filters.own.where);
params.push(...filters.own.params);
}
return {
sql: clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "",
params,
@@ -98,6 +105,7 @@ router.get("/", (req, res) => {
from: parseDate(req.query.from),
to: parseDate(req.query.to),
sources: parseSources(req),
own: memberId(req) != null ? bySessionWhere(req) : null,
};
const { sql: whereSql, params: whereParams } = buildWhere(filters);
@@ -120,17 +128,21 @@ router.get("/", (req, res) => {
});
// GET /api/events/facets — distinct event_type / tool_name values in the DB.
router.get("/facets", (_req, res) => {
router.get("/facets", (req, res) => {
// A server-mode member sees only the values in their own sessions' events.
const own = bySessionWhere(req);
const eventTypes = db
.prepare(
"SELECT DISTINCT event_type FROM events WHERE event_type IS NOT NULL ORDER BY event_type"
`SELECT DISTINCT event_type FROM events WHERE event_type IS NOT NULL AND ${own.where} ORDER BY event_type`
)
.all()
.all(...own.params)
.map((r) => r.event_type);
const toolNames = db
.prepare("SELECT DISTINCT tool_name FROM events WHERE tool_name IS NOT NULL ORDER BY tool_name")
.all()
.prepare(
`SELECT DISTINCT tool_name FROM events WHERE tool_name IS NOT NULL AND ${own.where} ORDER BY tool_name`
)
.all(...own.params)
.map((r) => r.tool_name);
res.json({ event_types: eventTypes, tool_names: toolNames });
+75 -12
View File
@@ -31,6 +31,8 @@ const runs = require("../lib/pty-run");
const tmux = require("../lib/tmux");
const { resumeProxy } = require("../lib/proxy-apply");
const { sameOriginGuard } = require("./run");
const { isLocalRequest } = require("../lib/same-origin");
const scope = require("../lib/scope");
const { writeLaneFile } = require("../lib/lane-file-write");
const { pickPath } = require("../lib/native-dialog");
const { preflight } = require("../lib/lane-preflight");
@@ -200,8 +202,10 @@ function broadcastLane(id) {
if (lane) broadcast("lane_update", { lane: payload(lane) });
}
router.get("/", (_req, res) => {
const rows = lanesLib.listLanes();
router.get("/", (req, res) => {
// Server mode: a member lists (and counts) only their own workspaces.
const visible = scope.visibleLaneIds(req);
const rows = lanesLib.listLanes().filter((l) => visible === null || visible.includes(l.id));
// ONE list-sessions answers every lane's run, instead of a synchronous
// has-session per lane (null when tmux could not be asked: per-lane then).
const live = rows.some((l) => l.run_id) ? tmux.liveSessions() : null;
@@ -249,13 +253,18 @@ router.get("/pipeline-stats", (req, res) => {
}
}
// Server mode: a member's aggregate, lanes and picker cover only their own
// workspaces ("1" for everyone else). A `?lane=` of another user's never
// gets here — scopeGuard answers 404 first.
const own = scope.laneWhere(req, "lane_id");
const ownLanes = scope.laneWhere(req, "id");
const events =
laneId === null
? db
.prepare(
"SELECT lane_id, pipeline, run_seq, node_id, source, result, evidence FROM lane_stage_events ORDER BY id"
`SELECT lane_id, pipeline, run_seq, node_id, source, result, evidence FROM lane_stage_events WHERE ${own.where} ORDER BY id`
)
.all()
.all(...own.params)
: db
.prepare(
"SELECT lane_id, pipeline, run_seq, node_id, source, result, evidence FROM lane_stage_events WHERE lane_id = ? ORDER BY id"
@@ -279,7 +288,7 @@ router.get("/pipeline-stats", (req, res) => {
// with zero runs must appear, at zero, rather than vanish.
const scopedLanes =
laneId === null
? db.prepare("SELECT pipeline FROM lanes").all()
? db.prepare(`SELECT pipeline FROM lanes WHERE ${ownLanes.where}`).all(...ownLanes.params)
: db.prepare("SELECT pipeline FROM lanes WHERE id = ?").all(laneId);
for (const row of scopedLanes) entryFor(row.pipeline);
@@ -343,7 +352,9 @@ router.get("/pipeline-stats", (req, res) => {
// the reader most likely came to look at.
.sort((a, b) => b.runs - a.runs);
const lanes = db.prepare("SELECT id, title, cwd FROM lanes ORDER BY id").all();
const lanes = db
.prepare(`SELECT id, title, cwd FROM lanes WHERE ${ownLanes.where} ORDER BY id`)
.all(...ownLanes.params);
res.json({ pipelines, lanes });
});
@@ -385,6 +396,23 @@ router.get("/branches", async (req, res) => {
}
});
/** The lane fields a server-mode member may PATCH (the UI sends title and pipeline). */
const MEMBER_PATCHABLE = new Set(["title", "pipeline", "notes", "links", "branch"]);
/** Server mode: the folder is, holds or lies in another user's workspace. */
function refuseOwned(res) {
return res.status(409).json({
error: { code: "EOWNED", message: "this folder belongs to another user's workspace" },
});
}
/** Server mode with AGENTHUB_USERS_ROOT: a member's folder lies outside their own. */
function refuseFolder(res) {
return res.status(403).json({
error: { code: "EFOLDER", message: "a workspace must be inside your own folder" },
});
}
/**
* Idempotent "which lane owns this directory?" — the Workspace page opens on a
* cwd, not on a lane id, so it needs one lane to exist for that cwd without
@@ -404,6 +432,8 @@ router.post("/ensure", sameOriginGuard, (req, res) => {
if (body.tool !== undefined && !aiTools.has(body.tool)) {
return res.status(400).json({ error: { code: "EBADTOOL", message: "unknown tool" } });
}
if (!scope.folderAllowed(req, cwd)) return refuseFolder(res);
if (scope.folderTakenByOther(req, cwd)) return refuseOwned(res);
// A cwd can now back one lane per tool (idx_lanes_cwd_tool) — "does a lane
// already own this (cwd, tool) pair" is the real question, so a request for
@@ -418,6 +448,12 @@ router.post("/ensure", sameOriginGuard, (req, res) => {
const found = lanesLib.resolveLaneByCwd(cwd, body.tool || "claude");
const strip = (p) => p.replace(/[\\/]+$/, "");
const owner = found && (body.exact !== true || strip(found.cwd) === strip(cwd)) ? found : null;
// Server mode: another user's workspace is not this one's to adopt.
if (owner && !scope.canSeeLane(req, owner.id)) {
return res.status(409).json({
error: { code: "EOWNED", message: "this folder belongs to another user's workspace" },
});
}
if (owner) {
// First-write wins on the title: an ensure-with-title on an existing lane
// is how callers like Sagi adopt the lane, so don't clobber a real title
@@ -431,6 +467,7 @@ router.post("/ensure", sameOriginGuard, (req, res) => {
try {
const lane = lanesLib.createLane({
cwd,
owner_id: scope.ownerFor(req),
title: body.title || "",
...(body.pipeline ? { pipeline: body.pipeline } : {}),
...(body.tool ? { tool: body.tool } : {}),
@@ -463,6 +500,12 @@ router.post("/ensure", sameOriginGuard, (req, res) => {
* "oversized" mean.
*/
router.post("/gc", sameOriginGuard, (req, res) => {
// Machine-wide, so it cannot be narrowed to one member's workspaces.
if (scope.memberId(req) != null) {
return res
.status(403)
.json({ error: { code: "EADMIN", message: "only an admin may run housekeeping" } });
}
const dryRun = req.body?.dryRun === true;
try {
const reaped = reapOrphanMcp({ dryRun });
@@ -525,9 +568,7 @@ router.get("/browse", (req, res) => {
* apart — the dialog is closed when the browser's request goes away.
*/
router.post("/pick-path", sameOriginGuard, async (req, res) => {
const addr = req.socket.remoteAddress || "";
const proxied = req.headers["x-forwarded-for"] || req.headers.forwarded;
if (proxied || !["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(addr)) {
if (!isLocalRequest(req)) {
return res.status(403).json({
error: { code: "EREMOTE", message: "the native picker opens only for a local browser" },
});
@@ -557,7 +598,11 @@ router.get("/:id", (req, res) => {
router.post("/", sameOriginGuard, (req, res) => {
try {
const lane = lanesLib.createLane(req.body || {});
const body = req.body || {};
const cwd = typeof body.cwd === "string" ? lanesLib.canonicalCwd(body.cwd) : body.cwd;
if (typeof cwd === "string" && !scope.folderAllowed(req, cwd)) return refuseFolder(res);
if (typeof cwd === "string" && scope.folderTakenByOther(req, cwd)) return refuseOwned(res);
const lane = lanesLib.createLane({ ...body, owner_id: scope.ownerFor(req) });
broadcastLane(lane.id);
res.status(201).json({ lane: payload(lane) });
} catch (err) {
@@ -584,9 +629,21 @@ router.patch("/:id", sameOriginGuard, (req, res) => {
if (!lanesLib.getLane(req.params.id)) {
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
}
// Server mode: a member edits only what a person types — never the columns
// that tie a workspace to a pane or a session (`run_id` pointed at another
// user's pane would let stop/reset kill it).
const body = req.body && typeof req.body === "object" ? req.body : {};
if (scope.memberId(req) != null) {
const off = Object.keys(body).filter((k) => !MEMBER_PATCHABLE.has(k));
if (off.length) {
return res.status(403).json({
error: { code: "EFIELD", message: `a member may not change ${off.join(", ")}` },
});
}
}
let lane;
try {
lane = lanesLib.updateLane(req.params.id, req.body || {});
lane = lanesLib.updateLane(req.params.id, body);
} catch (err) {
// A bad `kind`, `pipeline` or field value is invalid input, not a server
// fault — every sibling route answers 400 here, so this one must too
@@ -1253,6 +1310,8 @@ router.post("/worktree", sameOriginGuard, async (req, res) => {
},
});
}
if (!scope.folderAllowed(req, lanesLib.canonicalCwd(sourceRepo))) return refuseFolder(res);
if (scope.folderTakenByOther(req, lanesLib.canonicalCwd(sourceRepo))) return refuseOwned(res);
// A repo with no commit has nothing a worktree could branch from: git would
// fail later, in the background job, and leave a `failed` row behind. Refuse
@@ -1318,6 +1377,7 @@ router.post("/worktree", sameOriginGuard, async (req, res) => {
cwd: dir,
branch,
kind: "managed",
owner_id: scope.ownerFor(req),
source_repo: resolvedSourceRepo,
base_branch: body.base || null,
slug,
@@ -1421,7 +1481,10 @@ function laneRunIds(lane) {
.filter((r) => r.laneId === lane.id)
.map((r) => r.id)
);
if (lane.run_id) ids.add(lane.run_id);
// Only this lane's own panes: a run_id naming another lane's is ignored.
if (lane.run_id && new RegExp(`^agenthub-lane-${lane.id}(-\\d+)?$`).test(lane.run_id)) {
ids.add(lane.run_id);
}
return [...ids];
}
+33 -5
View File
@@ -3,18 +3,39 @@
* Every route is single-shot — this layer never blocks waiting for a lock to
* free up. The CLI (`agenthub lock acquire`) owns the polling loop; AgentHub's
* server-side primitives stay non-orchestrating, same as every other lane
* route in this project.
* route in this project. Lock names are one directory name each (named-lock.js
* refuses a path), and in server mode a member's live in their own namespace.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { Router } = require("express");
const namedLock = require("../lib/named-lock");
const { sameOriginGuard } = require("./run");
const { memberId } = require("../lib/scope");
const router = Router();
router.get("/", (_req, res) => {
res.json({ locks: namedLock.listLocks() });
// Server mode: a member's locks live in their own namespace (`u<id>.<name>`),
// so one user's agents can neither see nor take another user's locks.
const memberPrefix = (req) => {
const uid = memberId(req);
return uid == null ? "" : `u${uid}.`;
};
const lockName = (req) => memberPrefix(req) + req.params.name;
const badInput = (res, err) =>
res.status(400).json({ error: { code: err.code, message: err.message } });
router.get("/", (req, res) => {
const prefix = memberPrefix(req);
const locks = namedLock.listLocks();
res.json({
locks: prefix
? locks
.filter((l) => l.name.startsWith(prefix))
.map((l) => ({ ...l, name: l.name.slice(prefix.length) }))
: locks,
});
});
router.post("/:name/acquire", sameOriginGuard, (req, res) => {
@@ -22,7 +43,13 @@ router.post("/:name/acquire", sameOriginGuard, (req, res) => {
if (!holder) {
return res.status(400).json({ error: { code: "EBADHOLDER", message: "holder is required" } });
}
const result = namedLock.tryAcquire(req.params.name, holder);
let result;
try {
result = namedLock.tryAcquire(lockName(req), holder);
} catch (err) {
if (err.code === "EBADNAME" || err.code === "EBADHOLDER") return badInput(res, err);
throw err;
}
if (result.acquired) return res.json(result);
res.status(409).json(result);
});
@@ -33,9 +60,10 @@ router.post("/:name/release", sameOriginGuard, (req, res) => {
return res.status(400).json({ error: { code: "EBADHOLDER", message: "holder is required" } });
}
try {
namedLock.release(req.params.name, holder);
namedLock.release(lockName(req), holder);
res.json({ ok: true });
} catch (err) {
if (err.code === "EBADNAME" || err.code === "EBADHOLDER") return badInput(res, err);
if (err.code === "ENOLOCK") {
return res.status(404).json({ error: { code: err.code, message: err.message } });
}
+6 -1
View File
@@ -585,7 +585,12 @@ router.get("/cost", (req, res) => {
// GET /api/pricing/quota - plan quota spent per native tool (Claude, Codex):
// percentages and reset times only, `?force=1` for a Refresh click (server/lib/quota.js).
router.get("/quota", async (req, res) => {
res.json(await quotas({ force: req.query.force === "1" }));
const out = await quotas({ force: req.query.force === "1" });
// Server mode: a member sees how full the plan is, not whose accounts.
if (require("../lib/scope").memberId(req) != null && Array.isArray(out.tools)) {
out.tools = out.tools.map((t) => ({ ...t, account: null, label: t.id }));
}
res.json(out);
});
// GET /api/pricing/cost/:sessionId - Get cost for a specific session
+20 -5
View File
@@ -2,7 +2,8 @@
* @file Express router for /api/projects: every lane grouped by the git repo
* it belongs to, with that repo's worktrees (see server/lib/projects.js). Read
* on demand by the sidebar's project tree, never polled; `POST /refresh` drops
* the cached roots and worktree lists first.
* the cached roots and worktree lists first. In server mode a member gets only
* their own workspaces' projects.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -10,16 +11,30 @@ const { Router } = require("express");
const lanesLib = require("../lib/lanes");
const { listProjects, clearProjectCaches } = require("../lib/projects");
const { sameOriginGuard } = require("../lib/same-origin");
const scope = require("../lib/scope");
const router = Router();
router.get("/", async (_req, res) => {
res.json({ projects: await listProjects(lanesLib.listLanes()) });
/**
* Server mode: a member's projects are built from their own workspaces only,
* and a worktree another user's workspace sits on is left out of the list.
*/
async function projectsFor(req) {
const all = lanesLib.listLanes();
const visible = scope.visibleLaneIds(req);
if (visible === null) return listProjects(all);
const others = new Set(all.filter((l) => !visible.includes(l.id)).map((l) => l.cwd));
const projects = await listProjects(all.filter((l) => visible.includes(l.id)));
return projects.map((p) => ({ ...p, worktrees: p.worktrees.filter((w) => !others.has(w.path)) }));
}
router.get("/", async (req, res) => {
res.json({ projects: await projectsFor(req) });
});
router.post("/refresh", sameOriginGuard, async (_req, res) => {
router.post("/refresh", sameOriginGuard, async (req, res) => {
clearProjectCaches();
res.json({ projects: await listProjects(lanesLib.listLanes()) });
res.json({ projects: await projectsFor(req) });
});
module.exports = router;
+19 -5
View File
@@ -1,5 +1,5 @@
/**
* @file Express router for managing push notifications, providing endpoints to retrieve the VAPID public key, subscribe/unsubscribe to push notifications, and send push notifications to all subscribers. It interacts with the database to store subscription details and uses a push library to send notifications.
* @file Express router for managing push notifications, providing endpoints to retrieve the VAPID public key, subscribe/unsubscribe to push notifications, and send push notifications to all subscribers (in server mode: only the logged-in user's own subscriptions). It interacts with the database to store subscription details and uses a push library to send notifications.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -19,8 +19,8 @@ router.post("/subscribe", (req, res) => {
return res.status(400).json({ error: { message: "Missing required fields" } });
}
db.prepare(
"INSERT OR REPLACE INTO push_subscriptions (endpoint, p256dh, auth) VALUES (?, ?, ?)"
).run(endpoint, keys.p256dh, keys.auth);
"INSERT OR REPLACE INTO push_subscriptions (endpoint, p256dh, auth, user_id) VALUES (?, ?, ?, ?)"
).run(endpoint, keys.p256dh, keys.auth, req.user ? req.user.id : null);
res.json({ ok: true });
});
@@ -29,7 +29,15 @@ router.delete("/subscribe", (req, res) => {
if (!endpoint) {
return res.status(400).json({ error: { message: "Missing endpoint" } });
}
db.prepare("DELETE FROM push_subscriptions WHERE endpoint = ?").run(endpoint);
// Server mode: only one's own subscription.
if (req.user) {
db.prepare("DELETE FROM push_subscriptions WHERE endpoint = ? AND user_id = ?").run(
endpoint,
req.user.id
);
} else {
db.prepare("DELETE FROM push_subscriptions WHERE endpoint = ?").run(endpoint);
}
res.json({ ok: true });
});
@@ -43,7 +51,13 @@ router.post("/send", async (req, res) => {
// { native: true|false, pushed: <count>, failed: <count> }
// so a silent "no subscribers, no Electron host" no-op stops looking like
// success on the client side.
const result = await sendPushToAll(db, title, body, typeof url === "string" ? url : undefined);
const result = await sendPushToAll(
db,
title,
body,
typeof url === "string" ? url : undefined,
req.user ? req.user.id : null
);
res.json({ ok: true, ...result });
} catch (err) {
res.status(500).json({ error: { message: err.message } });
+109 -35
View File
@@ -31,6 +31,7 @@ const lanesLib = require("../lib/lanes");
const { sameOriginGuard } = require("../lib/same-origin");
const aiTools = require("../lib/ai-tools");
const { resumeProxy } = require("../lib/proxy-apply");
const scope = require("../lib/scope");
const router = Router();
@@ -65,6 +66,28 @@ function sanitiseCwd(input) {
return resolved;
}
/**
* Server mode: whether a member may work in `dir` — only inside one of their
* own workspaces' folders, compared by real path so a symlink in their folder
* cannot point the check elsewhere. Always true for everyone else.
*/
function insideOwnWorkspace(req, dir) {
const visible = scope.visibleLaneIds(req);
if (visible === null) return true;
const real = lanesLib.canonicalCwd(dir);
return visible.some((id) => {
const cwd = lanesLib.getLane(id)?.cwd;
if (!cwd) return false;
const rel = path.relative(cwd, real);
return rel === "" || (rel.split(path.sep)[0] !== ".." && !path.isAbsolute(rel));
});
}
const refuseFolder = (res) =>
res.status(403).json({
error: { code: "EFOLDER", message: "that folder is not one of your workspaces" },
});
const ALLOWED_PERMISSION_MODES = new Set([
"auto",
"acceptEdits",
@@ -73,8 +96,11 @@ const ALLOWED_PERMISSION_MODES = new Set([
"bypassPermissions",
]);
router.get("/", (_req, res) => {
res.json({ items: runs.listRuns() });
router.get("/", (req, res) => {
// Server mode: a member sees only the panes of their own workspaces.
const visible = scope.visibleLaneIds(req);
const items = runs.listRuns();
res.json({ items: visible === null ? items : items.filter((r) => visible.includes(r.laneId)) });
});
/**
@@ -91,8 +117,9 @@ router.get("/", (_req, res) => {
// A local terminal answers a dropped image by typing its PATH, which is how
// `claude` attaches it. A browser has no path to give, so the image is saved
// here — on the machine the pane runs on — and the client pastes this path.
// Only the bytes come from the request: the name is random and the type picks
// the extension from a fixed map, so a request never names a file.
// drop-image: only the bytes come from the request — the name is random and the
// type picks the extension from a fixed map. drop-file (any other file) keeps a
// sanitized basename of the file's own name behind a random prefix.
const DROP_TYPES = {
"image/png": "png",
"image/jpeg": "jpg",
@@ -103,6 +130,30 @@ const DROP_MAX_BYTES = 20 * 1024 * 1024;
const DROP_TTL_MS = 24 * 60 * 60_000;
const dropDir = () => path.join(os.tmpdir(), `agenthub-drops-${os.userInfo().uid}`);
// Saves one dropped body under the drop dir and answers its path. Pruned on
// write: files older than a day go.
function saveDrop(res, name, body) {
try {
const dir = dropDir();
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
// ponytail: prune-on-write, a day old; a sweep job only if drops pile up.
const cutoff = Date.now() - DROP_TTL_MS;
for (const entry of fs.readdirSync(dir)) {
const f = path.join(dir, entry);
try {
if (fs.statSync(f).mtimeMs < cutoff) fs.unlinkSync(f);
} catch {
/* raced with another prune — fine */
}
}
const file = path.join(dir, name);
fs.writeFileSync(file, body, { mode: 0o600, flag: "wx" });
res.json({ path: file });
} catch (err) {
res.status(500).json({ error: err.message });
}
}
router.post(
"/drop-image",
express.raw({ type: Object.keys(DROP_TYPES), limit: DROP_MAX_BYTES }),
@@ -116,28 +167,26 @@ router.post(
if (!ext || !Buffer.isBuffer(req.body) || req.body.length === 0) {
return res.status(400).json({ error: "png, jpeg, gif or webp image body required" });
}
try {
const dir = dropDir();
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
// ponytail: prune-on-write, a day old; a sweep job only if drops pile up.
const cutoff = Date.now() - DROP_TTL_MS;
for (const name of fs.readdirSync(dir)) {
const f = path.join(dir, name);
try {
if (fs.statSync(f).mtimeMs < cutoff) fs.unlinkSync(f);
} catch {
/* raced with another prune — fine */
}
}
const file = path.join(dir, `${crypto.randomBytes(8).toString("hex")}.${ext}`);
fs.writeFileSync(file, req.body, { mode: 0o600, flag: "wx" });
res.json({ path: file });
} catch (err) {
res.status(500).json({ error: err.message });
}
saveDrop(res, `${crypto.randomBytes(8).toString("hex")}.${ext}`, req.body);
}
);
// Any other dropped file (a PDF, a log, a CSV…): the agent reads it by path,
// so its own name is kept — reduced to a basename of [A-Za-z0-9._-] behind a
// random prefix, so it can neither leave the drop dir nor collide, and the
// space-joined paste of several paths stays parseable.
router.post("/drop-file", express.raw({ type: () => true, limit: DROP_MAX_BYTES }), (req, res) => {
const safe = path
.basename(String(req.query.name || ""))
.replace(/[^A-Za-z0-9._-]/g, "_")
.replace(/^\.+/, "")
.slice(-100);
if (!safe || !Buffer.isBuffer(req.body) || req.body.length === 0) {
return res.status(400).json({ error: "?name= and a non-empty file body required" });
}
saveDrop(res, `${crypto.randomBytes(8).toString("hex")}-${safe}`, req.body);
});
router.get("/history", (req, res) => {
let dr = null;
try {
@@ -147,11 +196,22 @@ router.get("/history", (req, res) => {
}
const limit = Number.parseInt(String(req.query.limit || "50"), 10);
const laneId = Number.parseInt(String(req.query.laneId ?? ""), 10);
const items = dr.listRuns({
const opts = {
limit: Number.isFinite(limit) ? limit : 50,
laneId: Number.isFinite(laneId) ? laneId : null,
tool: typeof req.query.tool === "string" ? req.query.tool : null,
});
};
// Server mode: a member with no ?laneId (a named one is scopeGuard's) gets
// the newest runs of their own workspaces only.
const visible = opts.laneId === null ? scope.visibleLaneIds(req) : null;
// ponytail: one query per workspace; one IN (...) query if members own hundreds.
const items =
visible === null
? dr.listRuns(opts)
: visible
.flatMap((id) => dr.listRuns({ ...opts, laneId: id, tool: null }))
.sort((a, b) => String(b.started_at).localeCompare(String(a.started_at)))
.slice(0, Math.max(1, Math.min(500, Math.floor(opts.limit) || 50)));
const liveIds = new Set(runs.listRuns().map((h) => h.id));
res.json({
items: items.map((it) => ({ ...it, isLive: liveIds.has(it.id) })),
@@ -188,7 +248,12 @@ router.delete("/history/:id", (req, res) => {
*
* Optional ?q=<substring> filter applied client-side as well.
*/
router.get("/cwds", (_req, res) => {
router.get("/cwds", (req, res) => {
// Server mode: a member's suggestions come from their own workspaces and
// sessions only — not the server's own folder or home.
const member = scope.memberId(req) != null;
const ownLanes = scope.laneWhere(req, "id");
const ownSessions = scope.sessionWhere(req);
const out = [];
const seen = new Set();
const push = (kind, p, label) => {
@@ -200,8 +265,10 @@ router.get("/cwds", (_req, res) => {
out.push({ kind, path: abs, label: label || path.basename(abs) || abs });
};
push("dashboard", process.cwd(), "Dashboard server");
push("home", require("node:os").homedir(), "Home");
if (!member) {
push("dashboard", process.cwd(), "Dashboard server");
push("home", require("node:os").homedir(), "Home");
}
// Source repos lanes have already been provisioned from, newest lane first.
// The Add-lane form's repo field used to offer only "recent" session cwds,
@@ -214,10 +281,10 @@ router.get("/cwds", (_req, res) => {
const rows = db
.prepare(
`SELECT source_repo AS p, MAX(id) AS last_id FROM lanes
WHERE source_repo IS NOT NULL AND source_repo <> ''
WHERE source_repo IS NOT NULL AND source_repo <> '' AND ${ownLanes.where}
GROUP BY source_repo ORDER BY last_id DESC LIMIT 20`
)
.all();
.all(...ownLanes.params);
for (const row of rows) push("repo", row.p, path.basename(row.p));
} catch {
/* ignore — same best-effort contract as the recents below */
@@ -230,10 +297,10 @@ router.get("/cwds", (_req, res) => {
const rows = db
.prepare(
`SELECT cwd, MAX(started_at) AS last_at FROM sessions
WHERE cwd IS NOT NULL AND cwd <> ''
WHERE cwd IS NOT NULL AND cwd <> '' AND ${ownSessions.where}
GROUP BY cwd ORDER BY last_at DESC LIMIT 30`
)
.all();
.all(...ownSessions.params);
for (const row of rows) {
push("recent", row.cwd, path.basename(row.cwd));
}
@@ -257,6 +324,7 @@ router.get("/files", (req, res) => {
} catch (err) {
return res.status(400).json({ error: { code: err.code, message: err.message } });
}
if (!insideOwnWorkspace(req, cwd)) return refuseFolder(res);
const q = typeof req.query.q === "string" ? req.query.q.toLowerCase() : "";
const SKIP_DIRS = new Set([
"node_modules",
@@ -331,10 +399,15 @@ router.post("/", (req, res) => {
}
let cwd;
try {
cwd = sanitiseCwd(body.cwd);
// A member's run with no cwd starts in its workspace, not the server's folder.
const empty = body.cwd == null || body.cwd === "";
cwd = sanitiseCwd(
empty && scope.memberId(req) != null ? lanesLib.getLane(laneId)?.cwd : body.cwd
);
} catch (err) {
return res.status(400).json({ error: { code: err.code, message: err.message } });
}
if (!insideOwnWorkspace(req, cwd)) return refuseFolder(res);
try {
const lane = lanesLib.getLane(laneId);
const handle = runs.spawnRun({
@@ -386,7 +459,7 @@ router.post("/", (req, res) => {
});
router.get("/:id", (req, res) => {
const handle = runs.getRun(req.params.id);
const handle = scope.canSeeRun(req, req.params.id) ? runs.getRun(req.params.id) : null;
if (!handle || handle.status === "gone") {
return res.status(404).json({ error: { code: "ENOTFOUND", message: "run not found" } });
}
@@ -394,7 +467,8 @@ router.get("/:id", (req, res) => {
});
router.delete("/:id", (req, res) => {
const ok = runs.killRun(req.params.id);
// scopeGuard checks the raw path; this checks the decoded id the kill uses.
const ok = scope.canSeeRun(req, req.params.id) && runs.killRun(req.params.id);
if (!ok) {
return res.status(404).json({ error: { code: "ENOTFOUND", message: "run not found" } });
}
+42 -4
View File
@@ -11,6 +11,7 @@ const { stmts, db } = require("../db");
const { broadcast } = require("../websocket");
const { calculateCost, attachAgentCosts } = require("./pricing");
const { parseSources, sourceColumnClause } = require("../lib/source-filter");
const { memberId, sessionWhere, canSeeSession } = require("../lib/scope");
const {
getClaudeHome,
getProjectsDir,
@@ -220,6 +221,12 @@ router.get("/", (req, res) => {
where.push(sourceFilter.clause);
params.push(...sourceFilter.params);
}
// Server mode: a member lists only their own workspaces' sessions.
if (memberId(req) != null) {
const own = sessionWhere(req, "s.id", "s.lane_id");
where.push(own.where);
params.push(...own.params);
}
const whereSql = where.length > 0 ? `WHERE ${where.join(" AND ")}` : "";
const total = db.prepare(`SELECT COUNT(*) as c FROM sessions s ${whereSql}`).get(...params).c;
@@ -290,13 +297,24 @@ router.get("/", (req, res) => {
res.json({ sessions: rows, limit, offset, total });
});
router.get("/facets", (_req, res) => {
router.get("/facets", (req, res) => {
const own = sessionWhere(req);
const rows = db
.prepare("SELECT DISTINCT cwd FROM sessions WHERE cwd IS NOT NULL AND cwd != '' ORDER BY cwd")
.all();
.prepare(
`SELECT DISTINCT cwd FROM sessions WHERE cwd IS NOT NULL AND cwd != '' AND ${own.where} ORDER BY cwd`
)
.all(...own.params);
// Distinct origins present in the data, so the UI can offer a source facet /
// scope selector. Always includes at least 'local' (the column default).
const sources = stmts.distinctSessionSources.all().map((r) => r.source);
const sources = (
memberId(req) == null
? stmts.distinctSessionSources.all()
: db
.prepare(
`SELECT DISTINCT source FROM sessions WHERE source IS NOT NULL AND source != '' AND ${own.where} ORDER BY source`
)
.all(...own.params)
).map((r) => r.source);
res.json({ cwds: rows.map((r) => r.cwd), sources });
});
@@ -405,6 +423,13 @@ function sessionStats(sessionId) {
}
router.post("/", (req, res) => {
// Server mode: sessions come from hooks and the dashboard's own panes; a
// member hand-making one would plant an unowned row every admin sees.
if (memberId(req) != null) {
return res
.status(403)
.json({ error: { code: "EADMIN", message: "a member cannot create a session directly" } });
}
const { id, name, cwd, model, metadata } = req.body;
if (!id) {
return res.status(400).json({ error: { code: "INVALID_INPUT", message: "id is required" } });
@@ -412,6 +437,10 @@ router.post("/", (req, res) => {
const existing = stmts.getSession.get(id);
if (existing) {
// A member may not read another's session by naming its id here.
if (!canSeeSession(req, id)) {
return res.status(404).json({ error: { code: "NOT_FOUND", message: "Session not found" } });
}
return res.json({ session: existing, created: false });
}
@@ -667,6 +696,15 @@ router.get("/:id/transcript", async (req, res) => {
const agentId = req.query.agent_id || null;
const runId = req.query.run_id || null;
// Both are joined into a file path below: one plain name each, never a path.
const SAFE_ID = /^[\w.-]{1,200}$/;
for (const v of [agentId, runId]) {
if (v !== null && (typeof v !== "string" || !SAFE_ID.test(v) || v.includes(".."))) {
return res
.status(400)
.json({ error: { code: "EBADID", message: "invalid agent_id or run_id" } });
}
}
// Subagent transcripts (anything but the main session file) need different
// sender attribution: their first user line is the orchestrator's task.
const isSubagentFile = !!(agentId && agentId !== "main");
+26 -14
View File
@@ -28,7 +28,13 @@ const { envOwned, applySetting } = require("../lib/settings-env");
const privacy = require("../lib/privacy");
const pushToken = require("../lib/push-token");
const pricingSync = require("../lib/pricing-sync");
const { EXPORT_FORMAT, EXPORT_VERSION, importExportBundle } = require("../lib/data-transfer");
const {
EXPORT_FORMAT,
EXPORT_VERSION,
importExportBundle,
exportableRun,
} = require("../lib/data-transfer");
const { sessionWhere, bySessionWhere } = require("../lib/scope");
function getDbSize() {
try {
@@ -39,16 +45,19 @@ function getDbSize() {
}
}
function getTableCounts() {
const tables = ["sessions", "agents", "events", "model_pricing"];
const counts = {};
for (const t of tables) {
counts[t] = db.prepare(`SELECT COUNT(*) as c FROM ${t}`).get().c;
}
counts.token_usage = db
.prepare("SELECT COUNT(DISTINCT session_id) as c FROM token_usage")
.get().c;
return counts;
// `req` scopes the counts to a server-mode member's own rows (sessions,
// agents, events, token usage); pricing is shared configuration, counted whole.
function getTableCounts(req) {
const own = sessionWhere(req);
const bySession = bySessionWhere(req);
const count = (sql, w) => db.prepare(`${sql} WHERE ${w.where}`).get(...w.params).c;
return {
sessions: count("SELECT COUNT(*) as c FROM sessions", own),
agents: count("SELECT COUNT(*) as c FROM agents", bySession),
events: count("SELECT COUNT(*) as c FROM events", bySession),
model_pricing: db.prepare("SELECT COUNT(*) as c FROM model_pricing").get().c,
token_usage: count("SELECT COUNT(DISTINCT session_id) as c FROM token_usage", bySession),
};
}
function getHookStatus() {
@@ -98,7 +107,7 @@ function getHookStatus() {
// GET /api/settings/info — system info, db stats, hook status
router.get("/info", (req, res) => {
const dbSize = getDbSize();
const counts = getTableCounts();
const counts = getTableCounts(req);
const hookStatus = getHookStatus();
// Advanced SQLite info
@@ -112,9 +121,12 @@ router.get("/info", (req, res) => {
};
// Recent activity load (events in last 5, 15, 60 minutes)
const ownEvents = bySessionWhere(req);
const getCount = (ms) => {
const d = new Date(Date.now() - ms).toISOString();
return db.prepare("SELECT COUNT(*) as c FROM events WHERE created_at > ?").get(d).c;
return db
.prepare(`SELECT COUNT(*) as c FROM events WHERE created_at > ? AND ${ownEvents.where}`)
.get(d, ...ownEvents.params).c;
};
const load_stats = {
@@ -357,7 +369,7 @@ router.get("/export", async (req, res) => {
await write(
field(
"dashboard_runs",
db.prepare("SELECT * FROM dashboard_runs ORDER BY started_at DESC").all()
db.prepare("SELECT * FROM dashboard_runs ORDER BY started_at DESC").all().map(exportableRun)
)
);
await write(
+7 -4
View File
@@ -14,6 +14,7 @@ const { db, stmts } = require("../db");
const { sameOriginGuard } = require("../lib/same-origin");
const { calculateCost, attachAgentCosts } = require("./pricing");
const { sessionStats } = require("./sessions");
const { bySessionWhere } = require("../lib/scope");
const router = Router();
@@ -106,8 +107,10 @@ function capture(sessionId, redactPaths) {
};
}
// GET /api/snapshots — every snapshot, newest first, without its payload.
router.get("/", (_req, res) => {
// GET /api/snapshots — every snapshot, newest first, without its payload; a
// server-mode member gets only the snapshots of their own sessions.
router.get("/", (req, res) => {
const own = bySessionWhere(req);
const snapshots = db
.prepare(
`SELECT id, token, session_id, created_at,
@@ -115,9 +118,9 @@ router.get("/", (_req, res) => {
json_extract(payload, '$.redacted.file_paths') AS redacted,
json_array_length(payload, '$.events') AS event_count,
length(payload) AS bytes
FROM session_snapshots ORDER BY id DESC`
FROM session_snapshots WHERE ${own.where} ORDER BY id DESC`
)
.all()
.all(...own.params)
.map((s) => ({ ...s, redacted: s.redacted === 1 }));
res.json({ snapshots });
});
+73
View File
@@ -0,0 +1,73 @@
/**
* @file users.js
* @description `/api/users` — server mode's per-user view for an admin or
* analyst (members are refused by STAFF_ONLY_PREFIXES in server/lib/auth.js):
* `GET /` lists every account with its workspaces, sessions and $ spent in the
* last 7 and 30 days (from `token_usage_hourly`, sessions owned through their
* workspace — server/lib/scope.js); `GET /audit` the newest audit entries
* (server/lib/audit.js). Read-only: accounts are managed from the host's shell.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { Router } = require("express");
const { db, stmts } = require("../db");
const { listUsers } = require("../lib/auth");
const { sessionWhere } = require("../lib/scope");
const { listAudit } = require("../lib/audit");
const router = Router();
const USAGE_COLS = [
"input_tokens",
"output_tokens",
"cache_read_tokens",
"cache_write_tokens",
"cache_write_1h_tokens",
"web_search_requests",
"web_fetch_requests",
"code_execution_requests",
];
/** $ and tokens a member's sessions spent since `sinceHour` (UTC `YYYY-MM-DDTHH`). */
function usageSince(userId, sinceHour, rules) {
const { calculateCost } = require("./pricing");
const w = sessionWhere({ user: { id: userId, role: "member" } }, "s.id", "s.lane_id");
const rows = db
.prepare(
`SELECT h.model, h.speed, h.inference_geo, h.service_tier,
${USAGE_COLS.map((c) => `SUM(h.${c}) AS ${c}`).join(", ")}
FROM token_usage_hourly h JOIN sessions s ON s.id = h.session_id
WHERE h.hour >= ? AND ${w.where}
GROUP BY h.model, h.speed, h.inference_geo, h.service_tier`
)
.all(sinceHour, ...w.params);
const tokens = rows.reduce(
(n, r) => n + r.input_tokens + r.output_tokens + r.cache_read_tokens + r.cache_write_tokens,
0
);
return { cost: rows.length ? calculateCost(rows, rules).total_cost : 0, tokens };
}
const hourAgo = (days) => new Date(Date.now() - days * 864e5).toISOString().slice(0, 13);
router.get("/", (_req, res) => {
const rules = stmts.listPricing.all();
const users = listUsers().map((u) => {
const w = sessionWhere({ user: { id: u.id, role: "member" } });
return {
...u,
workspaces: db.prepare("SELECT COUNT(*) AS n FROM lanes WHERE owner_id = ?").get(u.id).n,
sessions: db.prepare(`SELECT COUNT(*) AS n FROM sessions WHERE ${w.where}`).get(...w.params)
.n,
last7d: usageSince(u.id, hourAgo(7), rules),
last30d: usageSince(u.id, hourAgo(30), rules),
};
});
res.json({ users });
});
router.get("/audit", (req, res) => {
res.json({ entries: listAudit(req.query.limit) });
});
module.exports = router;
+48 -4
View File
@@ -1,5 +1,5 @@
/**
* @file Express router for workflow intelligence endpoints, providing aggregated insights into workflow orchestration, tool usage patterns, subagent effectiveness, error propagation, concurrency, and session complexity. It queries the database for various metrics and patterns related to agents, sessions, and events, and returns a comprehensive JSON response for frontend visualization on the dashboard. The GET / bundle is computed by `server/lib/workflow-stats.js` in the read worker (`server/lib/read-pool.js`), off the event loop.
* @file Express router for workflow intelligence endpoints, providing aggregated insights into workflow orchestration, tool usage patterns, subagent effectiveness, error propagation, concurrency, and session complexity. It queries the database for various metrics and patterns related to agents, sessions, and events, and returns a comprehensive JSON response for frontend visualization on the dashboard. The GET / bundle is computed by `server/lib/workflow-stats.js` in the read worker (`server/lib/read-pool.js`), off the event loop. In server mode a member reads only their own sessions (`member` in the filter, `server/lib/scope.js`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -8,6 +8,7 @@ const { db, stmts } = require("../db");
const { runRead } = require("../lib/read-pool");
const { WORKFLOW_SECTIONS, sessionIdFilter } = require("../lib/workflow-stats");
const { parseTool, BAD_TOOL, parseRange, BAD_RANGE } = require("../lib/source-filter");
const { memberId, bySessionWhere } = require("../lib/scope");
const router = Router();
@@ -18,7 +19,7 @@ const router = Router();
router.get("/", async (req, res) => {
// Optional filters: status ("active", "completed", or omit for all), the AI
// tool (`?tool=`, `sessions.tool`) and `?from=&to=` (sessions started in
// those local days).
// those local days). A server-mode member reads only their own sessions.
const statusFilter = req.query.status || null;
const tool = parseTool(req);
if (tool === false) return res.status(400).json(BAD_TOOL);
@@ -40,7 +41,13 @@ router.get("/", async (req, res) => {
res.json(
await runRead(
"workflows",
{ status: statusFilter, tool, range, sections },
{
status: statusFilter,
tool,
range,
sections,
member: memberId(req),
},
// Every open Workflows page shares one run (~1.5 s on a large DB).
{ maxAgeMs: 30_000 }
)
@@ -72,7 +79,12 @@ router.get("/skill-usage", (req, res) => {
if (tool === false) return res.status(400).json(BAD_TOOL);
const range = parseRange(req);
if (range === false) return res.status(400).json(BAD_RANGE);
const sf = sessionIdFilter({ status: req.query.status || null, tool, range });
const sf = sessionIdFilter({
status: req.query.status || null,
tool,
range,
member: memberId(req),
});
const skills = db
.prepare(
@@ -233,6 +245,7 @@ function hydrateWorkflow(row) {
}
// GET /runs — list workflow runs (filter by status or session_id), paginated.
// A server-mode member lists and counts only runs of their own sessions.
router.get("/runs", (req, res) => {
try {
const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 50, 1), 1000);
@@ -240,6 +253,9 @@ router.get("/runs", (req, res) => {
const status = req.query.status && req.query.status !== "all" ? req.query.status : null;
const sessionId = req.query.session_id || null;
if (memberId(req) != null)
return res.json(memberRuns(req, { limit, offset, status, sessionId }));
let rows;
if (sessionId) {
rows = stmts.listWorkflowsBySessionFilter.all(sessionId, limit, offset);
@@ -261,6 +277,34 @@ router.get("/runs", (req, res) => {
}
});
/** GET /runs for a member: the same answer over their own sessions' runs only. */
function memberRuns(req, { limit, offset, status, sessionId }) {
const w = bySessionWhere(req);
const order = "ORDER BY COALESCE(started_at, created_at) DESC LIMIT ? OFFSET ?";
const rows = sessionId
? db
.prepare(`SELECT * FROM workflows WHERE session_id = ? AND ${w.where} ${order}`)
.all(sessionId, ...w.params, limit, offset)
: status
? db
.prepare(`SELECT * FROM workflows WHERE status = ? AND ${w.where} ${order}`)
.all(status, ...w.params, limit, offset)
: db
.prepare(`SELECT * FROM workflows WHERE ${w.where} ${order}`)
.all(...w.params, limit, offset);
const total = db
.prepare(
`SELECT COUNT(*) AS n FROM workflows WHERE ${status ? "status = ? AND " : ""}${w.where}`
)
.get(...(status ? [status] : []), ...w.params).n;
const counts = {};
for (const r of db
.prepare(`SELECT status, COUNT(*) AS n FROM workflows WHERE ${w.where} GROUP BY status`)
.all(...w.params))
counts[r.status] = r.n;
return { runs: rows.map(hydrateWorkflow), total, counts, limit, offset };
}
// GET /runs/:runId — one run with its linked inner agents + their events.
router.get("/runs/:runId", (req, res) => {
try {
+83 -6
View File
@@ -5,8 +5,12 @@
const { WebSocketServer } = require("ws");
const { isHostAllowed, isWebSocketAuthorized } = require("./lib/security");
const { isWebSocketLoggedIn, isServerMode, userFromRequest } = require("./lib/auth");
const { broadcastAudience, audienceIncludes, canSeeRun } = require("./lib/scope");
let wss = null;
// How often an open terminal's login is re-checked in server mode.
const PTY_RECHECK_MS = Number(process.env.AGENTHUB_PTY_RECHECK_MS) || 10000;
function initWebSocket(server) {
// `noServer: true` + a manual, path-checked `server.on("upgrade", ...)`
@@ -22,6 +26,7 @@ function initWebSocket(server) {
verifyClient(info, done) {
if (!isHostAllowed(info.req.headers.host)) return done(false, 403, "host not allowed");
if (!isWebSocketAuthorized(info.req)) return done(false, 401, "unauthorized");
if (!isWebSocketLoggedIn(info.req)) return done(false, 401, "log in first");
return done(true);
},
});
@@ -42,7 +47,13 @@ function initWebSocket(server) {
});
});
wss.on("connection", (ws) => {
wss.on("connection", (ws, req) => {
// Server mode: who this socket belongs to, for broadcast filtering, and
// the cookie to re-check it by (a logout or a disabled user ends it).
if (isServerMode()) {
ws.user = userFromRequest(req);
ws.cookie = req.headers.cookie || "";
}
ws.isAlive = true;
ws.on("pong", () => {
ws.isAlive = true;
@@ -66,6 +77,13 @@ function initWebSocket(server) {
ws.terminate();
return;
}
if (ws.cookie !== undefined) {
ws.user = userFromRequest({ headers: { cookie: ws.cookie } });
if (!ws.user) {
ws.terminate();
return;
}
}
ws.isAlive = false;
ws.ping();
});
@@ -117,7 +135,12 @@ function initPtyWebSocket(server) {
const { attach } = require("./lib/pty-attach");
ptyWss = new WebSocketServer({ noServer: true, maxPayload: 1024 * 1024 });
ptyWss.on("connection", (ws, runId, size) => {
ptyWss.on("connection", (ws, runId, size, cookie) => {
// Server mode: who attached, re-checked on the beat below.
if (cookie !== undefined) {
ws.cookie = cookie;
ws.runId = runId;
}
try {
attach(ws, runId, size || { cols: 80, rows: 24 });
} catch (err) {
@@ -142,7 +165,12 @@ function initPtyWebSocket(server) {
socket.destroy();
return;
}
if (!isWebSocketAuthorized(req)) {
if (!isWebSocketAuthorized(req) || !isWebSocketLoggedIn(req)) {
socket.destroy();
return;
}
// Server mode: a member attaches only to a pane of their own workspace.
if (isServerMode() && !canSeeRun({ user: userFromRequest(req) }, match[1])) {
socket.destroy();
return;
}
@@ -153,19 +181,68 @@ function initPtyWebSocket(server) {
return;
}
ptyWss.handleUpgrade(req, socket, head, (ws) => {
ptyWss.emit("connection", ws, match[1], sizeFromQuery(url.searchParams));
ptyWss.emit(
"connection",
ws,
match[1],
sizeFromQuery(url.searchParams),
isServerMode() ? req.headers.cookie || "" : undefined
);
});
});
// Server mode: a terminal stays open only while its user may still use it —
// logged in, not disabled, not an analyst, still owning the pane's workspace.
const recheck = setInterval(() => {
if (!ptyWss) {
clearInterval(recheck);
return;
}
ptyWss.clients.forEach((ws) => {
if (ws.cookie === undefined) return;
const user = userFromRequest({ headers: { cookie: ws.cookie } });
if (!user || user.role === "analyst" || !canSeeRun({ user }, ws.runId)) {
try {
ws.close(1008, "log in again");
} catch {
/* already gone */
}
ws.terminate();
}
});
}, PTY_RECHECK_MS);
recheck.unref();
return ptyWss;
}
function broadcast(type, data) {
if (!wss) return;
const message = JSON.stringify({ type, data, timestamp: new Date().toISOString() });
wss.clients.forEach((client) => {
if (client.readyState === 1) {
// Server mode: worked out once, and only when a member is connected.
let audience;
const reaches = (client) => {
if (!client.user || client.user.role !== "member")
return !isServerMode() || Boolean(client.user);
if (audience === undefined) {
try {
audience = broadcastAudience(type, data);
} catch {
audience = { staffOnly: true };
}
}
return audienceIncludes(audience, client.user);
};
let stripped;
wss.clients.forEach((client) => {
if (client.readyState === 1 && reaches(client)) {
try {
const member = client.user && client.user.role === "member";
if (member && audience && audience.strip) {
stripped ??= JSON.stringify({ type, data: {}, timestamp: new Date().toISOString() });
client.send(stripped);
return;
}
client.send(message);
} catch {
// Client closed between readyState check and send — safe to ignore