docs(plans): workspace & session sharing — draft for review
This commit is contained in:
@@ -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)?
|
||||
Reference in New Issue
Block a user