diff --git a/docs/plans/workspace-sharing/README.md b/docs/plans/workspace-sharing/README.md new file mode 100644 index 00000000..71e0460a --- /dev/null +++ b/docs/plans/workspace-sharing/README.md @@ -0,0 +1,174 @@ +# Workspace & session sharing — plan + +Status: **draft, waiting for review** (2026-11-06). Nothing here is built. + +Server mode only. In local mode there are no users, so there is nothing to share +and every piece below is a no-op (`isServerMode()`). + +## What the user asked for + +Members of the same AgentHub install work together on one workspace or one +session. Decisions taken with the repo owner (2026-11-06): + +| Question | Decision | +| --- | --- | +| What may a person it is shared with do? | **Always collaborate** — the same as the owner inside that workspace/session: type into panes, send prompts, start sessions, commit, push. No view-only level. | +| Unit of sharing | **Both** — a whole workspace (every session, present and future) or a single session. | +| Who pays for a session a collaborator starts? | **The person who opens it** — its tokens, cost and spend quota are theirs, not the workspace owner's. | + +## The one fact everything else follows from (S3) + +Under S3 a pane runs as the Linux account of its **folder** +(`accountForPath`, `server/lib/run-as.js`), and only the folder's account may +own a workspace there (`assertOwnerFits`, `EOWNERFOLDER`). Sharing does **not** +change that: a collaborator's work in Alice's workspace runs as `ah-alice`. + +What that means, and the UI must say it once, plainly, in the Share dialog: + +- **Git identity, SSH key, HTTPS store, commit signing are the owner's.** A + collaborator's commit is authored by the owner (`git config --global` of the + owner's account). AgentHub's `audit_log` records who clicked; git does not. +- **Files written are the owner's.** Everything a collaborator's session + creates belongs to `ah-alice`. +- **The owner's `~/.claude` applies** — their skills, memory, MCP servers, + settings — to the collaborator's sessions too. +- **The owner's account can read the collaborator's pane token** (it is in a + launch file in the owner's home, like every pane secret). So the collaborator's + token must be scoped to the shared workspace only (below), never their whole + account. + +There is no way around these without moving the folder to a shared account — +out of scope. Sharing is therefore something you do with people you would +also hand your keyboard to; the dialog says so. + +## Data model (migration-safe, `server/db.js`) + +```sql +CREATE TABLE IF NOT EXISTS lane_shares ( + lane_id INTEGER NOT NULL REFERENCES lanes(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + granted_by INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + PRIMARY KEY (lane_id, user_id) +); +CREATE TABLE IF NOT EXISTS session_shares ( + session_id TEXT NOT NULL, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + granted_by INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + PRIMARY KEY (session_id, user_id) +); +-- who started a dashboard run (a collaborator's run is billed to them) +ALTER TABLE dashboard_runs ADD COLUMN started_by INTEGER; -- guarded like every ALTER in db.js +``` + +No `role`/`level` column: there is one level (collaborate). Adding view-only +later is one column. + +## Who may do what + +| Action | Owner | Workspace collaborator | Session collaborator | Admin | +| --- | --- | --- | --- | --- | +| See the workspace, its sessions, Changes, history | ✓ | ✓ | that session + the workspace's Changes panel | ✓ | +| Attach to / type into a live pane | ✓ | ✓ every pane | that session's pane | ✓ | +| Send a message to an ended session (resume) | ✓ | ✓ | that session | ✓ | +| Start a NEW session | ✓ | ✓ (billed to them) | ✗ | ✓ | +| Git writes (commit, push, shelve, rewrite…) | ✓ | ✓ | ✓ (the session's tree) | ✓ | +| Workspace instructions, context files, extra folders | ✓ | ✓ | ✗ | ✓ | +| Rename / Remove / Reset the workspace | ✓ | ✗ | ✗ | ✓ | +| Share / unshare | ✓ | ✗ | ✗ | ✓ | +| `agenthub stage`, lane runtime `up/down/hook` | ✓ | ✓ | ✗ | ✓ | + +Owner-only actions get an `assertLaneOwner(req, laneId)` (owner or admin); +everything else becomes "owner **or** collaborator". + +## Server changes + +1. **`server/lib/scope.js`** — the one place that decides visibility: + - `laneWhere` / `canSeeLane`: `owner_id = ? OR id IN (SELECT lane_id FROM lane_shares WHERE user_id = ?)`. + - `ownedSessionWhere`: add `OR col IN (SELECT session_id FROM session_shares WHERE user_id = ?)` and sessions of shared lanes (through the same lane predicate). + - `PATH_RULES` / `namedIds` need no new rule (they call `canSeeLane`/`canSeeSession`); a new route under a new prefix (`/api/shares`) gets its own rule. + - Keep `memberId` as is: an analyst still reads everything and writes only what they own **or collaborate on**. +2. **Session ownership & billing ("whoever opens it pays")** + - `spawnRun` takes `startedBy = req.user.id`; writes `dashboard_runs.started_by`. + - `bindRunSession` (`server/routes/hooks.js`) stamps `sessions.owner_id = started_by` when it differs from the lane owner. `sessions.owner_id` already exists (remote clients) and `scope.js`, `session-account.js`, `users.js` honour it. + - `server/routes/users.js` ownership CTE: `sessions.owner_id` must WIN over the lane owner (today it is a UNION — a session could count twice). Same in `user-quota.js` spend (`hourlyTokenRows` + `calculateCost`, never a second formula). + - Spend quota: `assertSpawnAllowed(startedBy)` instead of the lane owner, and `hook-handler.js`'s `UserPromptSubmit` check reads the flag of the session's billed user (the quota file is per Linux account today → becomes per account + billed user id; see open question 2). + - A resume by a collaborator: billed to the session's existing owner (the opener stays the payer), unless decided otherwise (open question 3). +3. **AI login of a collaborator's session.** Which subscription is actually + consumed is separate from who is billed in AgentHub: + - Pool account of kind `token`: passed as `CLAUDE_CODE_OAUTH_TOKEN` through tmux `-e` / the launch file, so the collaborator's **own** pool pick (`pickAccount(startedBy)`) is used — real separation. + - Pool account of kind `login` (or no pool): the login lives in the owner's `~/.claude/.credentials.json`; a second login cannot be placed there while the owner's panes run (existing pool rule). The session then runs on the **owner's** subscription quota, only billed to the collaborator in AgentHub. The session row shows this. +4. **Pane token.** A collaborator's session gets a pane token of the + collaborator, **scoped to that lane**: `dashboard_runs.pane_token_hash` plus a + `scope_lane_id`, and `userFromPaneToken` refuses anything outside that lane. + `peerIsPaneAccount` keeps checking the folder's account (`ah-alice`) — the + token is accepted from that uid because the pane runs as it. +5. **Live co-working.** tmux already allows several attaches with a shared + size (`pty-attach.js`). Add: + - presence: who is attached to which pane (in-memory, broadcast to the lane's audience); + - an audit line per attach-with-input and per prompt sent from the chat composer (`audit_log`, actor = the real user). Keystrokes are not logged. +6. **WebSocket** — `broadcastAudience` (`server/websocket.js`): every lane- or + session-scoped message also reaches the lane's/session's collaborators. A new + type `shares_changed` (owner + affected user). +7. **Routes** (`server/routes/shares.js`, same-origin, audited, server mode only): + - `GET /api/lanes/:id/shares`, `PUT /api/lanes/:id/shares {userIds}` (owner/admin) + - `GET /api/sessions/:id/shares`, `PUT /api/sessions/:id/shares {userIds}` (owner/admin) + - `GET /api/me/shared` — what is shared with me + - only enabled users; never the owner themself; a disabled user's shares stay but grant nothing. +8. **Revoke.** Removing a share closes that user's live attaches on the lane + (their websocket attach ends), revokes their pane tokens' lane scope, and + leaves their running sessions running (they run as the owner; the owner can + stop them). Deleting a lane cascades its shares. + +## Client changes + +- **Share dialog** from the workspace's `⋯` menu and the session's right-click + menu: user picker (enabled users, not me), the current list with Remove, and + the S3 warning above. `confirmDialog` on Share, `toast` on the result. +- **Sidebar**: a "Shared with me" group (workspaces shared with me, and loose + shared sessions under their workspace name, marked). A share icon on rows I + shared. +- **Presence**: small avatars on a pane's tab of who else is attached. +- **Recents / session list**: the opener's avatar on a session a collaborator + started; the cost column is the opener's. +- Owner-only controls hidden for collaborators (the server refuses anyway — + `ENOTOWNER`, 403). + +## Docs to update when built + +`docs/SERVER-MODE.md` (new § Sharing + scope table), `docs/LANES.md`, +`docs/API.md`, `CLAUDE.md` (the scope.js rule and the S3 rule gain "or a +collaborator"), `README.md` feature list. + +## Phases + +1. **S1 — Workspace sharing, visibility + live panes.** Tables, scope.js, + broadcastAudience, Share dialog, sidebar group, attach/type, git writes. + Collaborators can join live panes but cannot start sessions yet. +2. **S2 — Collaborator sessions billed to the opener.** `started_by`, + `sessions.owner_id` stamping, users.js / quota precedence, pane token scope, + pool `token` env. +3. **S3 — Session-only sharing.** `session_shares`, its scope rules and UI. +4. **S4 — Presence + audit of prompts.** + +Each phase ships on its own; tests per phase: `scope.test.js` (every predicate +with and without a share), `server-mode-review-fixes.test.js` style 404s for a +non-collaborator, users/quota attribution, websocket audience, and the client +dialog/sidebar tests. + +## Open questions + +1. **Which subscription does a collaborator's session use** when the pool is + `login`-kind (or empty)? Proposal: the owner's, shown on the session; the + alternative (refuse a collaborator session without a `token` pool account) is + stricter. +2. **Spend quota of a collaborator inside someone else's workspace** — enforce + the collaborator's own quota (proposal) or both theirs and the owner's? +3. **A collaborator resumes the owner's ended session** — billed to the session's + owner (proposal) or to whoever resumes? +4. **Session-only share and git**: may a session collaborator commit/push, or + only use the Changes panel read-only? (The table above says commit is + allowed, following "always collaborate".) +5. **Analysts**: does sharing to an analyst let them write in that workspace + (proposal: yes — it becomes "their own" for writes)?