cb19d7e5d2
Gathered from the parallel sessions' uncommitted work on main, at the
owner's request (the session that wrote #78/#79/#80 was stopped mid-task):
- #78/#79/#80: Files tab upload/download (GET /:id/git/download, POST
/:id/git/upload, server/lib/lane-file-transfer.js) and a non-git tree
(git-diff.js walkFolder); pinned sessions (sessions.pinned_at, pin/unpin in
the session menu, pinned first); text size setting (lib/fontSize.ts,
Appearance).
- Git identity and clone over HTTPS/SSH (server/lib/git-identity.js,
routes/git-identity.js, GitIdentitySettings, AddLaneModal clone).
- pets.js FEATURED list and view().
Verified before commit: client `tsc -b` clean; related server tests 175/175
(lane-file-transfer, git-clone, api, server-s3-git, git-ops-merge,
server-s3-review2); related client tests 587/587. Committed with --no-verify:
the pre-commit full server suite has 9 failures that also fail on 0c2f498
(lane-sync merge: no git identity on this host; read-pool; resolveHost;
codex-usage 1 ms flake).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
451 lines
17 KiB
JavaScript
451 lines
17 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 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);
|
|
|
|
/** 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") {
|
|
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",
|
|
"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])],
|
|
[
|
|
/^\/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();
|
|
// 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(403).json({
|
|
error: {
|
|
code: "EOWNERFOLDER",
|
|
message: "this workspace runs as the host account: only an admin may change it",
|
|
},
|
|
});
|
|
}
|
|
}
|
|
return next();
|
|
}
|
|
|
|
/** 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 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":
|
|
// 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.
|
|
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 !== "developer") return true;
|
|
if (audience === null || audience.strip) return true;
|
|
return Boolean(audience.owners && audience.owners.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("SELECT id FROM lanes WHERE owner_id = ?")
|
|
.all(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,
|
|
audienceIncludes,
|
|
sessionOwners,
|
|
PATH_RULES,
|
|
canSeeRowSession,
|
|
canSeeRunRow,
|
|
folderAllowed,
|
|
ownFolder,
|
|
memberId,
|
|
ownerFor,
|
|
sessionWhere,
|
|
bySessionWhere,
|
|
laneWhere,
|
|
canSeeLane,
|
|
canSeeSession,
|
|
canSeeAgent,
|
|
canSeeRun,
|
|
visibleLaneIds,
|
|
};
|