Files
AgentHub/server/lib/scope.js
T
nntrivi2001 9a54978eef 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.
2026-10-07 14:11:25 +07:00

339 lines
13 KiB
JavaScript

/**
* @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,
};