Cloud: share chat starts on a team machine with a teammate

TE
5d ago
+826-26

Leo, 2026-10-31: "but what's the sharing flow? we can't simply block always. we need to be able to get stuff running asap. lots and lots of bare prototypes so we can evolve our app as we go."

What

A machine's creator, or a team admin, can share app-started chats on a team Cloud machine with one named teammate. The attach endpoint then answers vmKind: "owner" to that teammate while the grant stands and they are still on the machine's team, so the Mac app sends vm_kind: "owner" and cmux-tui's guarded start accepts them (with cmux#29107 accepting owner on team VMs). Revoking is one DELETE.

  • Table cloud_vm_chat_grants(vm_id, user_id, granted_by, created_at), unique on (vm_id, user_id), vm_id cascades with its machine. Migration web/db/migrations/20261031120000_cloud_vm_chat_grants is replay-safe (creates only what is missing).
  • Routes (same withAuthedVmApiRoute auth and account/team scoping as the other /api/vm/[id]/* routes, machine lookup through requireUserVm):
    • GET /api/vm/{id}/chat-grants → { grants: [{ userId, grantedBy, createdAt }] }, for anyone who can reach the machine.
    • PUT /api/vm/{id}/chat-grants/{userId} → { grant }. Idempotent (an existing grant is kept).
    • DELETE /api/vm/{id}/chat-grants/{userId} → { revoked: boolean }. Idempotent.
  • Policy mirrors cmux-platform machineAccess (leo/owner-machines, src/server/machines/Access.ts):
    • start_chat: the creator, or a current member of the machine's team holding a grant. Never on a personal-account machine (ownerTeamId === creator); classic web has no private visibility, so personal is the only exclusion.
    • share_chat (PUT and DELETE): the creator while still on the team, or a Stack team_admin (through the existing resolveTeamBillingAccess admin check). A plain member cannot grant or revoke.
    • PUT checks the target is on the owner team's member list through the same bounded listTeamMemberIdsWithTimeout lookup the attach route uses; the creator and padded or empty ids are refused (400), a non-member is 409, a personal machine is 409.
  • vmKindForCaller takes an optional { chatGranted }: owner for a granted non-creator only on a Stack team machine and only when the caller is on the member list read for this attach. callerVmKind reads the grant (repo.hasVmChatGrant, 3 s bound) only when the attach route passes readChatGrants: true. A failed, slow or missing read counts as no grant, so the answer falls back to team (fails closed).

Default-safe

  • Existing callers see no change: the creator path and every non-granted caller answer exactly as before (all existing vm-attach-vm-kind cases unchanged and passing). The grant read only happens for a non-creator on a team machine when a directory is present.
  • resolveVmGuestKind and the reflection route are untouched.
  • The new repository methods are optional on VmRepositoryShape, so no fake has to change.
  • Before the migration runs, the grant read fails and is treated as none (today's answer); the new routes answer an error until the table exists.
  • Not changed: account deletion does not delete a deleted user's grants. Such a grant is harmless, because the member check fails once the user is gone and machine deletion cascades. [follow-up]

Deploy (the one outward step)

After merge: run the Cloud VM DB migration workflow (.github/workflows/cloud-vm-migrate.yml, workflow_dispatch, target production, which migrates staging first) to create cloud_vm_chat_grants. web deploys from main as usual. The order doesn't matter for safety (see above).

Tests

  • web/tests/vm-chat-grants.test.ts (new): creator and admin grant, plain member refused, creator who left refused, target must be a current member, personal machine refused, Stack failure or timeout is 503, idempotent grant and revoke, revoke after the grantee left.
  • web/tests/vm-attach-vm-kind.test.ts: a granted member is owner on both attach transports; no grant, grantee who left, failed or never-answering reads stay team; without readChatGrants the grant is never read; personal machines ignore grants.
  • web/tests/vm-route-auth.test.ts: attach route passes readChatGrants: true.
  • Fleet run of the full web suite at this tree: 5943 pass, 0 fail; tsgo --noEmit and eslint on the changed files clean.

The client UI (Share machine…) is a separate PR.

Agent session: agent session

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.


Summary by cubic

Lets a machine's creator, or a team admin, share app-started chats on a team Cloud machine with one teammate. The attach endpoint answers vmKind: "owner" to that teammate while the grant stands and they remain on the machine's team, so the guarded chat start accepts them; revoking is a single DELETE.

Behavior and policy

  • Adds cloud_vm_chat_grants (unique per machine and user, cascading with the machine) and the routes GET /api/vm/{id}/chat-grants, PUT/DELETE /api/vm/{id}/chat-grants/{userId}.
  • Only the creator (while still on the team) or a Stack team admin can grant or revoke; a plain member cannot, and the target must be a current team member.
  • Personal-account machines never accept grants; a missing, failed, or slow Stack or grant read counts as none, so non-granted callers see unchanged answers.
  • The client UI (Share machine…) is a separate PR.

Deploy

  • Run the Cloud VM DB migration workflow after merge to create cloud_vm_chat_grants; deploy order does not matter for safety.