Files
AgentHub/server/lib/scope.js
T
nntrivi2001 879a6bb02d feat(server-mode): share one session; scope collaborators' pane tokens (sharing phase 3)
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.
2026-11-06 11:25:40 +07:00

712 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @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,
};