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