docs(plans): workspace & session sharing — draft for review

This commit is contained in:
2026-11-06 10:19:48 +07:00
parent e60ebe468b
commit 9d28807f2c
+174
View File
@@ -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)?