web: record a new Cloud machine's vm-kind at create, so vmHost defaults on

Merged
TE
10d ago
+764-9

What

A fresh cmux cloud new-machine VM now gets its /etc/cmux/vm-kind from the Cloud backend at create time. A personal machine gets owner and a team machine gets team. That turns acpmux's vmHost default on for the owner's VM.

Informational only; acpmux no longer trusts it (remote_sandbox.rs, hb-acpmux). The record says which kind the backend assigned at create; nothing relies on it as a boundary, since the guest's work user can change it.

Why

The design comes from the vmHost default in #25749: acpmux reads /etc/cmux/vm-kind and turns vmHost on only when it says owner. #25778's "Dedicated agent machine" switch also shows only on such a machine.

A fresh-VM proof on a nightly that contained #25749 found the default never turns on for real creates:

  • New Machine boots the devbox image cmux-devbox 2026-10-14-r1 (web/services/vms/images/manifest.json).
  • That image predates both guest writers of the marker: cmux-vm-kind-sync (#24594) and cmux-host's record_owner_kind.
  • So nothing writes the file, and host vm-host reads {"cmuxVm":false,"configured":null,"vmHost":false} forever.

A re-bake of the image will make this redundant; nothing here conflicts with it. On an image whose sync runs, the sync writes the kind it fetches from the same rule, and this write finds a file and does nothing. The guest-side writers (cmux-vm-kind-sync and cmux-host's record_owner_kind) are changed in #26235 so they never replace a record they did not fetch; that change is split out to keep this PR under 800 lines and lands independently.

Where the write lives

It goes in the backend's create workflow (web/services/vms/workflows.ts), with the logic in web/services/vms/guestVmKind.ts. The write is scheduled at the two places a machine is created: createVm and finishBaseCreate. Every caller passes the route's after-response hook, so the work stays alive after the response:

  • POST /api/vm (the app and cmux cloud new-machine) → createVm;
  • fork (/api/vm/[id]/fork) → createVm;
  • restore (/api/vm/restore) → restoreVm → createVm (the hook is new here);
  • Base open and reset (/api/vm/base) → openBaseVm/resetBaseVm → finishBaseCreate (the hook is new here);
  • the dogfood run's two creates (web/services/runs/dogfood.ts) → createVm (hook added);
  • persistent workspaces (freestyleWorkspaceComposition.ts) → createVm (hook added).

Without a hook (no caller here) the work forks as a daemon, as before. Writing it here:

  • needs no app or CLI release;
  • uses the backend's own ownership rule, the same one /api/vm/reflection/kind answers with.

How the write works:

  • Kind: resolveVmGuestKind, the same rule as /api/vm/reflection/kind. owner when owner_team_id is the creator's user id, team for any real team. A row with no billing owner gets nothing.
  • Never overwrites. It writes only when no file of that name exists, and a symlink or directory there also counts as existing. The write is link() of a finished 0644 temp file, so a marker that appears at the same moment is kept and no reader sees a partial file. Running it twice prints kept.
  • Root. It uses the provider's root exec (asRoot), the same channel and user as the prompt-name push, which already writes /etc/cmux.
  • Off the critical path, kept alive. It runs after the response through the route's runAfterResponse (after()/waitUntil), for POST /api/vm and for Base open/reset alike.
  • Bounded. It retries until the machine's exec answers: 1, 2, 4, 8 s, then every 10 s, 13 tries over 95 s of waits. A two-minute budget, exec time included, ends it in any case. A failure is logged and never fails the create.
  • The budget cancels the exec. The record's exec opts into cancelOnInterrupt: the provider gateway hands the Effect's AbortSignal to the Freestyle driver, which passes it to the HTTP client. When the budget or a retry ends an attempt, the Freestyle request itself is aborted, not only the wait. Other exec callers keep their current behaviour.
  • Same machine only. Every attempt first reads the machine's row again. It goes on only while the row is the same one: same id, provider id, user, owner and kind, and still running. A deleted, paused or rebound machine ends the work with no exec.
  • Clean on the guest. A trap removes the temp file on any exit the shell sees. A stale .vm-kind.* older than a minute (left by a kill) is swept first, before the existing-record check, so it is swept even when the record already exists. The link is ln -T, so it never writes into a directory, and written is printed only after the path reads back as a regular file holding the kind.
  • Freestyle only. GCP Labs machines keep their own bootstrap.

acpmux is unchanged: it still reads only the marker.

Tests

New tests are in web/tests/vm-guest-vm-kind.test.ts. The guest command runs against a temporary root, and the check copies acpmux's cmux_vm_at rule.

  • A fresh personal create reads cmuxVm false before the after-response work and true after it.
  • A team create writes team, which reads cmuxVm false.
  • An existing team, owner, empty or symlinked marker is never changed.
  • It is idempotent and leaves no temp file, and it creates a missing /etc/cmux.
  • A failing exec never fails the create: its after-response work runs to the end on the real schedule (on a test clock) and succeeds. An exec that never answers ends within the budget.
  • Base open and reset both write the marker after the response. Removing that wiring fails these tests.
  • A restore writes the marker after the response. vm-route-auth.test.ts now checks every provisioning route (restore included) passes the after-response hook; it fails without the restore hook.
  • An exec with cancelOnInterrupt against a local Freestyle server that never answers: the timeout aborts the HTTP request on the server side. It fails with the signal not forwarded.
  • A machine deleted before the first try, or during the backoff, gets no further exec. So does a provider id that now belongs to another row, another owner, or a paused machine.
  • A directory that appears at the marker path between the check and the link is never written into. A killed run leaves no temp file, and a stale one is swept, even when the record already exists.
  • Retries stop after the bound, and a non-zero exit is retried.
  • A GCP machine and a row with no owner get nothing.

vm-create-critical-path.test.ts now expects the extra after-response exec.

Run on Linux (fast checks):

  • 21 VM workflow, route, dogfood and persistent-workspace suites (each in its own process), 478 pass, 0 fail, vm-guest-vm-kind 27/27 three runs in a row;
  • tsgo --noEmit, eslint and the complexity check clean.

With the create, Base and restore call sites removed, the signal not forwarded and the sweep moved back after the early return, 10 of the new tests fail. Without the restore route's hook, vm-route-auth fails 1.

Credit: the record and the vmHost default it drives come from #25749. Links: #25778 (the Settings switch), #24594 (cmux-vm-kind-sync), #26235 (the guest writers keep an existing record).

Risk: low. A row read and a root exec after the response on each Freestyle create (retried for at most two minutes), which never fails the create and never replaces an existing marker. The exec cancellation is opt-in, used only by this write.

Agent session: agent session

🤖 Generated with Claude Code