Files
AgentHub/docs/plans/workspace-sharing
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
..

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)

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 " so nobody is surprised.