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