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