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
+150
View File
@@ -299,6 +299,35 @@ describe("sharing a workspace", () => {
assert.equal(asUser(tokenFor(`agenthub-lane-${laneA}-1`, null))?.username, "alice");
const bobs = tokenFor(`agenthub-lane-${laneA}-2`, users.bob.id);
assert.equal(asUser(bobs)?.username, "bob");
// …inside this workspace only: the owner's account can read that token.
assert.equal(asUser(bobs)?.paneLane, laneA);
const guard = (p, method = "GET") => {
let out = null;
auth.authGuard(
{
path: p,
method,
headers: { "x-agenthub-pane-token": bobs },
socket: { remoteAddress: "127.0.0.1" },
},
{
status: (code) => ({ json: (body) => (out = { code, body }) }),
},
() => (out = "next")
);
return out;
};
assert.equal(guard(`/lanes/${laneA}`), "next");
assert.equal(guard(`/workspaces/${laneA}/git`), "next");
assert.equal(guard("/sessions").body.error.code, "EPANESCOPE");
assert.equal(guard(`/lanes/${laneA + 1000}`).body.error.code, "EPANESCOPE");
assert.equal(guard("/me").body.error.code, "EPANESCOPE");
// The CLI finds its workspace in the list (which answers that one only)…
assert.equal(guard("/workspaces"), "next");
// …and never starts or bills a session with it.
assert.equal(guard(`/lanes/${laneA}/start`, "POST").body.error.code, "EPANESCOPE");
assert.equal(guard(`/lanes/${laneA}/reset`, "POST").body.error.code, "EPANESCOPE");
assert.equal(guard("/run", "POST").body.error.code, "EPANESCOPE");
// No longer shared with him: his pane's token is nobody.
db.prepare("DELETE FROM lane_shares WHERE lane_id = ? AND user_id = ?").run(
laneA,
@@ -371,3 +400,124 @@ describe("sharing a workspace", () => {
);
});
});
describe("sharing one session", () => {
let laneS;
before(() => {
laneS = Number(
db
.prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES ('/w/alice2', 'claude', ?)")
.run(users.alice.id).lastInsertRowid
);
for (const id of ["s1-shared", "s2-private"]) {
db.prepare(
"INSERT INTO sessions (id, name, status, cwd, lane_id) VALUES (?, ?, 'completed', '/w/alice2', ?)"
).run(id, id, laneS);
}
});
const put = (as, userIds, sid = "s1-shared") =>
call("PUT", `/api/lanes/${laneS}/sessions/${sid}/shares`, { as, body: { userIds } });
it("is managed by the workspace's owner only, for a session of that workspace", async () => {
assert.equal((await put("carol", [users.carol.id])).status, 404);
assert.equal((await put("alice", [users.carol.id], "sA")).status, 404, "not this workspace's");
const r = await put("alice", [users.carol.id]);
assert.equal(r.status, 200);
assert.deepEqual(
r.body.shares.map((u) => u.username),
["carol"]
);
assert.equal(
db.prepare("SELECT COUNT(*) AS n FROM audit_log WHERE path = 'share session s1-shared'").get()
.n,
1
);
});
it("shows that session and its workspace — no other session of it", async () => {
const lanes = (await call("GET", "/api/lanes", { as: "carol" })).body.lanes;
const mine = lanes.find((l) => l.id === laneS);
assert.equal(mine?.access, "session");
const list = await call("GET", `/api/sessions?lane=${laneS}`, { as: "carol" });
assert.deepEqual(
list.body.sessions.map((s) => s.id),
["s1-shared"]
);
assert.equal((await call("GET", "/api/sessions/s1-shared", { as: "carol" })).status, 200);
assert.equal((await call("GET", "/api/sessions/s2-private", { as: "carol" })).status, 404);
});
it("allows that session's pane, Changes and a resume of it — nothing else of the workspace", async () => {
const scope = require("../lib/scope");
const req = { user: { id: users.carol.id, role: "developer" }, method: "POST" };
const run = (id, sid) =>
db
.prepare(
`INSERT OR REPLACE INTO dashboard_runs (id, session_id, mode, cwd, status, lane_id, tool)
VALUES (?, ?, 'interactive', '/w/alice2', 'running', ?, 'claude')`
)
.run(id, sid, laneS);
run(`agenthub-lane-${laneS}-1`, "s1-shared");
run(`agenthub-lane-${laneS}-2`, "s2-private");
assert.equal(scope.canSeeRun(req, `agenthub-lane-${laneS}-1`), true);
assert.equal(scope.canSeeRun(req, `agenthub-lane-${laneS}-2`), false);
assert.equal(scope.canSeeRun(req, `agenthub-lane-${laneS}`), false, "no run there");
const refused = async (method, p, body) => {
const r = await call(method, p, { as: "carol", body });
assert.equal(r.status, 403, `${method} ${p} → ${r.status}`);
assert.equal(r.body.error.code, "ESESSIONSHARE");
};
await refused("PATCH", `/api/lanes/${laneS}`, { title: "x" });
await refused("GET", `/api/lanes/${laneS}/sources`);
await refused("PUT", `/api/lanes/${laneS}/instructions`, { instructions: "x" });
await refused("POST", `/api/lanes/${laneS}/start`, { slot: "auto", prompt: "new" });
await refused("POST", `/api/lanes/${laneS}/start`, { shell: true });
await refused("POST", `/api/lanes/${laneS}/start`, {
resumeSessionId: "s1-shared",
forkSession: true,
});
await refused("POST", "/api/run", { laneId: laneS, initialPrompt: "hi" });
await refused("POST", `/api/lanes/${laneS}/start`, { resumeSessionId: "s1-shared", slot: 0 });
await refused("GET", `/api/lanes/${laneS}/git/tree?source=1`);
await refused("GET", `/api/artifacts/candidates?laneId=${laneS}`);
await refused("PATCH", "/api/sessions/s1-shared", { name: "mine now" });
// A shared session resumed only in its own workspace.
const laneT = Number(
db
.prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES ('/w/alice3', 'claude', ?)")
.run(users.alice.id).lastInsertRowid
);
db.prepare(
"INSERT INTO sessions (id, name, status, cwd, lane_id) VALUES ('sT-other', 't', 'completed', '/w/alice3', ?)"
).run(laneT);
db.prepare("INSERT INTO session_shares (session_id, user_id) VALUES ('sT-other', ?)").run(
users.carol.id
);
await refused("POST", `/api/lanes/${laneT}/start`, { resumeSessionId: "s1-shared" });
db.prepare("DELETE FROM session_shares WHERE session_id = 'sT-other'").run();
// Another session of it is not even there.
const other = await call("POST", `/api/lanes/${laneS}/start`, {
as: "carol",
body: { resumeSessionId: "s2-private" },
});
assert.equal(other.status, 404);
// A resume of the shared one gets past the scope (and then needs their own account).
const resume = await call("POST", `/api/lanes/${laneS}/start`, {
as: "carol",
body: { resumeSessionId: "s1-shared", prompt: "hi" },
});
assert.ok(![403, 404].includes(resume.status), JSON.stringify(resume.body));
// Reads of the workspace's git and its own row pass.
assert.equal((await call("GET", `/api/lanes/${laneS}`, { as: "carol" })).status, 200);
const git = await call("GET", `/api/lanes/${laneS}/git`, { as: "carol" });
assert.notEqual(git.status, 403);
db.prepare("DELETE FROM dashboard_runs WHERE lane_id = ?").run(laneS);
});
it("ends their access when it is taken back", async () => {
assert.equal((await put("alice", [])).status, 200);
assert.ok(!(await laneIds("carol")).includes(laneS));
assert.equal((await call("GET", "/api/sessions/s1-shared", { as: "carol" })).status, 404);
});
});