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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user