54001585e2
- 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.
274 lines
10 KiB
JavaScript
274 lines
10 KiB
JavaScript
/**
|
|
* @file lane-shares.test.js
|
|
* @description Sharing a workspace with other users (server mode,
|
|
* docs/plans/workspace-sharing/ phase 1): only the owner or an admin manages
|
|
* 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`, `/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>
|
|
*/
|
|
|
|
const { describe, it, before, after } = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
const path = require("path");
|
|
const fs = require("fs");
|
|
const os = require("os");
|
|
|
|
const TEST_DB = path.join(os.tmpdir(), `lane-shares-${Date.now()}-${process.pid}.db`);
|
|
process.env.DASHBOARD_DB_PATH = TEST_DB;
|
|
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
|
|
|
const { createApp, startServer } = require("../index");
|
|
const { db } = require("../db");
|
|
const auth = require("../lib/auth");
|
|
|
|
let server;
|
|
let BASE;
|
|
const cookies = {};
|
|
const users = {};
|
|
let laneA;
|
|
|
|
async function call(method, p, { as, body } = {}) {
|
|
const res = await fetch(BASE + p, {
|
|
method,
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Origin: BASE,
|
|
...(as ? { cookie: cookies[as] } : {}),
|
|
},
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
return { status: res.status, body: await res.json().catch(() => null) };
|
|
}
|
|
|
|
before(async () => {
|
|
server = await startServer(createApp(), 0);
|
|
BASE = `http://127.0.0.1:${server.address().port}`;
|
|
users.alice = await auth.createUser("alice", "alice-pass-1", "developer");
|
|
users.bob = await auth.createUser("bob", "bob-pass-1", "developer");
|
|
users.carol = await auth.createUser("carol", "carol-pass-1", "developer");
|
|
users.boss = await auth.createUser("boss", "admin-pass-1", "admin");
|
|
laneA = Number(
|
|
db
|
|
.prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES ('/w/alice', 'claude', ?)")
|
|
.run(users.alice.id).lastInsertRowid
|
|
);
|
|
db.prepare(
|
|
"INSERT INTO sessions (id, name, status, cwd, lane_id) VALUES ('sA', 'alice one', 'active', '/w/alice', ?)"
|
|
).run(laneA);
|
|
process.env.AGENTHUB_MODE = "server";
|
|
for (const [name, pass] of [
|
|
["alice", "alice-pass-1"],
|
|
["bob", "bob-pass-1"],
|
|
["carol", "carol-pass-1"],
|
|
["boss", "admin-pass-1"],
|
|
]) {
|
|
const r = await fetch(BASE + "/api/auth/login", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ username: name, password: pass }),
|
|
});
|
|
assert.equal(r.status, 200);
|
|
cookies[name] = r.headers.get("set-cookie").split(";")[0];
|
|
}
|
|
});
|
|
|
|
after(() => {
|
|
delete process.env.AGENTHUB_MODE;
|
|
server?.close();
|
|
for (const f of [TEST_DB, `${TEST_DB}-wal`, `${TEST_DB}-shm`, `${TEST_DB}-migrate.lock`]) {
|
|
fs.rmSync(f, { force: true });
|
|
}
|
|
});
|
|
|
|
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 () => {
|
|
assert.equal((await call("GET", `/api/lanes/${laneA}/shares`, { as: "bob" })).status, 404);
|
|
const put = (as, userIds) =>
|
|
call("PUT", `/api/lanes/${laneA}/shares`, { as, body: { userIds } });
|
|
assert.equal((await put("bob", [users.bob.id])).status, 404);
|
|
assert.equal((await put("alice", [users.alice.id])).body.error.code, "EBADUSER");
|
|
assert.equal((await put("alice", [9999])).body.error.code, "EBADUSER");
|
|
assert.equal((await put("alice", "bob")).body.error.code, "EBADUSER");
|
|
|
|
const r = await put("alice", [users.bob.id]);
|
|
assert.equal(r.status, 200);
|
|
assert.deepEqual(
|
|
r.body.shares.map((u) => u.username),
|
|
["bob"]
|
|
);
|
|
const audit = db
|
|
.prepare("SELECT user_id, owner_id, path FROM audit_log WHERE path LIKE 'share workspace%'")
|
|
.all();
|
|
assert.deepEqual(audit, [
|
|
{ user_id: users.alice.id, owner_id: users.bob.id, path: `share workspace ${laneA}` },
|
|
]);
|
|
});
|
|
|
|
it("shows the workspace and its sessions to the user it is shared with — and nobody else", async () => {
|
|
assert.ok((await laneIds("bob")).includes(laneA));
|
|
assert.equal((await call("GET", "/api/sessions/sA", { as: "bob" })).status, 200);
|
|
const shares = await call("GET", `/api/lanes/${laneA}/shares`, { as: "bob" });
|
|
assert.equal(shares.status, 200);
|
|
assert.equal(shares.body.canManage, false);
|
|
assert.ok(!(await laneIds("carol")).includes(laneA));
|
|
assert.equal((await call("GET", "/api/sessions/sA", { as: "carol" })).status, 404);
|
|
});
|
|
|
|
it("keeps rename, remove, delete and sharing the owner's", async () => {
|
|
const code = (r) => r.body?.error?.code;
|
|
assert.equal(
|
|
code(await call("PATCH", `/api/lanes/${laneA}`, { as: "bob", body: { title: "mine" } })),
|
|
"ENOTOWNER"
|
|
);
|
|
assert.equal(
|
|
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");
|
|
assert.equal(
|
|
code(
|
|
await call("PUT", `/api/lanes/${laneA}/shares`, {
|
|
as: "bob",
|
|
body: { userIds: [users.bob.id, users.carol.id] },
|
|
})
|
|
),
|
|
"ENOTOWNER"
|
|
);
|
|
// Notes are not owner-only: a collaborator writes them like the owner.
|
|
const notes = await call("PATCH", `/api/lanes/${laneA}`, { as: "bob", body: { notes: "hi" } });
|
|
assert.equal(notes.status, 200);
|
|
// The owner and an admin still rename it.
|
|
assert.equal(
|
|
(await call("PATCH", `/api/lanes/${laneA}`, { as: "alice", body: { title: "A" } })).status,
|
|
200
|
|
);
|
|
assert.equal(
|
|
(await call("PATCH", `/api/lanes/${laneA}`, { as: "boss", body: { title: "B" } })).status,
|
|
200
|
|
);
|
|
});
|
|
|
|
it("starts no AI session for the user it is shared with yet", async () => {
|
|
const r = await call("POST", `/api/lanes/${laneA}/start`, {
|
|
as: "bob",
|
|
body: { slot: "auto", prompt: "hi" },
|
|
});
|
|
assert.equal(r.status, 403);
|
|
assert.equal(r.body.error.code, "ECOLLABSTART");
|
|
const run = await call("POST", "/api/run", {
|
|
as: "bob",
|
|
body: { laneId: laneA, prompt: "hi" },
|
|
});
|
|
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 () => {
|
|
const r = await call("PUT", `/api/lanes/${laneA}/shares`, {
|
|
as: "alice",
|
|
body: { userIds: [] },
|
|
});
|
|
assert.equal(r.status, 200);
|
|
assert.deepEqual(r.body.shares, []);
|
|
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 = ? AND owner_id = ?")
|
|
.get(`unshare workspace ${laneA}`, users.bob.id).n,
|
|
1
|
|
);
|
|
});
|
|
|
|
it("goes with the workspace when it is deleted", async () => {
|
|
await call("PUT", `/api/lanes/${laneA}/shares`, {
|
|
as: "alice",
|
|
body: { userIds: [users.carol.id] },
|
|
});
|
|
assert.equal(
|
|
db.prepare("SELECT COUNT(*) AS n FROM lane_shares WHERE lane_id = ?").get(laneA).n,
|
|
1
|
|
);
|
|
assert.equal((await call("DELETE", `/api/lanes/${laneA}`, { as: "alice" })).status, 200);
|
|
assert.equal(
|
|
db.prepare("SELECT COUNT(*) AS n FROM lane_shares WHERE lane_id = ?").get(laneA).n,
|
|
0
|
|
);
|
|
});
|
|
});
|