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
+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}