Cloud attach-endpoint vmKind: owner for the creator's machine on a one-member team

Merged
TE
6d ago
+172-24

HOLD: security policy (who counts as owner on classic attach) awaits Leo

Why

On the classic Cloud route (/api/vm, every release and nightly build of cmux NEXT), cmux cloud start-session and the app's agent chats on a Cloud machine are refused with agent_session.host_unverified ("could not confirm what kind of Cloud machine it is").

The cause is the attach answer. POST /api/vm puts a new machine on the caller's selected Stack team (web/services/vms/auth.ts resolves billingTeamId to the Stack team, and falls back to the user id only for an account with no Stack team at all). So a person working on their own personal Stack team gets ownerTeamId = that team's id. vmKindForCaller answered team for every Stack team, so the Mac app never sent vm_kind: "owner", and cmux-tui's start gate refused. The machine's own record said owner (resolveVmGuestKind already treats a one-member team as owner). That combination is exactly host_unverified and not team_vm_blocked.

What changes (reverses #26222's one-member choice)

#26222 made the attach answer stricter than the guest's: "Every Stack team, a one-member team included, is team". This PR reverses that one point:

  • vmKindForCaller(row, caller, members?) answers owner for a machine the caller created (row.userId === caller, exact) on a Stack team whose member list, read for this request, is exactly [caller].
  • The list comes from the same bounded read resolveVmGuestKind uses (listTeamMemberIdsWithTimeout, 3 s). It is read on every attach and never stored, so a second member joining makes the next attach answer team.
  • Fail closed: no directory, a failed read, a timeout, a missing team, an empty list, or any other member gives team, never owner.
  • Everything else stays as it was. A personal-account machine (ownerTeamId === caller) is still owner without asking Stack. A machine the caller did not create is team whatever its team. A blank ownerTeamId gives null. Ids compare exactly.
  • Stack is asked only for a machine the caller created on a Stack team (vmKindForCallerNeedsMembers). The read is forked as soon as the machine is known and joined at the end, so it overlaps the provider endpoint open instead of adding to it.
  • The attach-endpoint route (cmux-remote and websocket) passes vmTeamDirectory(). The sessions route does not return vmKind and is unchanged.

Why it is acceptable: owner still means "the caller created it and nobody else can reach it", the same rule the guest already applies to /etc/cmux/vm-kind. The one-member check runs live per attach, like the guest's per-minute re-read. A team that gains a member turns team on the next link. The Mac app reads the answer per link and never saves it.

Risk (security policy): this decides when the Mac app gives up its local sandbox for its own session on a Cloud machine. There is a window between a second member joining and the person's next attach, during which an already-running link keeps its owner answer. The guest has the same window, up to its one-minute re-read.

Tests

web/tests/vm-attach-vm-kind.test.ts, through both openVmCmuxRemote and openAttachEndpoint plus vmKindForCaller directly:

  • the caller's machine on a one-member own team: owner (the list is read per attach, for the machine's own team);
  • a two-member team: team;
  • a machine the caller did not create, on the caller's one-member team: team, and Stack is not asked;
  • an empty ownerTeamId: null;
  • an unread, failed, missing (null), empty or timed-out member list: team;
  • the personal machine never asks Stack.

web/tests/vm-route-auth.test.ts: the cmux-remote route passes the team directory.

Results are posted in a comment below (cmux-ci, Linux).

Refs #26222

🤖 Generated with Claude Code

Agent session: agent session


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


Summary by cubic

Fixes the Cloud attach endpoint answering team for a caller's own machine on a one-member Stack team, which made cmux-tui refuse agent sessions on the classic route with agent_session.host_unverified.

  • vmKindForCaller now answers owner only when the caller created the machine and the team's member list, read per attach with a bounded timeout, is exactly them; a second member, missing team, failed read, or a machine the caller did not create still answers team.
  • The member read runs only for machines the caller created on Stack teams and overlaps the endpoint open; personal machines never ask Stack.
  • Both cmux-remote and websocket attach routes pass the team directory; the sessions route is unchanged.
  • Tests cover the same machine flipping from owner to team when a second member joins on the next attach.
  • The route auth test now asserts the team directory type-safely.

Summary by CodeRabbit

  • Bug Fixes
    • VM ownership is now identified more accurately for machines in Stack teams. A creator’s machine is shown as personally owned only when the membership information read during attachment lists that creator as the team’s sole member; otherwise, it remains team-owned.
    • If team membership information is unavailable or cannot be retrieved, the machine is treated as team-owned.
    • Personal machines continue to be identified without requiring team membership information, and machines attached by someone other than their creator remain team-owned.