Workspace & session sharing — plan
Status: self-reviewed 2026-11-06 — BLOCKED on finding R1 (below). 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. |
| 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 --globalof the owner's account). AgentHub'saudit_logrecords who clicked; git does not. - Files written are the owner's. Everything a collaborator's session
creates belongs to
ah-alice. - The owner's
~/.claudeapplies — 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)
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
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: addOR col IN (SELECT session_id FROM session_shares WHERE user_id = ?)and sessions of shared lanes (through the same lane predicate).PATH_RULES/namedIdsneed no new rule (they callcanSeeLane/canSeeSession); a new route under a new prefix (/api/shares) gets its own rule.- Keep
memberIdas is: an analyst still reads everything and writes only what they own or collaborate on.
- Session ownership & billing ("whoever opens it pays")
spawnRuntakesstartedBy = req.user.id; writesdashboard_runs.started_by.bindRunSession(server/routes/hooks.js) stampssessions.owner_id = started_bywhen it differs from the lane owner.sessions.owner_idalready exists (remote clients) andscope.js,session-account.js,users.jshonour it.server/routes/users.jsownership CTE:sessions.owner_idmust WIN over the lane owner (today it is a UNION — a session could count twice). Same inuser-quota.jsspend (hourlyTokenRows+calculateCost, never a second formula).- Spend quota:
assertSpawnAllowed(startedBy)instead of the lane owner, andhook-handler.js'sUserPromptSubmitcheck 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 asAGENTHUB_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.hourlyTokenRowsbooks a session's tokens per UTC hour already; each hour goes to the payer whosefrom_atis 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_idkeeps meaning "who opened it" (visibility);session_payersonly moves money.
- 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 kindtoken: it goes in asCLAUDE_CODE_OAUTH_TOKENthrough 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:spawnRunrefuses with409 ENOCOLLABACCOUNTbefore 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, CursorCURSOR_API_KEY); a ChatGPT login (written into the home asauth.json) is refused like a Claudelogin.
- The collaborator's pool pick (
- Pane token. A collaborator's session gets a pane token of the
collaborator, scoped to that lane:
dashboard_runs.pane_token_hashplus ascope_lane_id, anduserFromPaneTokenrefuses anything outside that lane.peerIsPaneAccountkeeps checking the folder's account (ah-alice) — the token is accepted from that uid because the pane runs as it. - 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.
- WebSocket —
broadcastAudience(server/websocket.js): every lane- or session-scoped message also reaches the lane's/session's collaborators. A new typeshares_changed(owner + affected user). - 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.
- 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.confirmDialogon Share,toaston 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
- 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.
- S2 — Collaborator runs on their own account, billed per run.
started_by,sessions.owner_idstamping,session_payers, users.js / quota precedence (and the double count fixed), pane token scope, pooltokenenv,ENOCOLLABACCOUNT. - S3 — Session-only sharing.
session_shares, its scope rules and UI. - 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 byagenthub-provisionlike a member, its own home, git identity, logins = the pool'stokenkinds only). Every member of the team works there; nobody's personal home is exposed. Real isolation; more work (provisioning, git identity per team,assertOwnerFitslearns 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)
- A collaborator's run uses their own
token-kind pool account, or is refused (ENOCOLLABACCOUNT) — never the owner's subscription. - Spend quota: only the payer's.
- Resume by someone else: the resumer pays from then on (per-run billing,
session_payers). - Session-only share: commit/push allowed.
- 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 " so nobody is surprised.