fix(server-mode): close the gaps the review of workspace sharing found

- A Terminal tab a collaborator opens records them (dashboard_runs.started_by):
  its pane token acts as them — and only while still shared with them —
  never as the workspace owner (userFromPaneToken).
- POST /api/run always starts the lane's AI tool: a collaborator gets
  ECOLLABSTART whatever the body says (shell: true no longer slips past).
- A collaborator disabled since no longer locks the share list: kept on it
  (shown disabled) or taken off; only a new addition must be enabled.
- The workspace home tells a collaborator to open an existing session and
  keeps Start disabled instead of failing on every submit.
This commit is contained in:
2026-11-06 10:48:29 +07:00
parent 0bf552b793
commit 54001585e2
16 changed files with 201 additions and 31 deletions
+77 -7
View File
@@ -5,8 +5,10 @@
* the list (`GET/PUT /api/lanes/:id/shares`, audited, enabled users only, never
* the owner); the user it is shared with sees the workspace and its sessions
* but may not rename, reset, remove or delete it (`ENOTOWNER`) and starts no AI
* session in it yet (`ECOLLABSTART`); a user it is not shared with still gets
* 404; taking it back ends their access at once.
* session in it yet (`ECOLLABSTART`, `/api/run` whatever the body says); a
* Terminal tab they open acts as THEM through its pane token, never as the
* owner; a collaborator disabled since does not block the list; a user it is
* not shared with still gets 404; taking it back ends their access at once.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -83,8 +85,7 @@ after(() => {
}
});
const laneIds = async (as) =>
(await call("GET", "/api/lanes", { as })).body.lanes.map((l) => l.id);
const laneIds = async (as) => (await call("GET", "/api/lanes", { as })).body.lanes.map((l) => l.id);
describe("sharing a workspace", () => {
it("is managed only by its owner (or an admin), for enabled users other than the owner", async () => {
@@ -127,7 +128,9 @@ describe("sharing a workspace", () => {
"ENOTOWNER"
);
assert.equal(
code(await call("POST", `/api/lanes/${laneA}/remove`, { as: "bob", body: { confirm: true } })),
code(
await call("POST", `/api/lanes/${laneA}/remove`, { as: "bob", body: { confirm: true } })
),
"ENOTOWNER"
);
assert.equal(code(await call("DELETE", `/api/lanes/${laneA}`, { as: "bob" })), "ENOTOWNER");
@@ -167,6 +170,72 @@ describe("sharing a workspace", () => {
});
assert.equal(run.status, 403);
assert.equal(run.body.error.code, "ECOLLABSTART");
// /api/run has no shell: `shell: true` does not get an AI session past it.
const shell = await call("POST", "/api/run", {
as: "bob",
body: { laneId: laneA, shell: true, initialPrompt: "hi" },
});
assert.equal(shell.body.error.code, "ECOLLABSTART");
});
it("makes a pane token act as the user who opened that pane, never the owner", () => {
const tmux = require("../lib/tmux");
const real = tmux.hasSession;
tmux.hasSession = () => true;
try {
const tokenFor = (runId, startedBy) => {
const cred = auth.newPaneToken();
db.prepare(
`INSERT OR REPLACE INTO dashboard_runs (id, mode, cwd, status, lane_id, tool, pane_token_hash, started_by)
VALUES (?, 'interactive', '/w/alice', 'running', ?, 'shell', ?, ?)`
).run(runId, laneA, cred.hash, startedBy);
return cred.token;
};
const asUser = (token) =>
auth.userFromPaneToken({
headers: { "x-agenthub-pane-token": token },
socket: { remoteAddress: "127.0.0.1" },
});
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");
// 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,
users.bob.id
);
assert.equal(asUser(bobs), null);
db.prepare("INSERT INTO lane_shares (lane_id, user_id) VALUES (?, ?)").run(
laneA,
users.bob.id
);
} finally {
tmux.hasSession = real;
db.prepare("DELETE FROM dashboard_runs WHERE lane_id = ?").run(laneA);
}
});
it("is not blocked by a collaborator disabled since", async () => {
const put = (userIds) =>
call("PUT", `/api/lanes/${laneA}/shares`, { as: "alice", body: { userIds } });
assert.equal((await put([users.bob.id, users.carol.id])).status, 200);
db.prepare("UPDATE users SET disabled = 1 WHERE id = ?").run(users.carol.id);
try {
const listed = await call("GET", `/api/lanes/${laneA}/shares`, { as: "alice" });
assert.equal(listed.body.shares.find((u) => u.id === users.carol.id)?.disabled, true);
// Kept on the list while another change is saved, then taken off.
assert.equal((await put([users.bob.id, users.carol.id])).status, 200);
const r = await put([users.bob.id]);
assert.equal(r.status, 200);
assert.deepEqual(
r.body.shares.map((u) => u.username),
["bob"]
);
// But never newly added while disabled.
assert.equal((await put([users.bob.id, users.carol.id])).body.error.code, "EBADUSER");
} finally {
db.prepare("UPDATE users SET disabled = 0 WHERE id = ?").run(users.carol.id);
}
});
it("ends their access as soon as it is taken back, and says so in the audit log", async () => {
@@ -179,8 +248,9 @@ describe("sharing a workspace", () => {
assert.ok(!(await laneIds("bob")).includes(laneA));
assert.equal((await call("GET", "/api/sessions/sA", { as: "bob" })).status, 404);
assert.equal(
db.prepare("SELECT COUNT(*) AS n FROM audit_log WHERE path = ?").get(`unshare workspace ${laneA}`)
.n,
db
.prepare("SELECT COUNT(*) AS n FROM audit_log WHERE path = ? AND owner_id = ?")
.get(`unshare workspace ${laneA}`, users.bob.id).n,
1
);
});