9a54978eef
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.
339 lines
13 KiB
JavaScript
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,
|
|
};
|