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:
2026-11-06 10:48:29 +07:00
parent 0bf552b793
commit 54001585e2
16 changed files with 201 additions and 31 deletions
+14 -5
View File
@@ -1416,11 +1416,12 @@ 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
s.shared_at AS sharedAt, u.disabled
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));
.all(Number(laneId))
.map((u) => ({ ...u, disabled: Boolean(u.disabled) }));
}
router.get("/:id/shares", (req, res) => {
@@ -1455,10 +1456,13 @@ router.put("/:id/shares", sameOriginGuard, (req, res) => {
}
const { db } = require("../db");
const wanted = [...new Set(raw)];
const before = new Set(laneShares(lane.id).map((u) => u.id));
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) {
// A user already on the list may since have been disabled: keeping them
// must not block every other change (it grants nothing while disabled).
if (!u || (u.disabled && !before.has(id))) {
return res
.status(400)
.json({ error: { code: "EBADUSER", message: `no enabled user ${id}` } });
@@ -1469,7 +1473,6 @@ router.put("/:id/shares", sameOriginGuard, (req, res) => {
.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;
@@ -3497,7 +3500,13 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
if (picked === null) return { allBusy: true };
slot = picked;
if (isLive(current.id, slot)) return { conflict: true };
startedRunId = runs.spawnShell({ laneId: current.id, slot, cwd: current.cwd }).id;
startedRunId = runs.spawnShell({
laneId: current.id,
slot,
cwd: current.cwd,
// A user it is shared with: their pane token acts as them.
startedBy: req.user?.id ?? null,
}).id;
return {};
});
if (shellOutcome.missing) {
+3 -2
View File
@@ -618,8 +618,9 @@ 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) {
// A workspace shared with this user: no AI session of theirs yet (sharing
// phase 1). This route always starts the lane's AI tool — no shell here.
if (lanesLib.getLane(laneId) && !scope.isLaneOwner(req, laneId)) {
return require("./lanes").collabStart(res);
}
let cwd;