879a6bb02d
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.
524 lines
22 KiB
JavaScript
524 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("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);
|
|
});
|
|
});
|