cloud enroll-machine: ssh from this Mac through the app's tunnel, and an acpmux peer

Merged
TE
10d ago
+709-11

Leo (2026-10-25): "One command for a persistent machine: cmux cloud new-machine --persistent --size ..., with built-in bootstrap, no idle pause, and automatic acpmux and fleet-registry enrollment. Then use it to create the lane VMs in machines/lane-vms.md, and make lane-place able to create them on demand."

cmux cloud enroll-machine --target machine:<id> --name lane-vm-1 [--ssh-key FILE.pub]

Run it on the Mac with the signed-in cmux app. It makes a Cloud machine an ssh host and an acpmux peer of that Mac. These are the enrollment steps of #25217's cloud new-machine, which prints this command as enrollment.enroll_command.

  • On the machine, over the exec API (the signed-in person's authenticated link):
    • It adds this Mac's ed25519 key to the cmux user's authorized_keys, whichever account the exec runs as. It adds the key once (a whole-key match that ignores comment lines), adds a newline first if the file doesn't end in one, and runs under set -eu.
    • It starts sshd and prints the machine's host key. That key is pinned from this authenticated answer, so there is no trust on first use.
  • On this Mac: a marked Host lane-vm-1 block in ~/.ssh/cmux-cloud/config.
    • HostName <private address>, User cmux, Port 22.
    • ProxyCommand '<app cmux-tui>' wg dial --hub '<stable hub link>' %h %p.
    • HostKeyAlias cmux-<machine id>, with the pinned key in ~/.ssh/cmux-cloud/known_hosts, StrictHostKeyChecking yes, GlobalKnownHostsFile none and KnownHostsCommand none. Only the pinned key vouches for the machine.
    • ControlMaster no and ControlPath none, so a shared master from an earlier enrollment of the same name can't answer.
    • IdentityFile (the private half of --ssh-key, which must be *.pub) and IdentitiesOnly yes.
    • The folder is opened O_NOFOLLOW|O_DIRECTORY and locked through that descriptor. One enrollment at a time edits it, and its files are 0600, written through a temporary file and a rename.
    • The markers must pair up. Damaged markers are refused, not guessed.
  • The person's ~/.ssh/config is never written. When there is none, one is created holding only the Include line (a temporary file, then link(2)). Otherwise OpenSSH decides: ssh -G NAME must show exactly this route (address, user, port, alias, proxy, known_hosts, no global known hosts, no master).
    • Only then does the acpmux step run.
    • If it doesn't match, steps_pending names the one Include line to add first, and a rerun picks it up.
  • cmux wg dial --hub PATH HOST PORT (new, Rust): one TCP stream through the app's WireGuard hub (SOCKS5 CONNECT), on stdio, for ssh's ProxyCommand. When the input ends it half-closes, and the peer's answer still drains.
  • The hub keeps a stable symlink (CloudPaths.hubLink) to its per-launch socket.
    • The link moves before the start counts as done; a failure fails the start, and a moved link is re-pointed.
    • A marker file records that an ssh route depends on the hub. Then the hub starts after each successful machine list (sign-in, Cloud re-enabled, periodic refresh) whenever Cloud is available.
    • If its process ends, it starts again, at most 3 times in 10 min.
  • acpmux: acp host enroll NAME --harness claude --harness codex. That command installs only this Mac's platform, so for a Linux machine (whose acpmux comes from the image or the lane bootstrap) it falls back to acp host add NAME. The peer counts as done only when it is connected; the harness profiles then stay pending.
  • The answer: machine_id, name, ssh_host, address, steps_done, steps_pending, rerun_command, and registry_command (tools/lane-vm-enroll ... --apply in leo-workspace). After 30 s the same fields come back with pending: true and the work keeps going. A rerun is idempotent.

So ssh lane-vm-N and reach lane-vm-N work from that Mac while its cmux app runs. Other hosts reach the VM through that Mac.

Threat model: a process that can write the person's ~/.ssh already owns their ssh. Races against such a process inside ~/.ssh/cmux-cloud are out of scope. Within that: no symlink is ever followed, and enrollments are serialized.

Changelog

Split to stay under 800 changed lines: cmux wg dial is #25493 and the 21-language strings are #25494. Both have landed, and this branch now includes them from feat-cmux-next. It is 720 changed lines.

  • Added: cmux cloud enroll-machine makes a Cloud machine reachable as ssh NAME from this Mac, through the app's tunnel with its host key pinned, and adds it as an agent host.

Observed / Not run

  • Ran here:
    • prepush-gate.py: no new violations.
    • rustfmt on the Rust file.
    • ssh -G (OpenSSH 9.6) against the generated block shapes, which confirmed how each checked key prints.
    • The authorized_keys edit, run twice on a file with no final newline: one newline and the key were added once. A commented-out key reads as absent; an options ssh-ed25519 KEY line reads as present.
  • Review: Codex gpt-6-astra at xhigh through SR2, 10 rounds, SHIP at ce1aabe916a2767060da9b4a64cf02d58351aa05. A read-only compile pass found one compile error, a missing public, which is fixed.
  • Review (codex gpt-6-astra xhigh, independent): SHIP at a784ecbcd68b31b1fef9b4f9b250f483f06983a7. An automated next-review then blocked there on two P2s, both fixed in 454d14c0:
    • the hub's exit watcher no longer schedules a second start when a caller already began one;
    • a public key whose comment has several words (or tabs) is accepted.
      A full Codex pass at 454d14c0 confirmed both fixes. A confirming pass after feat-cmux-next (with #25493) came in is SHIP at a33d4329e97392da707308d4597447b50d6c5ebe.
  • Fleet (Mac): CloudEnrollMachineTests and ActionCatalogTests passed. ActionSurfaceParityTests failed only everyMenuKeepsAShortTopLevel on .sidebarBackground, a menu this PR doesn't touch.
  • Not run: a live enrollment (it needs a signed-in app and a Cloud machine).

No capture: CLI-only (one new palette row, Enroll Machine as Agent Host, and no new UI).

Timers/loops: the hub's exit watcher ends when the hub process does. It restarts the hub only under the same generation, at most 3 times in 10 minutes, and stop()/revoke() end it by bumping the generation. There is no polling.

Risk: high by rule ("adds a delete of files"). The deletes remove only files this code creates: its temporary files and the staged hub-link symlink. It lands on an independent review receipt on the exact head plus a green compile (docs/ci/risk-tiers.md).

Agent session: agent session

šŸ¤– Generated with Claude Code