Files
AgentHub/server/__tests__/lane-shares.test.js
T
nntrivi2001 dea74b777e fix(server-mode): a session-only share resumes in a free slot only
A start that names no slot means pane 0; for a user with only a session of
the workspace that was let through and answered ERUNLIVE whenever another
user's agent held pane 0 (found by the browser run). The client already
sends slot: "auto"; the scope now requires it.
2026-11-06 11:45:25 +07:00

528 lines
22 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`); an AI session
* they start needs an account of their own (`ENOCOLLABACCOUNT`) and each hour
* of a session is billed to whoever ran it then (users page, quotas, Today); 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 a collaborator's AI session only on an account of their own", async () => {
// No pool account and no AI Proxy of theirs: refused, never the owner's login.
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "lane-shares-cwd-"));
db.prepare("UPDATE lanes SET cwd = ? WHERE id = ?").run(dir, laneA);
let r;
let run;
try {
r = await call("POST", `/api/lanes/${laneA}/start`, {
as: "bob",
body: { slot: "auto", prompt: "hi" },
});
run = await call("POST", "/api/run", {
as: "bob",
body: { laneId: laneA, initialPrompt: "hi" },
});
} finally {
db.prepare("UPDATE lanes SET cwd = '/w/alice' WHERE id = ?").run(laneA);
fs.rmSync(dir, { recursive: true, force: true });
}
assert.equal(r.status, 409, JSON.stringify(r.body));
assert.equal(r.body.error.code, "ENOCOLLABACCOUNT");
assert.equal(run.status, 409, JSON.stringify(run.body));
assert.equal(run.body.error.code, "ENOCOLLABACCOUNT");
});
it("bills each hour of a session to whoever ran it then", async () => {
const payers = require("../lib/session-payers");
const MODEL = "claude-sonnet-4-5-20250929";
const hour = (h) => new Date(Date.now() - h * 3600e3).toISOString().slice(0, 13);
const spend = (sid, h, tokens) => {
db.prepare(
`INSERT INTO token_usage (session_id, model, output_tokens) VALUES (?, ?, ?)
ON CONFLICT DO UPDATE SET output_tokens = output_tokens + excluded.output_tokens`
).run(sid, MODEL, tokens);
db.prepare(
"INSERT INTO token_usage_hourly (session_id, hour, model, output_tokens) VALUES (?, ?, ?, ?)"
).run(sid, hour(h), MODEL, tokens);
};
const started = new Date(Date.now() - 3 * 3600e3).toISOString();
db.prepare(
`INSERT INTO sessions (id, name, status, cwd, lane_id, started_at, updated_at)
VALUES ('sPaid', 'paid', 'active', '/w/alice', ?, ?, ?)`
).run(laneA, started, new Date().toISOString());
spend("sPaid", 2, 1_000_000); // alice's hours
// Bob resumes it now: his from the next hour on.
const runIns = (id, by) =>
db
.prepare(
`INSERT OR REPLACE INTO dashboard_runs (id, session_id, mode, cwd, status, lane_id, tool, started_by, started_at)
VALUES (?, 'sPaid', 'interactive', '/w/alice', 'completed', ?, 'claude', ?, ?)`
)
.run(id, laneA, by, new Date().toISOString());
runIns("agenthub-lane-x-bob", users.bob.id);
const now = Date.now();
payers.noteRun("agenthub-lane-x-bob", now);
assert.deepEqual(
db
.prepare(
"SELECT from_hour, user_id FROM session_payers WHERE session_id = 'sPaid' ORDER BY from_hour"
)
.all(),
[
{ from_hour: "0000", user_id: users.alice.id },
{ from_hour: new Date(now + 3600e3).toISOString().slice(0, 13), user_id: users.bob.id },
]
);
// Noting the same payer again changes nothing.
payers.noteRun("agenthub-lane-x-bob", now);
assert.equal(
db.prepare("SELECT COUNT(*) AS n FROM session_payers WHERE session_id = 'sPaid'").get().n,
2
);
// Book bob's hour as the next one (a test cannot wait an hour).
db.prepare(
"INSERT INTO token_usage_hourly (session_id, hour, model, output_tokens) VALUES ('sPaid', ?, ?, 2000000)"
).run(new Date(now + 3600e3).toISOString().slice(0, 13), MODEL);
db.prepare(
"UPDATE token_usage SET output_tokens = output_tokens + 2000000 WHERE session_id = 'sPaid'"
).run();
const resolve = payers.payerResolver();
assert.equal(resolve({ session_id: "sPaid", date: hour(2) }), users.alice.id);
assert.equal(
resolve({ session_id: "sPaid", date: new Date(now + 3600e3).toISOString().slice(0, 13) }),
users.bob.id
);
// The users page: each sees only the hours they paid (2:1 tokens).
const details = async (u) =>
(await call("GET", `/api/users/${u.id}/details`, { as: "boss" })).body.usage.last7d;
const a = await details(users.alice);
const b = await details(users.bob);
assert.ok(a.cost > 0 && b.cost > 0, JSON.stringify({ a, b }));
assert.ok(Math.abs(b.cost / a.cost - 2) < 0.01, JSON.stringify({ a, b }));
const list = (await call("GET", "/api/users", { as: "boss" })).body.users;
const last7 = (name) => list.find((u) => u.username === name).last7d.cost;
assert.ok(Math.abs(last7("bob") / last7("alice") - 2) < 0.01, JSON.stringify(list));
// Spend quotas count the same hours.
const q = require("../lib/user-quota");
q.setUserQuota(users.bob.id, { mode: "custom", limit_usd: 1000, period: "day", tz_offset: 0 });
q.setUserQuota(users.alice.id, {
mode: "custom",
limit_usd: 1000,
period: "day",
tz_offset: 0,
});
const st = q.statusAll({ now: now + 3600e3 + 60e3, userIds: [users.alice.id, users.bob.id] });
assert.ok(Math.abs(st.get(users.bob.id).spend - b.cost) < 0.01, JSON.stringify([...st]));
q.setUserQuota(users.bob.id, { mode: "default" });
q.setUserQuota(users.alice.id, { mode: "default" });
// Today (`mine=1`) is each one's own money, never the other's hours.
const mine = async (as) =>
(await call("GET", "/api/pricing/cost?mine=1", { as })).body.total_cost;
assert.ok(Math.abs((await mine("bob")) - b.cost) < 0.01);
assert.ok(Math.abs((await mine("alice")) - a.cost) < 0.01);
db.prepare("DELETE FROM dashboard_runs WHERE id = 'agenthub-lane-x-bob'").run();
});
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");
// …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,
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
);
});
});
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("POST", `/api/lanes/${laneS}/start`, { resumeSessionId: "s1-shared" });
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",
slot: "auto",
});
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", slot: "auto" },
});
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);
});
});