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:
+16
-5
@@ -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 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user