Files
AgentHub/docs/plans/workspace-sharing/README.md
T

10 KiB

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)

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)?