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
+62 -6
View File
@@ -258,9 +258,36 @@ function validateProfile(body) {
// Latin look-alikes from other scripts, so "Vĩ" in Cyrillic still collides.
const CONFUSABLE = {
а: "a", в: "b", е: "e", к: "k", м: "m", н: "h", о: "o", р: "p", с: "c", т: "t",
у: "y", х: "x", і: "i", ј: "j", ԁ: "d", ѕ: "s", ӏ: "l", α: "a", β: "b", ε: "e",
η: "n", ι: "i", κ: "k", ν: "v", ο: "o", ρ: "p", τ: "t", υ: "u", χ: "x", "ı": "i",
а: "a",
в: "b",
е: "e",
к: "k",
м: "m",
н: "h",
о: "o",
р: "p",
с: "c",
т: "t",
у: "y",
х: "x",
і: "i",
ј: "j",
ԁ: "d",
ѕ: "s",
ӏ: "l",
α: "a",
β: "b",
ε: "e",
η: "n",
ι: "i",
κ: "k",
ν: "v",
ο: "o",
ρ: "p",
τ: "t",
υ: "u",
χ: "x",
ı: "i",
};
/**
@@ -519,6 +546,7 @@ function deleteUser(username) {
sql("DELETE FROM user_prefs WHERE user_key = ?").run(`u${u.id}`);
// Workspaces other users shared with them (lane_shares has no foreign key).
sql("DELETE FROM lane_shares WHERE user_id = ?").run(u.id);
sql("DELETE FROM session_shares WHERE user_id = ?").run(u.id);
require("./avatars").removeAvatar(u.id);
try {
sql("DELETE FROM push_subscriptions WHERE user_id = ?").run(u.id);
@@ -549,6 +577,9 @@ function assignWorkspace(laneId, username) {
if (r.changes === 0) throw err("ENOLANE", `no workspace ${laneId}`);
// Its new owner no longer needs it shared with them.
sql("DELETE FROM lane_shares WHERE lane_id = ? AND user_id = ?").run(Number(laneId), u.id);
sql(
"DELETE FROM session_shares WHERE user_id = ? AND session_id IN (SELECT id FROM sessions WHERE lane_id = ?)"
).run(u.id, Number(laneId));
}
/** Take a workspace back from the user who owns it: it is left with no owner
@@ -741,7 +772,7 @@ function userFromPaneToken(req) {
if (typeof t !== "string" || !t.startsWith("ahpane_") || t.length > 200) return null;
if (!isLocalRequest(req)) return null;
const row = sql(
`SELECT r.id, r.lane_id, r.started_by, l.owner_id FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id
`SELECT r.id, r.lane_id, r.session_id, r.started_by, l.owner_id FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id
WHERE r.pane_token_hash = ? AND r.status IN ('running', 'spawning') AND instr(r.id, '@') = 0`
).get(sha256(t));
if (!row || row.owner_id == null) return null;
@@ -754,11 +785,33 @@ function userFromPaneToken(req) {
if (
uid !== row.owner_id &&
user.role !== "admin" &&
!require("./scope").sharedWith(row.lane_id, uid)
!require("./scope").sharedWith(row.lane_id, uid) &&
!require("./scope").sessionSharedWith(row.session_id, uid)
) {
return null;
}
return publicUser(user) || null;
const pub = publicUser(user);
if (!pub) return null;
// A collaborator's pane runs as the OWNER's Linux account, which can read
// its token: it acts as the collaborator inside this workspace only
// (`paneLane`, authGuard), never across their account.
return uid !== row.owner_id ? { ...pub, paneLane: row.lane_id } : pub;
}
/**
* What a collaborator's pane token may call — the CLI's workspace commands,
* nothing that starts or bills a session (the owner's account can read the
* token): reads of its own workspace (and the workspace list, which answers
* that one only), health, and its stage / stack / hook / feature writes.
*/
function paneLaneAllows(method, lp, laneId) {
const read = method === "GET" || method === "HEAD";
if (lp === "/health") return true;
if (read && /^\/(?:lanes|workspaces)\/?$/i.test(lp)) return true;
const m = /^\/(?:lanes|workspaces)\/(\d+)(\/.*)?$/i.exec(lp);
if (!m || Number(m[1]) !== laneId) return false;
if (read) return true;
return /^\/(stage|up|down|hook\/[^/]+|features\/activate|mcp\/sync)$/i.test(m[2] || "");
}
// ── Gates ───────────────────────────────────────────────────────────────────
@@ -814,6 +867,9 @@ function authGuard(req, res, next) {
const cookieUser = userFromRequest(req);
const user = cookieUser || userFromPaneToken(req);
if (!user) return deny(res, 401, "EAUTH", "log in first");
if (!cookieUser && user.paneLane != null && !paneLaneAllows(req.method, lp, user.paneLane)) {
return deny(res, 403, "EPANESCOPE", "this pane's token acts only inside its own workspace");
}
req.user = user;
// A password an admin gave: the login may only read its own account and
// pick its own password until it has.