Cloud: share chat starts on a team machine with a teammate
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_idcascades with its machine. Migrationweb/db/migrations/20261031120000_cloud_vm_chat_grantsis replay-safe (creates only what is missing). - Routes (same
withAuthedVmApiRouteauth and account/team scoping as the other/api/vm/[id]/*routes, machine lookup throughrequireUserVm):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 Stackteam_admin(through the existingresolveTeamBillingAccessadmin check). A plain member cannot grant or revoke.- PUT checks the target is on the owner team's member list through the same bounded
listTeamMemberIdsWithTimeoutlookup the attach route uses; the creator and padded or empty ids are refused (400), a non-member is 409, a personal machine is 409.
vmKindForCallertakes an optional{ chatGranted }:ownerfor a granted non-creator only on a Stack team machine and only when the caller is on the member list read for this attach.callerVmKindreads the grant (repo.hasVmChatGrant, 3 s bound) only when the attach route passesreadChatGrants: true. A failed, slow or missing read counts as no grant, so the answer falls back toteam(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-kindcases unchanged and passing). The grant read only happens for a non-creator on a team machine when a directory is present. resolveVmGuestKindand 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 isowneron both attach transports; no grant, grantee who left, failed or never-answering reads stayteam; withoutreadChatGrantsthe grant is never read; personal machines ignore grants.web/tests/vm-route-auth.test.ts: attach route passesreadChatGrants: true.- Fleet run of the full web suite at this tree: 5943 pass, 0 fail;
tsgo --noEmitand 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](https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v3-3x.png)
![Autofix with [code]smith](https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark-3x.png)
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 routesGET /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.