Files
AgentHub/docs/plans/workspace-sharing/README.md
T
nntrivi2001 18e574a765 feat(server-mode): pane presence and typing audit (sharing phase 4)
A pane's tab shows the other people who have it open (pane-presence.js,
in memory, fed by the /ws-pty attach; GET /api/lanes/:id/presence and the
pane_presence broadcast to the owner and full collaborators) and says
"Billed to <name>" for a pane a collaborator opened (startedBy on the run).
The first real keystroke of each attach into a shared workspace's pane by
anyone but its owner is audited as `type into <run id>` (mouse, focus and
terminal replies to tmux do not count). The pty socket now pings, so a
vanished browser is ended at the next re-check and leaves the presence list.
2026-11-06 11:32:25 +07:00

280 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Workspace & session sharing — plan
Status: **phases 1–4 built 2026-11-06** (workspace sharing; collaborators run on their own account, billed per payer by hour — `session_payers`; one session can be shared — `session_shares`; pane presence + typing audit; R1 decided as option **(a)** — accept and warn hard).
An independent review of phase 1 found and got fixed: a collaborator's Terminal
tab acted as the owner through its pane token (now `dashboard_runs.started_by`),
`POST /api/run {shell:true}` bypassed `ECOLLABSTART`, a disabled collaborator
locked the share list, and the home's prompt box let a collaborator type into a
Start that always failed. Accepted under (a): the owner's own panes' tokens are
readable by a collaborator, and `ECOLLABSTART` is advisory under S3.
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. |
| Whose AI subscription does a collaborator's run use? | **Their own, or none**: only a pool account of kind `token` that the collaborator may use (passed by env). With a `login`-kind pool or no pool the run is **refused** (`ENOCOLLABACCOUNT`) — a collaborator never spends the owner's subscription. |
| Spend quota | **Only the payer's** (the person who started or resumed the run). The owner's quota is not involved. |
| A collaborator resumes someone else's session | **The resumer pays from then on** — billing is per RUN, not per session (below). |
| Session-only share and git | **Allowed** — commit/push in that session's tree, like everything else. |
| Sharing with an analyst | **Allowed** — a shared workspace counts as their own for writes. |
## 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 RUN's payer (the quota file is per Linux account today → per account + payer user id, passed to the pane as `AGENTHUB_PAYER_ID`).
- **Billing is per run, not per session.** A session can be paid by several
people over time: whoever starts or resumes a run pays for everything that
run does, including what anyone types into its live pane (keystrokes cannot
be attributed; the run's starter can). New table
`session_payers(session_id, user_id, from_at)` — one row per run start whose
payer differs from the previous one. `hourlyTokenRows` books a session's
tokens per UTC hour already; each hour goes to the payer whose `from_at` is
the latest ≤ that hour (a run started mid-hour takes the hour from the next
one on — documented rounding, never a second cost formula). With no row the
session's owner pays, as today. `sessions.owner_id` keeps meaning "who
opened it" (visibility); `session_payers` only moves money.
3. **AI login of a collaborator's run — their own or refused.**
- The collaborator's pool pick (`pickAccount(startedBy)`, their group, pin
and own choice) must be of kind `token`: it goes in as
`CLAUDE_CODE_OAUTH_TOKEN` through tmux `-e` / the launch file, overriding
the owner's home login for that pane only.
- A `login`-kind pick, or no pool account for them: `spawnRun` refuses with
`409 ENOCOLLABACCOUNT` before anything starts, for a new session AND for a
resume. The home's Start button says why. The owner's login is never used
for a collaborator's run.
- Codex / other tools: same rule with their own env-only kinds (Codex API key
`AGENTHUB_POOL_KEY`, Cursor `CURSOR_API_KEY`); a ChatGPT login (written into
the home as `auth.json`) is refused like a Claude `login`.
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 runs on their own account, billed per run.**
`started_by`, `sessions.owner_id` stamping, `session_payers`, users.js /
quota precedence (and the double count fixed), pane token scope, pool
`token` env, `ENOCOLLABACCOUNT`.
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.
## Self-review against the code (2026-11-06)
Checked every claim above against `server/lib/scope.js`, `auth.js`
(`userFromPaneToken`), `hook-auth.js` (`peerIsPaneAccount`), `pty-run.js`
(`spawnRun`), `user-quota.js`, `lane-sources.js` (`launchExtras`),
`websocket.js` (pty attach) and `routes/users.js`.
**R1 — CRITICAL: sharing a workspace hands out a shell as the owner's whole
account, not just that workspace.** A collaborator who types into a pane (or
whose prompt makes Claude run Bash) runs as `ah-alice`, whose home holds ALL of
Alice's workspaces, `~/.ssh` keys, `~/.config/agenthub/` HTTPS store, Claude and
Codex logins (`.credentials.json`, `auth.json`) and the env of her live panes.
So Bob can read Alice's other projects, copy her SSH key and her Claude
subscription token, and — via `$AGENTHUB_PANE_TOKEN` in any of her panes — call
the CLI/MCP **as Alice** (`userFromPaneToken` returns the lane owner, and is
accepted from Alice's uid, which Bob's typing runs as). This also defeats
decision 1 ("never the owner's subscription"): Bob can simply use her login.
The section "The one fact everything else follows from" understated this.
Options:
- **(a) Accept it, warn hard.** Share = "give this person your account". Simple,
honest, no isolation.
- **(b) Team workspaces in their own Linux account.** A shared workspace lives
under a dedicated account (`ah-team-<id>`, provisioned by `agenthub-provision`
like a member, its own home, git identity, logins = the pool's `token` kinds
only). Every member of the team works there; nobody's personal home is
exposed. Real isolation; more work (provisioning, git identity per team,
`assertOwnerFits` learns team folders, moving an existing workspace = clone).
- **(c) Chat-only collaborators.** No terminal for collaborators — but Claude's
own Bash tool still runs as Alice, so this isolates nothing. Not proposed.
**R2 — Typing into the owner's live pane spends the owner's subscription.**
Unavoidable while the pane runs on her login; contradicts the spirit of
decision 1. Under (b) it disappears (team panes use pool `token` accounts).
**R3 — Pane tokens.** `userFromPaneToken` maps a token to the LANE OWNER. Needs
`dashboard_runs.started_by` → the token's user. And every pane token of a shared
lane (the owner's too, see R1) must be limited to that lane, or one leaked token
is a whole account.
**R4 — Session-only sharing does not fit the current checks.** `canSeeRun` and
every `/api/lanes/:id/...` route decide by LANE (`canSeeLane`). Granting a
session collaborator `canSeeLane` would show them the whole workspace. Needs a
run/session-level predicate and an allowlist of lane routes usable with
`?session=<shared id>` only. Recommend: drop session-only sharing from the first
release, or limit it to that session's pane + chat (no Changes panel).
**R5 — Per-account limits are shared.** `MAX_AI_SESSIONS` (default 4) and
`member-guard.js` count per Linux account, so collaborators' sessions eat the
owner's 4 (no issue under (b): the team account has its own limit).
**R6 — Spend-quota flag is one file per account** (`quota.json` in the member's
handoff dir, `flagEnv`). Payer-based quota needs one file per payer
(`quota-u<id>.json`) and `AGENTHUB_QUOTA_FILE` pointing at the payer's.
**R7 — `launchExtras` puts the lane OWNER's personal instructions** first in
every pane; a collaborator's session should get the starter's.
**R8 — AI Proxy.** `resolveProxy(tool, proxy, laneOwner)` resolves by owner: a
collaborator could spend the owner's proxy key. Resolve by the starter — and a
collaborator's OWN proxy profile is a valid "own account" besides a `token` pool
account (decision 1 should allow it).
**R9 — Other `owner_id = ?` call sites** to extend or keep owner-only, checked
one by one: `scope.js` (`laneWhere`, `canSeeLane`, `visibleLaneIds`,
`insideOwnWorkspace`), `auth.js` (demote/disable ends a user's host panes —
must also end runs they `started_by` elsewhere), `pool-usage.js`,
`member-tmux.js`, `me.js` counts, `users.js`.
**R10 — Simplification found:** the pty attach socket already re-checks its user
periodically ("still owning the pane's workspace", `websocket.js`), so revoking
a share closes live attaches by just changing the predicate — no new kill path.
## Answered questions (2026-11-06)
1. A collaborator's run uses **their own `token`-kind pool account, or is
refused** (`ENOCOLLABACCOUNT`) — never the owner's subscription.
2. Spend quota: **only the payer's**.
3. Resume by someone else: **the resumer pays from then on** (per-run billing,
`session_payers`).
4. Session-only share: **commit/push allowed**.
5. Analysts: **sharing gives write** in that workspace.
## Still to settle while building
- The per-hour rounding of `session_payers` (a run started at 10:40 by Bob in a
session Alice paid at 10:05): the hour goes to the earlier payer and Bob pays
from 11:00, or token rows get a finer key. Phase 2 measures how often it
matters before adding a finer key.
- Typing into a live pane started by someone else costs the run's starter. The
pane's header shows "billed to <name>" so nobody is surprised.