feat(server-mode): share a workspace with other users (sharing phase 1)

A workspace's owner (or an admin) shares it from its ⋯ menu → Share…
(lane_shares, GET/PUT /api/lanes/:id/shares, audited). The users it is shared
with see it, its sessions, Changes, history and live updates, join and type
into its open terminals, commit and push — every check through scope.js
(canSeeLane, sessionWhere, laneWhere, broadcastAudience viewers). Rename,
reset, remove, delete and sharing stay the owner's or an admin's
(403 ENOTOWNER), and a collaborator starts no AI session there yet
(403 ECOLLABSTART; a plain terminal tab is fine). Budgets and Today stay on
owned rows only (ownedOnlySessionWhere). Taking a share back sends lane_access
to those users and their terminals close at the next attach re-check.

Under S3 a collaborator works as the owner's Linux account; the Share dialog
says so plainly (docs/plans/workspace-sharing/, R1 option a).
This commit is contained in:
2026-11-06 10:43:16 +07:00
parent 045e46e111
commit 87ba935c6e
25 changed files with 1026 additions and 40 deletions
+203
View File
@@ -0,0 +1,203 @@
/**
* @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`); 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");
});
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 = ?").get(`unshare workspace ${laneA}`)
.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
);
});
});
+59
View File
@@ -308,3 +308,62 @@ describe("scopeGuard against the router's own leniency", () => {
assert.equal(run({ user: m1, path: "/lanes/pipeline-stats" }), 200);
});
});
describe("a workspace shared with another user (lane_shares)", () => {
// Lane 2 (user 2's, session s2) shared with user 1; taken back afterwards so
// the suites above keep their own picture.
before(() => {
db.prepare("INSERT INTO lane_shares (lane_id, user_id, granted_by) VALUES (2, 1, 2)").run();
});
after(() => db.prepare("DELETE FROM lane_shares").run());
it("lets the user it is shared with see it, its sessions and its runs", () => {
assert.equal(scope.canSeeLane(member(1), 2), true);
assert.equal(scope.canSeeSession(member(1), "s2"), true);
assert.equal(scope.canSeeRun({ ...member(1), method: "POST" }, "agenthub-lane-2-1"), true);
assert.deepEqual(visibleSessions(member(1)), ["s-unstamped", "s1", "s2"]);
assert.deepEqual(scope.visibleLaneIds(member(1)).sort(), [1, 2]);
const w = scope.laneWhere(member(1));
assert.deepEqual(
db.prepare(`SELECT id FROM lanes WHERE ${w.where} ORDER BY id`).all(...w.params).map((r) => r.id),
[1, 2]
);
// Nobody else gained anything.
assert.equal(scope.canSeeLane(member(3), 2), false);
assert.deepEqual(visibleSessions(member(3)), []);
});
it("keeps it out of the user's OWN money (budgets, Today)", () => {
const own = (w) =>
db.prepare(`SELECT id FROM sessions WHERE ${w.where} ORDER BY id`).all(...w.params).map((r) => r.id);
assert.deepEqual(own(scope.ownedOnlySessionWhere(member(1))), ["s-unstamped", "s1"]);
assert.deepEqual(own(scope.ownSessionWhere(member(1))), ["s-unstamped", "s1"]);
});
it("leaves owner-only actions to the owner and admins", () => {
assert.equal(scope.isLaneOwner(member(1), 2), false);
assert.equal(scope.isLaneOwner(member(2), 2), true);
assert.equal(scope.isLaneOwner(admin, 2), true);
assert.equal(scope.isLaneOwner(local, 2), true);
assert.equal(scope.isLaneOwner({ user: { id: 1, role: "analyst" } }, 2), false);
});
it("sends its news to the user it is shared with, without making it theirs", () => {
const a = scope.broadcastAudience("lane_update", { lane: { id: 2 } });
assert.equal(scope.audienceIncludes(a, member(1).user), true);
assert.equal(a.owners.has(1), false);
const s = scope.broadcastAudience("session_updated", { id: "s2" });
assert.equal(scope.audienceIncludes(s, member(1).user), true);
assert.equal(s.owners.has(1), false);
assert.equal(scope.audienceIncludes(s, member(3).user), false);
// Taken back: only the users named hear about it.
assert.deepEqual([...scope.exactAudience("lane_access", { laneId: 2, userIds: [1] })], [1]);
});
it("stops at once when it is taken back", () => {
db.prepare("DELETE FROM lane_shares WHERE lane_id = 2 AND user_id = 1").run();
assert.equal(scope.canSeeLane(member(1), 2), false);
assert.equal(scope.canSeeRun({ ...member(1), method: "POST" }, "agenthub-lane-2-1"), false);
assert.deepEqual(visibleSessions(member(1)), ["s-unstamped", "s1"]);
});
});
+16
View File
@@ -998,6 +998,22 @@ db.exec(`
CREATE INDEX IF NOT EXISTS idx_artifact_shares_user ON artifact_shares(user_id);
`);
// A workspace shared with another user (docs/plans/workspace-sharing/, phase 1):
// they see it and work in it like its owner, except owner-only actions
// (rename, reset, remove, sharing). No foreign keys, like artifact_shares — a
// table rebuild of lanes/users must not cascade them away; deleteLane and
// deleteUser remove the rows themselves.
db.exec(`
CREATE TABLE IF NOT EXISTS lane_shares (
lane_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
granted_by INTEGER,
shared_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
PRIMARY KEY (lane_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_lane_shares_user ON lane_shares(user_id);
`);
// One row per install of an artifact (server/lib/artifacts.js `install`):
// who, when, and the folders it was written to — `artifacts.installs` only
// counts. Installs before this table existed stay counted, not listed.
+4
View File
@@ -517,6 +517,8 @@ function deleteUser(username) {
const lanesLib = require("./lanes");
for (const lane of lanes) lanesLib.deleteLane(lane.id);
sql("DELETE FROM user_prefs WHERE user_key = ?").run(`u${u.id}`);
// Workspaces other users shared with them (lane_shares has no foreign key).
sql("DELETE FROM lane_shares WHERE user_id = ?").run(u.id);
require("./avatars").removeAvatar(u.id);
try {
sql("DELETE FROM push_subscriptions WHERE user_id = ?").run(u.id);
@@ -545,6 +547,8 @@ function assignWorkspace(laneId, username) {
if (lane) require("./run-as").assertOwnerFits(lane.cwd, u.id);
const r = sql("UPDATE lanes SET owner_id = ? WHERE id = ?").run(u.id, Number(laneId));
if (r.changes === 0) throw err("ENOLANE", `no workspace ${laneId}`);
// Its new owner no longer needs it shared with them.
sql("DELETE FROM lane_shares WHERE lane_id = ? AND user_id = ?").run(Number(laneId), u.id);
}
/** Take a workspace back from the user who owns it: it is left with no owner
+3 -2
View File
@@ -12,7 +12,7 @@
*/
const { stmts } = require("../db");
const { memberId, sessionWhere } = require("./scope");
const { memberId, ownedOnlySessionWhere } = require("./scope");
const { calculateCost, dailyTokenRows, costWhere } = require("../routes/pricing");
const PERIODS = ["day", "week", "month"];
@@ -72,7 +72,8 @@ function budgetSpend(cfg, { now = Date.now(), back = 0, rules, req } = {}) {
// skipped before their hours are read.
const extra = [{ clause: "s.updated_at >= ?", params: [b.startUtc] }];
if (memberId(req) != null) {
const own = sessionWhere(req, "s.id", "s.lane_id");
// Their OWN spend: a workspace shared with them is not their money.
const own = ownedOnlySessionWhere(req, "s.id", "s.lane_id");
extra.push({ clause: own.where, params: own.params });
}
const scope = cfg.tool ? { where: "tool = ?", params: [cfg.tool] } : null;
+2
View File
@@ -331,6 +331,8 @@ function setProvisioningFacts(id, facts = {}) {
function deleteLane(id) {
const gone = db.prepare("DELETE FROM lanes WHERE id = ?").run(id).changes > 0;
// Who it was shared with goes with it (lane_shares has no foreign key).
if (gone) db.prepare("DELETE FROM lane_shares WHERE lane_id = ?").run(id);
// Its `lane_sources` rows cascade; AgentHub's own copies of its uploaded
// context files go with them. A folder source is the user's and stays.
// Lazy: lane-sources.js reads the db directly and must not load at import.
+92 -20
View File
@@ -3,7 +3,10 @@
* @description Server mode's row ownership (phase S2, docs/SERVER-MODE.md) —
* the ONE place that decides which rows a request may see.
*
* A workspace (`lanes.owner_id`) belongs to the user who created it. A session
* A workspace (`lanes.owner_id`) belongs to the user who created it, and may be
* SHARED with other users (`lane_shares`): they see it and work in it like its
* owner — visibility here — while owner-only actions check `isLaneOwner`, and
* personal figures (Today, budgets) stay on owned rows only. A session
* belongs to its workspace: `sessions.lane_id`, or a `dashboard_runs` row of
* that workspace holding it (a session not stamped yet). A session no workspace owns
* belongs to the user of the Linux account it ran as (`sessions.account`,
@@ -41,7 +44,16 @@ const ownerFor = (req) => (req?.user ? req.user.id : null);
* unrestricted. `col` is the session id expression, `laneCol` its lane id.
*/
function sessionWhere(req, col = "id", laneCol = "lane_id") {
return ownedSessionWhere(memberId(req), col, laneCol);
return ownedSessionWhere(memberId(req), col, laneCol, true);
}
/**
* `sessionWhere` without shared workspaces: what this request's user OWNS —
* for their own money (budgets), never another user's spend in a workspace
* shared with them. "1" when unrestricted, like `sessionWhere`.
*/
function ownedOnlySessionWhere(req, col = "id", laneCol = "lane_id") {
return ownedSessionWhere(memberId(req), col, laneCol, false);
}
/**
@@ -53,15 +65,20 @@ function ownSessionWhere(req, col = "id", laneCol = "lane_id") {
return ownedSessionWhere(req?.user ? req.user.id : null, col, laneCol);
}
function ownedSessionWhere(uid, col, laneCol) {
// The workspaces a user owns or that are shared with them (2 params: uid, uid).
const MY_LANES = "SELECT id FROM lanes WHERE owner_id = ? UNION SELECT lane_id FROM lane_shares WHERE user_id = ?";
function ownedSessionWhere(uid, col, laneCol, shared = false) {
if (uid == null) return { where: "1", params: [] };
const lanes = shared ? MY_LANES : "SELECT id FROM lanes WHERE owner_id = ?";
const lp = shared ? [uid, uid] : [uid];
return {
where: `(${laneCol} IN (SELECT id FROM lanes WHERE owner_id = ?)
OR ${col} IN (SELECT r.session_id FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id
WHERE l.owner_id = ? AND r.session_id IS NOT NULL)
where: `(${laneCol} IN (${lanes})
OR ${col} IN (SELECT r.session_id FROM dashboard_runs r
WHERE r.lane_id IN (${lanes}) AND r.session_id IS NOT NULL)
OR ${col} IN (SELECT id FROM sessions WHERE owner_id = ?)
OR ${col} IN (SELECT session_id FROM (${ACCOUNT_OWNED}) WHERE uid = ?))`,
params: [uid, uid, uid, uid],
params: [...lp, ...lp, uid, uid],
};
}
@@ -77,7 +94,27 @@ function bySessionWhere(req, col = "session_id") {
function laneWhere(req, col = "id") {
const uid = memberId(req);
if (uid == null) return { where: "1", params: [] };
return { where: `${col} IN (SELECT id FROM lanes WHERE owner_id = ?)`, params: [uid] };
return { where: `${col} IN (${MY_LANES})`, params: [uid, uid] };
}
/** Whether `userId` is one of the users workspace `laneId` is shared with. */
function sharedWith(laneId, userId) {
if (laneId == null || userId == null) return false;
return Boolean(
sql("SELECT 1 FROM lane_shares WHERE lane_id = ? AND user_id = ?").get(Number(laneId), userId)
);
}
/**
* Owner-only actions on a workspace (rename, reset, remove, sharing): its
* owner or an admin; always true in local mode. A user it is shared with may
* do everything else, which `canSeeLane` decides.
*/
function isLaneOwner(req, laneId) {
const u = req?.user;
if (!u || u.role === "admin") return true;
const row = sql("SELECT owner_id FROM lanes WHERE id = ?").get(Number(laneId));
return Boolean(row) && row.owner_id === u.id;
}
function canSeeLane(req, laneId) {
@@ -87,8 +124,8 @@ function canSeeLane(req, laneId) {
// affinity reads '9002.0' or ' 9002' as 9002 for both, so the row checked
// here is the row the route would load.
const v = typeof laneId === "number" ? laneId : String(laneId);
const row = sql("SELECT owner_id FROM lanes WHERE id = ?").get(v);
return Boolean(row) && row.owner_id === uid;
const row = sql("SELECT id, owner_id FROM lanes WHERE id = ?").get(v);
return Boolean(row) && (row.owner_id === uid || sharedWith(row.id, uid));
}
function canSeeSession(req, sessionId) {
@@ -118,8 +155,8 @@ function canSeeRun(req, runId) {
function visibleLaneIds(req) {
const uid = memberId(req);
if (uid == null) return null;
return sql("SELECT id FROM lanes WHERE owner_id = ?")
.all(uid)
return sql(MY_LANES)
.all(uid, uid)
.map((r) => r.id);
}
@@ -292,6 +329,30 @@ function laneOwners(laneId) {
return new Set(row && row.owner_id != null ? [row.owner_id] : []);
}
/** The users a workspace is shared with — they get its news, but it is not "theirs". */
function laneViewers(laneId) {
if (laneId == null) return new Set();
return new Set(
sql("SELECT user_id FROM lane_shares WHERE lane_id = ?")
.all(Number(laneId))
.map((r) => r.user_id)
);
}
/** The users the workspace holding a session is shared with. */
function sessionViewers(sessionId) {
if (!sessionId) return new Set();
return new Set(
sql(
`SELECT user_id FROM lane_shares WHERE lane_id IN (
SELECT lane_id FROM sessions WHERE id = ? AND lane_id IS NOT NULL
UNION SELECT lane_id FROM dashboard_runs WHERE session_id = ? AND lane_id IS NOT NULL)`
)
.all(String(sessionId), String(sessionId))
.map((r) => r.user_id)
);
}
/**
* Who a broadcast may reach: `null` = every client, `{owners: Set<userId>}` =
* admin/analyst plus those members, `{staffOnly: true}` = admin/analyst only.
@@ -303,25 +364,27 @@ function broadcastAudience(type, data) {
switch (type) {
case "session_created":
case "session_updated":
return { owners: sessionOwners(d.id) };
return { owners: sessionOwners(d.id), viewers: sessionViewers(d.id) };
case "agent_created":
case "agent_updated":
case "new_event":
case "workflow_upserted":
case "transcript_changed":
return { owners: sessionOwners(d.session_id) };
return { owners: sessionOwners(d.session_id), viewers: sessionViewers(d.session_id) };
case "alert_triggered":
case "alert_updated":
if (d.acked_all) return null;
return d.session_id ? { owners: sessionOwners(d.session_id) } : STAFF;
return d.session_id
? { owners: sessionOwners(d.session_id), viewers: sessionViewers(d.session_id) }
: STAFF;
case "lane_update":
// A removal names an id that no longer resolves; it reveals nothing.
if (d.removed != null && !d.lane) return null;
return { owners: laneOwners(d.lane?.id) };
return { owners: laneOwners(d.lane?.id), viewers: laneViewers(d.lane?.id) };
case "lane_hook_output":
case "lane_hook_result":
case "lane_runtime":
return { owners: laneOwners(d.laneId) };
return { owners: laneOwners(d.laneId), viewers: laneViewers(d.laneId) };
// A shared artifact is everyone's news; a private one, its owner's and
// the people it is shared with by name.
case "artifact_changed":
@@ -359,6 +422,8 @@ function broadcastAudience(type, data) {
function exactAudience(type, data) {
const d = data && typeof data === "object" ? data : {};
if (type === "cc_config_changed" && d.ownerId != null) return new Set([d.ownerId]);
// A workspace no longer shared with these users: only they re-read their list.
if (type === "lane_access" && Array.isArray(d.userIds)) return new Set(d.userIds);
return null;
}
@@ -366,7 +431,10 @@ function exactAudience(type, data) {
function audienceIncludes(audience, user) {
if (!user || user.role !== "developer") return true;
if (audience === null || audience.strip) return true;
return Boolean(audience.owners && audience.owners.has(user.id));
return Boolean(
(audience.owners && audience.owners.has(user.id)) ||
(audience.viewers && audience.viewers.has(user.id))
);
}
/**
@@ -437,8 +505,8 @@ function insideOwnWorkspace(req, dir) {
const u = req?.user;
if (!u || u.role === "admin") return true;
return insideLanes(
sql("SELECT id FROM lanes WHERE owner_id = ?")
.all(u.id)
sql(MY_LANES)
.all(u.id, u.id)
.map((r) => r.id),
dir
);
@@ -494,6 +562,10 @@ module.exports = {
ownerFor,
sessionWhere,
ownSessionWhere,
ownedOnlySessionWhere,
isLaneOwner,
sharedWith,
laneViewers,
bySessionWhere,
laneWhere,
canSeeLane,
+37
View File
@@ -701,6 +701,43 @@ const paths = {
},
},
},
"/api/lanes/{id}/shares": {
get: {
tags: ["Lanes"],
summary: "The users a workspace is shared with",
description:
"Server mode (local mode: `{shares: [], canManage: false}`). `{shares:[{id, username, name, sharedAt}], canManage}` — names only; `canManage` is whether this caller (the owner or an admin) may change the list. A user it is shared with may read it too.",
operationId: "getLaneShares",
parameters: [idParam],
responses: { 200: { description: "`{shares, canManage}`.", ...json(obj) }, 404: notFound },
},
put: {
tags: ["Lanes"],
summary: "Set the users a workspace is shared with",
description:
"`{userIds:[…]}` is the WHOLE list (≤ 200 enabled users, never the owner). The owner or an admin only (`403 ENOTOWNER`); same-origin; each user added or taken off is written to the audit log. A user it is shared with sees it and works in it like its owner — under S3 as the owner's Linux account — except rename, reset, remove, delete and sharing (`ENOTOWNER`), and starts no AI session in it yet (`ECOLLABSTART`). Users taken off get `lane_access` and lose access at once.",
operationId: "setLaneShares",
parameters: [idParam],
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["userIds"],
properties: { userIds: { type: "array", items: { type: "integer" } } },
},
},
},
},
responses: {
200: { description: "`{shares}`.", ...json(obj) },
400: { description: "EBADUSER or ELOCALMODE." },
403: { description: "ENOTOWNER." },
404: notFound,
},
},
},
"/api/lanes/{id}/memory": {
get: {
tags: ["Lanes"],
+120
View File
@@ -426,6 +426,23 @@ router.get("/branches", async (req, res) => {
/** The lane fields a server-mode member may PATCH (the UI sends title and pipeline). */
const MEMBER_PATCHABLE = new Set(["title", "pipeline", "notes", "links", "branch"]);
/** Sharing phase 1: a user a workspace is shared with starts no AI session in it. */
function collabStart(res) {
return res.status(403).json({
error: {
code: "ECOLLABSTART",
message: "this workspace is shared with you: join its open sessions — only its owner starts new ones for now",
},
});
}
/** A shared workspace's owner-only action (rename, reset, remove, sharing). */
function notOwner(res) {
return res.status(403).json({
error: { code: "ENOTOWNER", message: "only the workspace's owner (or an admin) may do this" },
});
}
/** Server mode: the folder is, holds or lies in another user's workspace. */
function refuseOwned(res) {
return res.status(409).json({
@@ -1356,6 +1373,8 @@ router.patch("/:id", sameOriginGuard, (req, res) => {
// that tie a workspace to a pane or a session (`run_id` pointed at another
// user's pane would let stop/reset kill it).
const body = req.body && typeof req.body === "object" ? req.body : {};
// A workspace's name is its owner's: a user it is shared with renames nothing.
if ("title" in body && !scope.isLaneOwner(req, req.params.id)) return notOwner(res);
if (scope.memberId(req) != null) {
const off = Object.keys(body).filter((k) => !MEMBER_PATCHABLE.has(k));
if (off.length) {
@@ -1386,6 +1405,95 @@ router.patch("/:id", sameOriginGuard, (req, res) => {
res.json({ lane: payload(lane) });
});
// ── Sharing a workspace with other users (server mode) ─────────────────────
// docs/plans/workspace-sharing/: a user it is shared with sees it and works in
// it like its owner — under S3 AS the owner's Linux account — except the
// owner-only actions (`scope.isLaneOwner`). Names only, never a password.
const MAX_SHARES = 200;
function laneShares(laneId) {
return require("../db")
.db.prepare(
`SELECT u.id, u.username, COALESCE(NULLIF(u.display_name, ''), u.username) AS name,
s.shared_at AS sharedAt
FROM lane_shares s JOIN users u ON u.id = s.user_id
WHERE s.lane_id = ? ORDER BY name COLLATE NOCASE`
)
.all(Number(laneId));
}
router.get("/:id/shares", (req, res) => {
const lane = lanesLib.getLane(req.params.id);
if (!lane)
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
if (!require("../lib/auth").isServerMode()) return res.json({ shares: [], canManage: false });
res.json({ shares: laneShares(lane.id), canManage: scope.isLaneOwner(req, lane.id) });
});
/** `{userIds}` — the WHOLE list of users the workspace is shared with. */
router.put("/:id/shares", sameOriginGuard, (req, res) => {
const auth = require("../lib/auth");
if (!auth.isServerMode()) {
return res
.status(400)
.json({ error: { code: "ELOCALMODE", message: "sharing needs server mode" } });
}
const lane = lanesLib.getLane(req.params.id);
if (!lane)
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
if (!scope.isLaneOwner(req, lane.id)) return notOwner(res);
const raw = req.body?.userIds;
if (
!Array.isArray(raw) ||
raw.length > MAX_SHARES ||
!raw.every((x) => Number.isInteger(x) && x > 0)
) {
return res
.status(400)
.json({ error: { code: "EBADUSER", message: "userIds must be a list of user ids" } });
}
const { db } = require("../db");
const wanted = [...new Set(raw)];
const user = db.prepare("SELECT id, disabled FROM users WHERE id = ?");
for (const id of wanted) {
const u = user.get(id);
if (!u || u.disabled) {
return res
.status(400)
.json({ error: { code: "EBADUSER", message: `no enabled user ${id}` } });
}
if (id === lane.owner_id) {
return res
.status(400)
.json({ error: { code: "EBADUSER", message: "a workspace is not shared with its owner" } });
}
}
const before = new Set(laneShares(lane.id).map((u) => u.id));
const added = wanted.filter((id) => !before.has(id));
const removed = [...before].filter((id) => !wanted.includes(id));
const actor = req.user?.id ?? null;
db.transaction(() => {
const del = db.prepare("DELETE FROM lane_shares WHERE lane_id = ? AND user_id = ?");
const ins = db.prepare(
"INSERT OR IGNORE INTO lane_shares (lane_id, user_id, granted_by) VALUES (?, ?, ?)"
);
for (const id of removed) del.run(lane.id, id);
for (const id of added) ins.run(lane.id, id, actor);
})();
const audit = require("../lib/audit");
for (const id of added) audit.recordAccountChange(actor, id, "PUT", `share workspace ${lane.id}`);
for (const id of removed) {
audit.recordAccountChange(actor, id, "PUT", `unshare workspace ${lane.id}`);
}
// The owner and everyone it is shared with now re-read it; the users taken
// off re-read their list (it is gone from it), and their open terminals on
// it close at the next attach re-check (websocket.js).
broadcastLane(lane.id);
if (removed.length) broadcast("lane_access", { laneId: lane.id, userIds: removed });
res.json({ shares: laneShares(lane.id) });
});
router.post("/:id/stage", sameOriginGuard, (req, res) => {
const current = lanesLib.getLane(req.params.id);
if (!current) {
@@ -3240,6 +3348,13 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
if (!lane)
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
const body = req.body || {};
// A workspace shared with this user: they may not destroy it, and (sharing
// phase 1) start no AI session in it — a terminal tab is fine; they join the
// owner's live panes. Phase 2 bills a collaborator's own sessions to them.
if (!scope.isLaneOwner(req, lane.id)) {
if (DESTRUCTIVE_ACTIONS.has(action)) return notOwner(res);
if (action === "start" && body.shell !== true) return collabStart(res);
}
if (DESTRUCTIVE_ACTIONS.has(action)) {
if (body.confirm !== true) {
@@ -3603,6 +3718,9 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
});
router.delete("/:id", sameOriginGuard, (req, res) => {
if (lanesLib.getLane(req.params.id) && !scope.isLaneOwner(req, req.params.id)) {
return notOwner(res);
}
const ok = lanesLib.deleteLane(req.params.id);
if (!ok)
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
@@ -3613,4 +3731,6 @@ router.delete("/:id", sameOriginGuard, (req, res) => {
module.exports = router;
module.exports.broadcastLane = broadcastLane;
module.exports.healAllRunIds = healAllRunIds;
module.exports.collabStart = collabStart;
module.exports.notOwner = notOwner;
module.exports.broadcastForeignLaneChanges = broadcastForeignLaneChanges;
+4
View File
@@ -618,6 +618,10 @@ router.post("/", async (req, res) => {
if (!Number.isInteger(laneId)) {
return res.status(400).json({ error: { code: "EBADLANE", message: "laneId is required" } });
}
// A workspace shared with this user: no AI session of theirs yet (sharing phase 1).
if (lanesLib.getLane(laneId) && !scope.isLaneOwner(req, laneId) && body.shell !== true) {
return require("./lanes").collabStart(res);
}
let cwd;
try {
// A member's run with no cwd starts in its workspace, not the server's folder.