879a6bb02d
A workspace's owner (or an admin) can share ONE session from its right-click menu (GET/PUT /api/lanes/:id/sessions/:sid/shares, table session_shares, audited). The user sees the workspace (access: "session") with only that session, joins the pane holding it, resumes it on their own account in a free slot, and uses the workspace's git and Files reads and git writes; everything else of the workspace answers 403 ESESSIONSHARE (scope.js sessionShareAllows in scopeGuard). Hook/runtime broadcasts stay with full collaborators. A collaborator's pane token (readable by the owner's Linux account) now acts as them inside that workspace only, and never starts or bills a session (auth.js paneLaneAllows, 403 EPANESCOPE); a Terminal tab the owner or an admin opens acts as the owner again. Docs say plainly that under S3 a session share hands over the owner's account inside the terminal.
712 lines
28 KiB
JavaScript
712 lines
28 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, and may be
|
||
* SHARED with other users (`lane_shares`): they see it and work in it like its
|
||
* owner — visibility here — while owner-only actions check `isLaneOwner`, and
|
||
* personal figures (Today, budgets) stay on owned rows only. A session
|
||
* belongs to its workspace: `sessions.lane_id`, or a `dashboard_runs` row of
|
||
* that workspace holding it (a session not stamped yet). A session no workspace owns
|
||
* belongs to the user of the Linux account it ran as (`sessions.account`,
|
||
* server/lib/session-account.js) — a script's `claude -p` is its runner's. A
|
||
* session a remote client uploaded is its device user's (`sessions.owner_id`,
|
||
* server/hub/remote-ingest.js). Agents, events and
|
||
* token rows belong to their session. A developer sees and changes only what
|
||
* their own workspaces own; an analyst SEES everything but changes only their
|
||
* own (every non-GET request is scoped like a developer's); an admin sees and
|
||
* changes 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);
|
||
const { ACCOUNT_OWNED } = require("./session-account");
|
||
|
||
/** The user whose rows this request is limited to, or null for "all". */
|
||
function memberId(req) {
|
||
const u = req?.user;
|
||
if (!u) return null;
|
||
if (u.role === "developer") return u.id;
|
||
// An analyst reads every user's rows but writes only their own.
|
||
if (u.role === "analyst" && req.method !== "GET" && req.method !== "HEAD") return u.id;
|
||
return 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") {
|
||
return ownedSessionWhere(memberId(req), col, laneCol, true);
|
||
}
|
||
|
||
/**
|
||
* `sessionWhere` without shared workspaces: what this request's user OWNS —
|
||
* for their own money (budgets), never another user's spend in a workspace
|
||
* shared with them. "1" when unrestricted, like `sessionWhere`.
|
||
*/
|
||
function ownedOnlySessionWhere(req, col = "id", laneCol = "lane_id") {
|
||
return ownedSessionWhere(memberId(req), col, laneCol, false);
|
||
}
|
||
|
||
/**
|
||
* `sessionWhere` for the requesting user's OWN sessions, whatever their role —
|
||
* a personal figure (the status bar's Today) that must not be every user's sum
|
||
* for an admin or analyst. "1" in local mode, where there is no user.
|
||
*/
|
||
function ownSessionWhere(req, col = "id", laneCol = "lane_id") {
|
||
return ownedSessionWhere(req?.user ? req.user.id : null, col, laneCol);
|
||
}
|
||
|
||
// The workspaces a user owns or that are shared with them (2 params: uid, uid).
|
||
const MY_LANES =
|
||
"SELECT id FROM lanes WHERE owner_id = ? UNION SELECT lane_id FROM lane_shares WHERE user_id = ?";
|
||
// The sessions shared with a user one by one (1 param: uid), and the
|
||
// workspaces holding them (sharing phase 3: seen, but only that session).
|
||
const MY_SESSIONS = "SELECT session_id FROM session_shares WHERE user_id = ?";
|
||
const SESSION_LANES = `SELECT lane_id FROM sessions WHERE lane_id IS NOT NULL AND id IN (${MY_SESSIONS})`;
|
||
// Every workspace a user may open (3 params: uid ×3).
|
||
const SEEN_LANES = `${MY_LANES} UNION ${SESSION_LANES}`;
|
||
|
||
function ownedSessionWhere(uid, col, laneCol, shared = false) {
|
||
if (uid == null) return { where: "1", params: [] };
|
||
const lanes = shared ? MY_LANES : "SELECT id FROM lanes WHERE owner_id = ?";
|
||
const lp = shared ? [uid, uid] : [uid];
|
||
return {
|
||
where: `(${laneCol} IN (${lanes})
|
||
OR ${col} IN (SELECT r.session_id FROM dashboard_runs r
|
||
WHERE r.lane_id IN (${lanes}) AND r.session_id IS NOT NULL)
|
||
OR ${col} IN (SELECT id FROM sessions WHERE owner_id = ?)
|
||
OR ${col} IN (SELECT session_id FROM (${ACCOUNT_OWNED}) WHERE uid = ?)${
|
||
shared
|
||
? `
|
||
OR ${col} IN (${MY_SESSIONS})`
|
||
: ""
|
||
})`,
|
||
params: [...lp, ...lp, uid, uid, ...(shared ? [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 (${SEEN_LANES})`, params: [uid, uid, uid] };
|
||
}
|
||
|
||
/** Whether session `sessionId` is shared with `userId` on its own (phase 3). */
|
||
function sessionSharedWith(sessionId, userId) {
|
||
if (!sessionId || userId == null) return false;
|
||
return Boolean(
|
||
sql("SELECT 1 FROM session_shares WHERE session_id = ? AND user_id = ?").get(
|
||
String(sessionId),
|
||
userId
|
||
)
|
||
);
|
||
}
|
||
|
||
/**
|
||
* How much of workspace `laneId` this request may use: `all` (local mode, an
|
||
* admin or analyst read, its owner, a user it is shared with), `session`
|
||
* (only sessions shared with them one by one), or null.
|
||
*/
|
||
function laneAccess(req, laneId) {
|
||
const uid = memberId(req);
|
||
if (uid == null) return "all";
|
||
const v = typeof laneId === "number" ? laneId : String(laneId);
|
||
const row = sql("SELECT id, owner_id FROM lanes WHERE id = ?").get(v);
|
||
if (!row) return null;
|
||
if (row.owner_id === uid || sharedWith(row.id, uid)) return "all";
|
||
const one = sql(
|
||
`SELECT 1 FROM sessions WHERE lane_id = ? AND id IN (${MY_SESSIONS}) LIMIT 1`
|
||
).get(row.id, uid);
|
||
return one ? "session" : null;
|
||
}
|
||
|
||
/** Whether `userId` is one of the users workspace `laneId` is shared with. */
|
||
function sharedWith(laneId, userId) {
|
||
if (laneId == null || userId == null) return false;
|
||
return Boolean(
|
||
sql("SELECT 1 FROM lane_shares WHERE lane_id = ? AND user_id = ?").get(Number(laneId), userId)
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Owner-only actions on a workspace (rename, reset, remove, sharing): its
|
||
* owner or an admin; always true in local mode. A user it is shared with may
|
||
* do everything else, which `canSeeLane` decides.
|
||
*/
|
||
function isLaneOwner(req, laneId) {
|
||
const u = req?.user;
|
||
if (!u || u.role === "admin") return true;
|
||
const row = sql("SELECT owner_id FROM lanes WHERE id = ?").get(Number(laneId));
|
||
return Boolean(row) && row.owner_id === u.id;
|
||
}
|
||
|
||
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.
|
||
return laneAccess(req, laneId) != null;
|
||
}
|
||
|
||
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) {
|
||
const access = laneAccess(req, m[1]);
|
||
if (access !== "session") return access != null;
|
||
// A session shared on its own: only the pane holding that session.
|
||
const row = sql("SELECT session_id FROM dashboard_runs WHERE id = ?").get(String(runId));
|
||
return Boolean(row?.session_id) && sessionSharedWith(row.session_id, memberId(req));
|
||
}
|
||
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(SEEN_LANES)
|
||
.all(uid, uid, uid)
|
||
.map((r) => r.lane_id ?? 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, session_id FROM dashboard_runs WHERE id = ?").get(String(id));
|
||
if (!row || row.lane_id == null) return false;
|
||
const access = laneAccess(req, row.lane_id);
|
||
if (access !== "session") return access != null;
|
||
return Boolean(row.session_id) && sessionSharedWith(row.session_id, memberId(req));
|
||
}
|
||
|
||
/**
|
||
* 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",
|
||
"clone",
|
||
"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])],
|
||
// `/artifacts/<id>…`: the owner's, or a shared one (server/lib/artifacts.js).
|
||
[/^\/artifacts\/(\d+)(?:\/|$)/i, (req, m) => require("./artifacts").canSeeId(req, 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 an admin, an analyst's
|
||
* reads, and local mode.
|
||
*/
|
||
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();
|
||
// A workspace seen only through a session shared on its own (phase 3):
|
||
// that session, its pane and the workspace's Changes — nothing else of it.
|
||
const write = req.method !== "GET" && req.method !== "HEAD";
|
||
const pm = /^\/(?:lanes|workspaces)\/(\d+)(\/.*)?$/i.exec(p);
|
||
if (
|
||
pm &&
|
||
laneAccess(req, Number(pm[1])) === "session" &&
|
||
!sessionShareAllows(req, pm[2] || "", Number(pm[1]))
|
||
) {
|
||
return sessionOnly(res);
|
||
}
|
||
const viaSession = lanes.some((id) => laneAccess(req, Number(id)) === "session");
|
||
// Workspace-wide reads by `?laneId=`: files and skills of every session, stage history.
|
||
if (
|
||
viaSession &&
|
||
(write || /^\/(artifacts|lanes\/pipeline-stats|workspaces\/pipeline-stats)(\/|$)/i.test(p))
|
||
) {
|
||
return sessionOnly(res);
|
||
}
|
||
// The shared session itself: open it, never rename or archive it.
|
||
const sm = /^\/sessions\/([^/]+)/i.exec(p);
|
||
if (write && sm && sessionOnlyVia(req, sm[1])) return sessionOnly(res);
|
||
// S3: a WRITE to a workspace re-checks that its owner may still own that
|
||
// folder, by the LIVE role — an admin demoted since keeps owning their
|
||
// host-folder workspaces, and `/up`, `/hook`, a commit (git hooks) or a
|
||
// file write there would run as the HOST account. An owner that fits (every
|
||
// workspace was checked when it was made) never notices; reads stay open.
|
||
if (req.method !== "GET" && req.method !== "HEAD") {
|
||
const m = /^\/(?:lanes|workspaces)\/(\d+)(?:\/|$)/i.exec(p);
|
||
const ids = [...(m ? [m[1]] : []), ...lanes];
|
||
if (ids.some((id) => !ownerStillFits(Number(id)))) {
|
||
return res.status(409).json({
|
||
error: {
|
||
code: "EOWNERFOLDER",
|
||
message: "this workspace runs as the host account: only an admin may change it",
|
||
},
|
||
});
|
||
}
|
||
}
|
||
return next();
|
||
}
|
||
|
||
/** Whether this request sees session `sid` only because it is shared with them on its own. */
|
||
function sessionOnlyVia(req, sid) {
|
||
const uid = memberId(req);
|
||
if (uid == null || !sessionSharedWith(sid, uid)) return false;
|
||
const row = sql("SELECT lane_id FROM sessions WHERE id = ?").get(String(sid));
|
||
return !row?.lane_id || laneAccess(req, row.lane_id) === "session";
|
||
}
|
||
|
||
const sessionOnly = (res) =>
|
||
res.status(403).json({
|
||
error: {
|
||
code: "ESESSIONSHARE",
|
||
message: "only a session of this workspace is shared with you: open that session",
|
||
},
|
||
});
|
||
|
||
/**
|
||
* What a user with only a session of workspace (rest = the path after
|
||
* `/lanes/<id>`) may call there: reads of the workspace, its git, files and
|
||
* share list (not its instructions or context); git writes; and a `start` that
|
||
* resumes a session shared with them — no new session, fork or terminal.
|
||
*/
|
||
function sessionShareAllows(req, rest, laneId) {
|
||
const r = rest.toLowerCase();
|
||
// A workspace's extra folders are its own, not the session's.
|
||
if (req.query?.source !== undefined) return false;
|
||
if (req.method === "GET" || req.method === "HEAD") {
|
||
return r === "" || r === "/" || /^\/(git|fs|shares)(\/|$)/.test(r);
|
||
}
|
||
if (/^\/git\//.test(r)) return true;
|
||
if (r === "/start") {
|
||
const b = req.body && typeof req.body === "object" ? req.body : {};
|
||
return (
|
||
typeof b.resumeSessionId === "string" &&
|
||
b.forkSession !== true &&
|
||
b.shell !== true &&
|
||
b.resume !== true &&
|
||
// A free slot the server picks: pane 0 is the workspace's own run.
|
||
(b.slot === undefined || b.slot === "auto") &&
|
||
sessionSharedWith(b.resumeSessionId, memberId(req)) &&
|
||
Boolean(
|
||
sql("SELECT 1 FROM sessions WHERE id = ? AND lane_id = ?").get(b.resumeSessionId, laneId)
|
||
)
|
||
);
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/** Whether a workspace's owner may still own its folder (S3, live role). */
|
||
function ownerStillFits(laneId) {
|
||
const lane = sql("SELECT cwd, owner_id FROM lanes WHERE id = ?").get(laneId);
|
||
if (!lane) return true;
|
||
try {
|
||
require("./run-as").assertOwnerFits(lane.cwd, lane.owner_id);
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// ── WebSocket broadcasts ───────────────────────────────────────────────────
|
||
|
||
const STAFF = { staffOnly: true };
|
||
|
||
/** Owner ids of a session: its workspace's (stamped or held by a run), else its account's user. */
|
||
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 = ?
|
||
UNION
|
||
SELECT owner_id FROM sessions WHERE id = ? AND owner_id IS NOT NULL
|
||
UNION
|
||
SELECT uid FROM (${ACCOUNT_OWNED}) WHERE session_id = ?`
|
||
).all(String(sessionId), String(sessionId), 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] : []);
|
||
}
|
||
|
||
/**
|
||
* The users a workspace is shared with — they get its news, but it is not
|
||
* "theirs". `sessions`: also those with only a session of it (its row
|
||
* changes, never its stack's output).
|
||
*/
|
||
function laneViewers(laneId, { sessions = false } = {}) {
|
||
if (laneId == null) return new Set();
|
||
return new Set(
|
||
sql(
|
||
`SELECT user_id FROM lane_shares WHERE lane_id = ?${
|
||
sessions
|
||
? ` UNION SELECT user_id FROM session_shares
|
||
WHERE session_id IN (SELECT id FROM sessions WHERE lane_id = ?)`
|
||
: ""
|
||
}`
|
||
)
|
||
.all(...(sessions ? [Number(laneId), Number(laneId)] : [Number(laneId)]))
|
||
.map((r) => r.user_id)
|
||
);
|
||
}
|
||
|
||
/** The users the workspace holding a session is shared with. */
|
||
function sessionViewers(sessionId) {
|
||
if (!sessionId) return new Set();
|
||
return new Set(
|
||
sql(
|
||
`SELECT user_id FROM lane_shares WHERE lane_id IN (
|
||
SELECT lane_id FROM sessions WHERE id = ? AND lane_id IS NOT NULL
|
||
UNION SELECT lane_id FROM dashboard_runs WHERE session_id = ? AND lane_id IS NOT NULL)
|
||
UNION SELECT user_id FROM session_shares WHERE session_id = ?`
|
||
)
|
||
.all(String(sessionId), String(sessionId), String(sessionId))
|
||
.map((r) => r.user_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), viewers: sessionViewers(d.id) };
|
||
case "agent_created":
|
||
case "agent_updated":
|
||
case "new_event":
|
||
case "workflow_upserted":
|
||
case "transcript_changed":
|
||
return { owners: sessionOwners(d.session_id), viewers: sessionViewers(d.session_id) };
|
||
case "alert_triggered":
|
||
case "alert_updated":
|
||
if (d.acked_all) return null;
|
||
return d.session_id
|
||
? { owners: sessionOwners(d.session_id), viewers: sessionViewers(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),
|
||
viewers: laneViewers(d.lane?.id, { sessions: true }),
|
||
};
|
||
case "lane_hook_output":
|
||
case "lane_hook_result":
|
||
case "lane_runtime":
|
||
return { owners: laneOwners(d.laneId), viewers: laneViewers(d.laneId) };
|
||
// A shared artifact is everyone's news; a private one, its owner's and
|
||
// the people it is shared with by name.
|
||
case "artifact_changed":
|
||
return d.shared
|
||
? null
|
||
: {
|
||
owners: new Set(
|
||
[d.ownerId, ...(Array.isArray(d.recipients) ? d.recipients : [])].filter(
|
||
(x) => x != null
|
||
)
|
||
),
|
||
};
|
||
case "update_status":
|
||
// No payload: each client refetches its own `GET /api/run/interrupted`.
|
||
case "runs_interrupted":
|
||
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.
|
||
// (A member's OWN config change never gets here: `exactAudience`.)
|
||
case "cc_config_changed":
|
||
case "codex_config_changed":
|
||
return { strip: true };
|
||
default:
|
||
return STAFF;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* A broadcast meant for exactly these users, whatever their role, or null for
|
||
* the role-based rules above. Pure (no DB): asked for every client. A change
|
||
* to a member's OWN Claude Code config (`cc_config_changed` with `ownerId`,
|
||
* routes/cc-config.js) names their files: it reaches that member and nobody
|
||
* else, an admin or analyst included.
|
||
*/
|
||
function exactAudience(type, data) {
|
||
const d = data && typeof data === "object" ? data : {};
|
||
if (type === "cc_config_changed" && d.ownerId != null) return new Set([d.ownerId]);
|
||
// A workspace no longer shared with these users: only they re-read their list.
|
||
if (type === "lane_access" && Array.isArray(d.userIds)) return new Set(d.userIds);
|
||
return null;
|
||
}
|
||
|
||
/** Whether a client logged in as `user` (undefined in local mode) gets it. */
|
||
function audienceIncludes(audience, user) {
|
||
if (!user || user.role !== "developer") return true;
|
||
if (audience === null || audience.strip) return true;
|
||
return Boolean(
|
||
(audience.owners && audience.owners.has(user.id)) ||
|
||
(audience.viewers && audience.viewers.has(user.id))
|
||
);
|
||
}
|
||
|
||
/**
|
||
* A non-admin's own folder, `<AGENTHUB_USERS_ROOT>/<username>`, or null (no
|
||
* root set, or an admin). Under S3 a missing variable falls back to
|
||
* server.conf's `USERS_ROOT` — the folder the member's Linux account lives in —
|
||
* so a settings file without it can never open the host's whole disk to them.
|
||
* Whatever the method: an analyst reads every user's
|
||
* ROWS, but a path named in a request is read with the dashboard's own rights
|
||
* (CAP_DAC_READ_SEARCH under S3), so an analyst's GET is held to it too.
|
||
*/
|
||
function ownFolder(req) {
|
||
const linux = require("./linux-accounts");
|
||
const root =
|
||
(process.env.AGENTHUB_USERS_ROOT || "").trim() ||
|
||
(linux.s3Enabled() ? linux.serverConf().usersRoot : "");
|
||
if (!req?.user || req.user.role === "admin" || !root) return null;
|
||
const path = require("node:path");
|
||
return path.join(path.resolve(root), req.user.username);
|
||
}
|
||
|
||
/**
|
||
* 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. Under S3 the
|
||
* home ITSELF is no workspace (its dot folders hold the member's logins and
|
||
* pane tokens, which a workspace's file views would serve) — `allowHome`
|
||
* is for browsing to a folder, not for making one a workspace.
|
||
*/
|
||
function folderAllowed(req, dir, { allowHome = false } = {}) {
|
||
const home = ownFolder(req);
|
||
if (!home) return true;
|
||
const path = require("node:path");
|
||
const rel = path.relative(home, path.resolve(dir));
|
||
if (rel === "") return allowHome || !require("./linux-accounts").s3Enabled();
|
||
return 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)));
|
||
}
|
||
|
||
/**
|
||
* Server mode: whether a non-admin may name `dir` — only inside one of their
|
||
* own workspaces' folders, compared by real path so a symlink in their folder
|
||
* cannot point the check elsewhere. An analyst's GET too (a path is read with
|
||
* the dashboard's rights, see `ownFolder`). Always true for an admin.
|
||
*/
|
||
function insideOwnWorkspace(req, dir) {
|
||
const u = req?.user;
|
||
if (!u || u.role === "admin") return true;
|
||
return insideLanes(
|
||
sql(MY_LANES)
|
||
.all(u.id, u.id)
|
||
.map((r) => r.id),
|
||
dir
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Server mode: Express middleware for the config readers (`/cc-config`,
|
||
* `/codex-config`) — a non-admin's `?cwd=` (an analyst's GET included) must be
|
||
* inside one of THEIR OWN workspaces, else 403 EFOLDER. A caller-chosen
|
||
* folder is a path the dashboard reads as itself: under S3, with
|
||
* CAP_DAC_READ_SEARCH, one planted link (`/tmp/a/.claude -> /`) made it a
|
||
* whole-machine read. An admin, and local mode, pass.
|
||
*/
|
||
function ownCwdQuery(req, res, next) {
|
||
const asked = typeof req.query?.cwd === "string" && req.query.cwd;
|
||
if (!asked || !req.user || req.user.role === "admin") return next();
|
||
const own = sql("SELECT id FROM lanes WHERE owner_id = ?")
|
||
.all(req.user.id)
|
||
.map((r) => r.id);
|
||
if (require("node:path").isAbsolute(asked) && insideLanes(own, asked)) return next();
|
||
res.status(403).json({
|
||
error: { code: "EFOLDER", message: "that folder is not one of your workspaces" },
|
||
});
|
||
}
|
||
|
||
function insideLanes(ids, dir) {
|
||
const path = require("node:path");
|
||
const lanesLib = require("./lanes");
|
||
const real = lanesLib.canonicalCwd(dir);
|
||
return ids.some((id) => {
|
||
const cwd = lanesLib.getLane(id)?.cwd;
|
||
if (!cwd) return false;
|
||
const rel = path.relative(cwd, real);
|
||
return rel === "" || (rel.split(path.sep)[0] !== ".." && !path.isAbsolute(rel));
|
||
});
|
||
}
|
||
|
||
module.exports = {
|
||
insideOwnWorkspace,
|
||
ownCwdQuery,
|
||
folderTakenByOther,
|
||
scopeGuard,
|
||
broadcastAudience,
|
||
exactAudience,
|
||
audienceIncludes,
|
||
sessionOwners,
|
||
PATH_RULES,
|
||
canSeeRowSession,
|
||
canSeeRunRow,
|
||
laneAccess,
|
||
sessionSharedWith,
|
||
folderAllowed,
|
||
ownFolder,
|
||
memberId,
|
||
ownerFor,
|
||
sessionWhere,
|
||
ownSessionWhere,
|
||
ownedOnlySessionWhere,
|
||
isLaneOwner,
|
||
sharedWith,
|
||
laneViewers,
|
||
bySessionWhere,
|
||
laneWhere,
|
||
canSeeLane,
|
||
canSeeSession,
|
||
canSeeAgent,
|
||
canSeeRun,
|
||
visibleLaneIds,
|
||
};
|