18e574a765
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.
280 lines
17 KiB
Markdown
280 lines
17 KiB
Markdown
# 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.
|