Cloud link: the attested vmKind decides the VM boundary and app-started chats (owner only)

Merged
TE
9d ago
+732-138

Lands together with #26330 (chat move-in and folder trust), which is stacked on this PR, has its own review, and lands right after it.

The client side of the VM boundary. The app asks the Cloud API which kind each VM is for the signed-in account, and only the exact answer "owner" lets the remote side relax anything. The guest never decides.

The design, as agreed with the area owner: "the kind comes from outside the guest. The client side (the app or the daemon that starts the remote session) asks the Cloud API which kind the VM is, for the signed-in account, and passes that to the remote start; the guest never decides." And: "Agents keep sudo in Cloud VMs".

PR 3 of the series:

What changes

Cloud link (remote chains):

  • CloudAttachEndpoint.vmKind (from #26269) gets callerOwnsVM, which is true only for the exact "owner". Missing, null or any other string counts as not owner. A value of another type fails the attach, as it does for the Codex broker.
  • CloudMachineLink passes --vm-kind owner to cmux-tui remote connect only then.
  • cmux-tui remote connect --vm-kind owner makes the acpmux peer bridge's _acpmux/peer_lease ask for vmBoundary: true, which #26309 takes.
  • The answer is read per link and never saved. An older Mac acpmux ignores the extra lease param. The app bundles its own cmux-tui, so the new flag never reaches an older binary.

App-started chats (agent-session-start): on a Cloud VM, these no longer trust the guest's own /etc/cmux/vm-kind record.

  • A start goes ahead only on the caller's vm_kind: "owner", which is the Cloud API's answer for the link in use, sent per start. Any other value, an explicit null included, is refused. A caller that leaves the field out is treated as before.
  • The app reads the link's owner answer before it connects and again right before it sends, and refuses the start if the link changed or ended in between. The start goes over the same connection whose capabilities it checked; if that connection drops, the start fails rather than reconnecting.
  • Team evidence still refuses. Macs and SSH hosts need no word.
  • New capability: agent-session-start-vm-kind-v1. The app sends vm_kind only to a daemon that has it.
  • For a Cloud start through an older daemon, the app refuses locally unless the caller owns the VM. That way the guest's own record is never the only thing that allows it.

Settings > Machines, "Dedicated agent machine":

  • The switch shows only while the running link says the signed-in caller owns the machine. Before, it also showed on team VMs.
  • The help text now says what it does after #26276: "On: agents your Mac runs here through your own link skip the sandbox and may act without asking; the VM is the boundary. Off: they keep the sandbox." Other languages are marked needs_review.

The chat move-in and folder-trust verbs (agent-transfer-receive/finish, agent-session-trust) still take the guest's record, exactly as on base. This PR does not widen them: host_gate(None) allows the same machines base's gate allowed (an owner record, or a Mac or SSH host) and refuses the rest. A follow-up PR stacked on this one closes them. It needs this PR's callerOwnsVM, so it lands right after this one.

Risk

Medium. This is what makes "owner" mean unsandboxed on a user's own VM, together with #26276. It fails closed on every odd answer. A team VM, another account's VM, or an old server all keep the sandbox and ask before acting.

Tests

  • remote_cli.rs only_vm_kind_owner_marks_the_link_as_the_callers_own_vm: only the exact --vm-kind owner counts.
  • acpmux_peer.rs the_lease_asks_for_the_vm_boundary_only_when_told_the_caller_owns_the_vm.
  • tests/cli/agent_session_start.rs a_cloud_vm_start_needs_the_callers_owner_word_not_the_vms_record:
    • These give host_unverified: an owner record with no word, team, Owner, "", and "owner ". So do team and an explicit null sent to an SSH host.
    • "owner" passes the host check.
    • A team record plus "owner" gives team_vm_blocked.
    • An SSH host passes with no word.
    • The capability is advertised.
  • agent_session_start.rs unit test: the host gate across the host kinds and caller words.
  • Swift CloudVmKindTests:
    • callerOwnsVM for owner, team, null, Owner, empty and missing. A malformed value fails the decode.
    • The --vm-kind arguments.
    • vm_kind is sent only for owner on a capable daemon.
  • Swift AgentSessionStartOwnerLinkTests, against a stand-in daemon:
    • The same link sends owner.
    • A link that changed or ended before the send is refused, and nothing is sent.
    • With no answer, the start is refused through both a new and an old daemon.
    • An owner going through an old daemon gets no param.
    • A machine that isn't Cloud needs no answer.
    • A connection that drops after the last ownership read fails the start, with no reconnect.
  • Swift CloudLinkReconnectingTests: a link that ends no longer says the caller owns the machine.
  • Swift CloudVmHostTests: a team VM's answer gives no switch unless the caller owns it.

Before and after

On base, with only the new socket test:

test agent_session_start::a_cloud_vm_start_needs_the_callers_owner_word_not_the_vms_record ... FAILED
{"error":"the folder is not trusted on this machine","error_code":"agent_session.untrusted_folder",...}
  left: String("agent_session.untrusted_folder")
 right: "agent_session.host_unverified"

On base, the guest's own owner record let the start past the host gate. The other new tests call functions that base lacks.

The review-fix tests, each run against the code this PR had before its fix:

cmux-tui --test cli: a_cloud_vm_start_needs_the_callers_owner_word_not_the_vms_record ... FAILED
  (an explicit "vm_kind": null)  left: String("agent_session.untrusted_folder")  right: "agent_session.host_unverified"
Swift: ✘ Test aStartNeverReconnectsAfterTheOwnerCheck() ... "the start went out over a new connection"
       (identify count → 2) == 1

At this head (cmux-ci, Linux):

cargo fmt --all --check: ok
cargo clippy -p cmux-tui -p cmux-tui-core --all-targets -D warnings: ok
cmux-tui-core server::: 1068 passed
cmux-tui --test cli agent_session: 35 passed
--test cloud_move_journey 2, link_agent_move 12: passed
cmux-tui --lib remote_cli: 120 passed, 1 failed (cloud_help_lists_every_offered_cloud_action, which fails the same way on the tip)

Swift (fleet Mac): AgentSessionStartOwnerLinkTests 3, CloudVmKindTests 3, CloudCodexBrokerTargetTests 4, CloudVmHostTests 11, AgentSessionAttachClientPresenceTests 3 and CloudLinkReconnectingTests 2, all passed.

Screenshots

No capture: the only visible change is the Machines row's switch and its help text. Both show only on a live Cloud link that the Cloud API answers owner for, and a fleet capture mini has no such link.

Settings > Machines, before and after, for an owned VM and a team VM. Both are added here after landing. They need the real Cloud link (the owner answer comes only from the live attach endpoint), so they are taken on the nightly that carries this PR, together with the live proof below.

Live proof

Added after landing, once the nightlies carry #26309, #26276 and this PR on both ends (the guests and the caller). It will show a remote Claude on the signed-in account's own VM running unsandboxed and one on a team VM running in bwrap. Both VMs are picked from the Cloud API's answer for the account and named here only by kind. Each shows its hostname plus sandbox evidence. It runs through the real path only, with no hand-set vmBoundary.

Agent session: agent session
šŸ¤– Generated with Claude Code