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
+16 -5
View File
@@ -729,25 +729,36 @@ function newPaneToken() {
}
/**
* The workspace owner a request from inside a live pane acts as: its
* The user a request from inside a live pane acts as: its
* `x-agenthub-pane-token` names a run that is still running in tmux, on a
* workspace someone owns, and the request came straight from this machine.
* That user is the workspace owner — or, for a pane a user the workspace is
* shared with started (`dashboard_runs.started_by`), THAT user, and only while
* it is still shared with them: their Terminal tab never acts as the owner.
*/
function userFromPaneToken(req) {
const t = req.headers[PANE_HEADER];
if (typeof t !== "string" || !t.startsWith("ahpane_") || t.length > 200) return null;
if (!isLocalRequest(req)) return null;
const row = sql(
`SELECT r.id, r.lane_id, l.owner_id FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id
`SELECT r.id, r.lane_id, r.started_by, l.owner_id FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id
WHERE r.pane_token_hash = ? AND r.status IN ('running', 'spawning') AND instr(r.id, '@') = 0`
).get(sha256(t));
if (!row || row.owner_id == null) return null;
// S3: only from the account that pane runs as (any local uid could post it).
if (!require("./hook-auth").peerIsPaneAccount(req, row.lane_id)) return null;
if (!require("./tmux").hasSession(row.id)) return null;
return (
publicUser(sql("SELECT * FROM users WHERE id = ? AND disabled = 0").get(row.owner_id)) || null
);
const uid = row.started_by ?? row.owner_id;
const user = sql("SELECT * FROM users WHERE id = ? AND disabled = 0").get(uid);
if (!user) return null;
if (
uid !== row.owner_id &&
user.role !== "admin" &&
!require("./scope").sharedWith(row.lane_id, uid)
) {
return null;
}
return publicUser(user) || null;
}
// ── Gates ───────────────────────────────────────────────────────────────────
+3 -2
View File
@@ -34,11 +34,11 @@ const insertStmt = db.prepare(`
INSERT OR REPLACE INTO dashboard_runs (
id, session_id, mode, cwd, model, permission_mode, effort,
resume_session_id, prompt_preview, status, exit_code, started_at, ended_at,
lane_id, tool, worktree, proxy, account, pane_token_hash
lane_id, tool, worktree, proxy, account, pane_token_hash, started_by
) VALUES (
@id, @session_id, @mode, @cwd, @model, @permission_mode, @effort,
@resume_session_id, @prompt_preview, @status, @exit_code, @started_at, @ended_at,
@lane_id, @tool, @worktree, @proxy, @account, @pane_token_hash
@lane_id, @tool, @worktree, @proxy, @account, @pane_token_hash, @started_by
)
`);
@@ -128,6 +128,7 @@ function recordRun(handle) {
proxy: handle.proxy || null,
account: handle.account || null,
pane_token_hash: handle.paneTokenHash || null,
started_by: Number.isInteger(handle.startedBy) ? handle.startedBy : null,
});
} catch (err) {
// Best-effort by design (a live run must never fail because sqlite did),
+4 -1
View File
@@ -808,7 +808,9 @@ function spawnRun(args) {
* `shell` so the tab can say what it is. The caller has already made sure the
* slot holds no live session.
*/
function spawnShell({ laneId, slot = 0, cwd }) {
/** `startedBy`: the user opening it when that is not the workspace's owner (a
* workspace shared with them) — its pane token then acts as them. */
function spawnShell({ laneId, slot = 0, cwd, startedBy = null }) {
if (!Number.isInteger(laneId)) throw makeErr("EBADLANE", "laneId must be an integer");
if (!Number.isInteger(slot) || slot < 0 || slot > MAX_PANE_SLOT) {
throw makeErr("EBADSLOT", `slot must be an integer 0–${MAX_PANE_SLOT}`);
@@ -844,6 +846,7 @@ function spawnShell({ laneId, slot = 0, cwd }) {
worktree: false,
proxy: null,
paneTokenHash: paneCred ? paneCred.hash : null,
startedBy: lane && Number.isInteger(startedBy) && startedBy !== lane.owner_id ? startedBy : null,
prompt: "",
status: "running",
startedAt: Date.now(),