Cloud: Share Machine… and Share with Me for team machine chats

TE
5d ago
+2759-9

Leo, 2026-10-31, on team VMs refusing app-started chats: "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."

A bare-prototype client for the chat-grants backend (#29217, classic web backend, CloudConfiguration.linkSource = .legacy). A grant makes attach-endpoint answer vmKind: "owner" to that teammate, and cmux-tui (#29107) then admits their app-started chat on the team VM.

What it does

Owner side: Share Machine… on a Cloud machine's right-click menu (identity group, after Rename Machine…). It opens a palette page, "Share build-box", listing the team's members (from the Accounts team detail the app already reads) except the machine's creator. Shared teammates come first, with "Can start chats" under their name. Return on a row shares with that teammate, or on a shared row stops sharing: revoke is one click, and no control repeats per row. A toast says what changed. A grant whose teammate has left the team stays listed, so it can still be revoked. cmux cloud share-machine --target machine:<id> [--teammate <user id> [--revoke]] does the same from the CLI; without --teammate a CLI or agent run answers the teammates list with shared and can_share.

Refused side (the start-refusal card from #29145, agent_session.team_vm_blocked only): one more button, Share with Me, before New Cloud Machine (still there) and Retry. It calls the pane's new machine.share.self. That needs a real gesture. It PUTs a grant for the signed-in person, renews the machine's link so its next attach answer says owner, and the card then retries the start. Only a team admin's click gets that far. Anyone else gets vm_chat_share_forbidden: the card says only the creator or a team admin can share, and the same button turns into Ask the Owner.

What Ask the Owner does: it copies a short request to the clipboard and the card says so. The request reads: "Could you share build-box with me in cmux? Right-click it in the sidebar, choose Share Machine…, then pick me." The app has no teammate-messaging surface to open, so the clipboard was the simplest thing that works. After the owner shares, Retry on the card picks the share up. Once the owner was asked, Retry calls machine.share.self again first: a refused PUT checks the grants, and if the person is on them the link renews before the start retries.

The card has at most three buttons, and only one of them is a share button at any time.

Where the logic lives

  • Shared web (webviews/src/agent-session/acpmux/machineShare.ts): which refusal offers sharing, and how the Cloud API's code maps to the card's next step (vm_chat_share_forbidden → Ask the Owner; vm_chat_grants_personal_machine, vm_chat_grants_unavailable → their own lines; anything else → "Couldn't share this machine: …"). It's keyed on codes, never on words.
  • Swift is the forwarding shell plus small pure helpers. CloudAPIClient+ChatGrants.swift makes the three HTTP calls on the existing classic client, with the same Stack session and X-Cmux-Team-Id auth path as every /api/vm call. Each id is one encoded path segment. CloudMachineShareRoster builds the page's rows. CloudMachineShareRefusal maps codes. The server makes the who-may-share decision (creator while still a member, or team admin); the client only words the answer. None of this is in Rust: the Rust side never sees the classic Cloud API's session, so moving it there would mean shipping the Stack refresh token to cmux-tui.
  • CloudMachineLink.renew() ends the running link, so the next socketPath() asks the Cloud API again (ownership is read once per link start). Terminals on that machine reconnect as after any dropped link.

Labs

None. Leo asked for this feature, so it ships on by default.

Deploy

None for the app. The backend's chat-grants migration is being run separately (cloud-vm-migrate).

Tests

  • Web: machineShare.test.ts covers the action mapping and the client call. SwitchNotice.share.test.tsx is a DOM test: Share with Me → retry; forbidden → Ask the Owner → copies a request naming the machine; after asking, Retry rechecks the share first; other refusals offer no share; a new refusal starts over. startRefusal.test.ts is unchanged and passes.
  • Swift: CloudMachineShareTests covers the routes and methods through a URLProtocol stub (with the encoded path segments), a 403's code surviving to the pane reply, the code round trip, the roster's order and filtering, and creator-or-admin.
  • Gallery: the agent-pane.switch-notice teammate-machine variant now shows Share with Me. A new teammate-machine-share-refused variant plays Share with Me → Ask the Owner. The share page is a native palette page, so it has no gallery entry.
  • New strings are in all 21 languages: 9 pane strings, 14 app strings in the new CloudShare.xcstrings, and 3 action and argument strings. Placeholders are kept and check-l10n.sh reports 0 errors.

Size

About 840 changed lines of code and tests. The other ~1,850 are the new CloudShare.xcstrings table, which holds 21 languages.

Risks

  • Renewing the link drops and redials the machine's link for one reconnect. It only happens after a successful grant for the signed-in person.
  • The page reads the team from the active team (auth.teamID), the team the classic list is scoped to. If a machine isn't on that team, the server refuses the grant.

Agent session: agent session

🤖 Generated with Claude Code