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.
This commit is contained in:
2026-11-06 11:25:40 +07:00
parent 1a365a45bd
commit 879a6bb02d
24 changed files with 838 additions and 120 deletions
+156 -21
View File
@@ -66,7 +66,14 @@ function ownSessionWhere(req, col = "id", laneCol = "lane_id") {
}
// 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 = ?";
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: [] };
@@ -77,8 +84,13 @@ function ownedSessionWhere(uid, col, laneCol, shared = false) {
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 = ?))`,
params: [...lp, ...lp, uid, uid],
OR ${col} IN (SELECT session_id FROM (${ACCOUNT_OWNED}) WHERE uid = ?)${
shared
? `
OR ${col} IN (${MY_SESSIONS})`
: ""
})`,
params: [...lp, ...lp, uid, uid, ...(shared ? [uid] : [])],
};
}
@@ -94,7 +106,36 @@ function bySessionWhere(req, col = "session_id") {
function laneWhere(req, col = "id") {
const uid = memberId(req);
if (uid == null) return { where: "1", params: [] };
return { where: `${col} IN (${MY_LANES})`, params: [uid, uid] };
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. */
@@ -123,9 +164,7 @@ function canSeeLane(req, laneId) {
// 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 id, owner_id FROM lanes WHERE id = ?").get(v);
return Boolean(row) && (row.owner_id === uid || sharedWith(row.id, uid));
return laneAccess(req, laneId) != null;
}
function canSeeSession(req, sessionId) {
@@ -146,7 +185,13 @@ function canSeeAgent(req, agentId) {
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]);
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);
}
@@ -155,9 +200,9 @@ function canSeeRun(req, runId) {
function visibleLaneIds(req) {
const uid = memberId(req);
if (uid == null) return null;
return sql(MY_LANES)
.all(uid, uid)
.map((r) => r.id);
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. */
@@ -170,8 +215,11 @@ function canSeeRowSession(req, table, rowId, idCol = "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);
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));
}
/**
@@ -272,6 +320,28 @@ function scopeGuard(req, res, next) {
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
@@ -292,6 +362,54 @@ function scopeGuard(req, res, next) {
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);
@@ -329,12 +447,23 @@ function laneOwners(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". */
function laneViewers(laneId) {
/**
* 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 = ?")
.all(Number(laneId))
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)
);
}
@@ -346,9 +475,10 @@ function sessionViewers(sessionId) {
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 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))
.all(String(sessionId), String(sessionId), String(sessionId))
.map((r) => r.user_id)
);
}
@@ -380,7 +510,10 @@ function broadcastAudience(type, data) {
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) };
return {
owners: laneOwners(d.lane?.id),
viewers: laneViewers(d.lane?.id, { sessions: true }),
};
case "lane_hook_output":
case "lane_hook_result":
case "lane_runtime":
@@ -433,7 +566,7 @@ function audienceIncludes(audience, user) {
if (audience === null || audience.strip) return true;
return Boolean(
(audience.owners && audience.owners.has(user.id)) ||
(audience.viewers && audience.viewers.has(user.id))
(audience.viewers && audience.viewers.has(user.id))
);
}
@@ -556,6 +689,8 @@ module.exports = {
PATH_RULES,
canSeeRowSession,
canSeeRunRow,
laneAccess,
sessionSharedWith,
folderAllowed,
ownFolder,
memberId,