web: tell a Cloud machine whether it is personal (vm-kind owner) or shared
Summary
Move to Cloud stops on every production Cloud machine with "cmux can't confirm who owns " (agent_session.host_unverified). cmux-tui on a Cloud VM only starts or receives a chat when the root-owned /etc/cmux/vm-kind reads owner, and nothing on the web devbox ever wrote that record. This PR adds the control-plane half: a machine can now ask GET /api/vm/reflection/kind (also https://reflection.cmux.internal/kind) and get {"vm_id": ..., "kind": "owner" | "team"}. The guest writer and the image rebake that use it are in the companion devbox PR.
The answer comes from the machine's own row, identified by the platform edge exactly like /reflection/name (requireVmPrincipal); the guest supplies nothing but the placeholder bearer.
owner: only the creator can reach the machine. That is a machine on the creator's own account (owner_team_idequals the creator's user id), or a machine on a Stack team whose only member is the creator.team: anything else, including a team with any other member, a team the creator left, and a deleted team.- A failed or timed-out member lookup answers a retryable
503 vm_kind_unavailable, neverowner. The guest keeps its last record.
Member lists are cached per team for 15 s per server instance (successful reads only, bounded at 1,000 teams), so a fleet re-asking every minute costs at most four Stack reads per team per minute per instance.
Security
Who can write vm-kind: only root on the machine. The companion PR's writer runs as a root systemd unit and writes only what this route answers. cmux-tui ignores a record that isn't root-owned, or whose folders are group- or other-writable or symlinks.
Why a user can't plant owner through this route: the route never reads anything the guest sends. The machine is identified by the signed header the platform edge injects for that machine (vmPrincipal.ts). A request from another machine resolves to that machine's own row, and a request from outside a machine has no credential (401).
Why a team machine can't get owner: owner needs the owner team to be the creator's own account, or Stack to list exactly one member, the creator. Any second member, a lookup error, or a missing team gives team or a 503. Membership can change after create, so the answer isn't fixed. The guest re-asks every minute, so a machine whose single-member team gains a member turns team within about 75 s (15 s cache plus the 60 s poll); the guest also drops its record after five minutes without an answer, so a Stack outage can't keep a stale owner. One limit, stated once: a team member already has root on a shared machine, so this gate keeps the app from moving private chats onto shared machines but doesn't contain a member. cmux-tui says the same in agent_start_host.rs.
Testing
- Red at 286676ad09cd (test only):
cd web && heavy --mem 6G bun test tests/vm-kind-route.test.tsfails withCannot find module '../app/api/vm/reflection/kind/route'. - Green at 509e5310435e and 4275a7de3bee: the same command passes (6 tests; 4275a7de3bee keeps them green after shortening the cache), covering personal account, single-member team, multi-member team, creator-left, deleted team, lookup error to 503, and one Stack read per team per minute with no cached failure.
tests/coderouter-request-telemetry.test.tsandtests/vm-reflection*.test.tsalso pass (61 tests across 6 files). bun run typecheck,eslinton the changed files, andbun run lint:complexitypass.- Not run: a live request from a real machine. The guest side needs the rebaked image from the companion PR.
Changelog
Fixed: Move to Cloud no longer refuses a personal Cloud machine as unverified, once the machine runs the updated image
🤖 Generated with Claude Code