Files
Lahfir 0217868a58 feat(sandbox): add the macOS VM sandbox (#506)
* feat(sandbox): add the macOS VM sandbox

1. There was no reliable way for an agent to get its own macOS desktop to drive.
2. Ships the macOS VM sandbox: built and signed on the user's Mac, set up by hand once, then instant clones with agent-desktop running inside.
3. Builds on #505.

## Demo

A set-up sandbox is cloned, started, driven with agent-desktop inside it, then stopped and removed.

https://github.com/user-attachments/assets/1cf6a806-82ed-419f-b5ed-72bf0cf4e98f

* fix(sandbox): remove the download directory before the macOS install

A catalog install left its download directory in the VM bundle, and vmhost install refuses a bundle with anything but its own bookkeeping files, so fresh installs failed. The install job now removes the directory once the image is published.

* fix(sandbox): escape backslashes in macOS VM provider replies on every awk

json_str doubled a backslash with a gsub replacement whose meaning differs
between awk implementations, so on some of them a message holding a host
path with a backslash came out as invalid JSON and core hid the real
error. It now escapes one character at a time with plain string
concatenation, under the C locale so odd bytes cannot abort awk.

* refactor(sandbox): rename the macos-vm provider to macos

The provider is now named macos, so its directory, the provider table,
the CI step, the docs page (now /desktop/sandboxes/macos), the skills and
the provider's own messages use that name.

The provider's suggestions and the docs this branch adds now point at
`agent-desktop sandbox install`, since install is moving under the
sandbox command on the base branch. Until that lands, the `next` hint a
provider returns names a command the CLI does not have yet.

* docs: list the sandbox command in the docs site command reference

The site still counted 60 commands in 11 groups. sandbox is the 61st, so
the counts are updated and the index links to the sandbox commands.

* fix(sandbox): keep the setup time when a status probe finds the guest ready

Every successful status probe rewrote setup_done and reset its mtime, and
create picks the automatic clone source by that mtime. Probing an older
running sandbox made the next create clone it instead of the newest set-up
one. The marker is now written once, when setup first completes.

* fix(sandbox): report a missing VM host, empty settings and failed boots

- status and exec say the VM host is not installed when vmhost is
  missing, instead of reporting a running guest as still starting.
- create rejects a set but empty ipsw, from or user setting instead of
  treating it as not passed.
- start answers running only after vmhost writes vmhost.started, which
  it does once machine.start succeeds. A boot failure is now a failed
  start, and the reason stays in vm/vmhost.log.

* fix(sandbox): name the install log, bound downloads and answer stop inside its budget

- A failed install's status now points at vm/install.log, which rm deletes.
- The image and catalog downloads time out on a stalled connection instead
  of holding the install job, and rm, forever.
- The stop wait ends at 24 seconds so its TIMEOUT reply lands before the
  30 second budget kills the script.

* fix(sandbox): keep guest exit codes and macOS VM state honest

- exec passes the guest's own exit status through, including 255. The
  guest command reports its status on stderr, so an ssh 255 without that
  report is still a Remote Login failure.
- exec stages each copy under a per-call name and removes it when a copy
  fails. Concurrent execs no longer move a half-written file.
- exec reports a 255 from the hash probe as Remote Login, not as a copy
  failure.
- create rejects unknown --set keys before touching the sandbox, so a typo
  does not start the image download.
- status reports an error when the setup record cannot be written instead
  of claiming guest setup is complete.
- The install job runs in its own process group, and liveness checks the
  group. Installer children keep the bundle reserved if the job shell dies.
- vmhost run takes an exclusive flock on vmhost.lock, so two hosts cannot
  boot the same disk image.

* fix(sandbox): close four macOS VM gaps in start, status, the setup window and the address lookup

- rm and stop refuse while a start is still launching the VM host, so
  neither acts on a sandbox whose host has not recorded its pid yet.
- A set-up sandbox whose guest has an address but refuses Remote Login for
  two minutes reports needs_setup and names the cause, instead of
  answering 'still starting' forever.
- Closing the setup window shuts the VM down, so the next start reopens it.
- The address lookup takes the newest lease for the VM and returns none
  when a later lease gave that address to another machine.

* fix(sandbox): give the macOS provider one explicit state machine and an explicit create source

Each verb worked out the sandbox's state on its own from marker files,
so every review found a verb that missed a state. One `sandbox_phase`
function now derives the phase (installing, failed, stopped, starting,
running) and every verb switches on it.

- `create` needs exactly one source: `--set ipsw=latest`, `--set
  ipsw=<path>` or `--set from=<sandbox>`. The implicit download and the
  newest-sandbox guess are gone, and so are the shared image cache, the
  download resume and the size probe. `from` cannot be combined with
  `ipsw` or `user`.
- `ipsw=latest` only takes an image whose major version the host can run.
- `stop` and `exec` no longer treat an installing sandbox as stopped, a
  pid file left by a dead VM host reads as stopped instead of broken, and
  `start` answers `running` only once the guest answers.
- SSH ignores the user's ssh config and every call has keepalives.
- The VM host appends to install.log instead of replacing it, reports why
  an install failed, and ignores dot files in the bundle directory.
- A failed clone reports the VM host's reason.

The provider suite is now a phase-by-verb table plus the create matrix.
Net: about 1,000 fewer lines.

* fix(sandbox): reuse the built VM host across upgrades, and reopen the window when a guest loses its setup

- The built VM host now lives in a directory keyed by its own source, so
  an agent-desktop upgrade that leaves the VM host unchanged keeps it.
  Before, every upgrade moved the provider directory and the next start
  reported the VM host as not installed.
- A set-up sandbox that loses Remote Login or the Accessibility grant had
  no way back to its window, because start always ran it headless. Status
  now drops the setup record and says to stop and start, which opens the
  window again.

* fix(sandbox): keep at most 64 KiB of guest stderr on the host during exec

The guest decides how much it writes to stderr, and exec spooled all of
it into the sandbox directory before reading it. A compromised guest
could fill the host disk outside its own virtual disk. The stream now
passes through a 64 KiB tail, which still carries the exit marker.

* feat(sandbox): start a macOS sandbox in a window with an optional title

A set-up sandbox always started headless and nothing could change that.
`sandbox start <name> --window` now shows the guest desktop on the host,
and `--set title=<text>` names the window. A sandbox already running
headless is refused until it is stopped, and an unknown start setting is
an argument error.

* fix(sandbox): keep the VM host delegate alive for the machine's lifetime

VZVirtualMachine holds its delegate weakly, and the delegate was a local
whose last use was the assignment, so nothing guaranteed it outlived the
run loop. A guest shutdown could never reach guestDidStop or
didStopWithError, leaving vmhost alive and status stuck at running. The box
that owns the machine now owns its delegate and attaches it at init.

Verified by building providers/macos/vmhost.swift with swiftc -O.

* chore: remove the sandbox probe ledger

The file recorded measurements, including an unmeasured install path, that
the provider code and docs no longer cite.

* docs: run container desktops through agent-desktop-run

Both guides made the reader start a desktop session, wait for it to write
its addresses, and source those addresses in every later command. One
wrapper now does it: it starts the session on first use, reuses it, and
starts a fresh one when it or the container is gone, then execs the
command inside it. The guides show that command and say plainly that the
raw binary has no session.

Verified the published wrapper against a real image with the released
1.0.2 Linux binary: launch and snapshot both return ok with ref_count 271,
a cold or concurrent start leaves one desktop, a killed Xvfb is replaced,
and a restarted container gets a new session on the next command.

* docs: state what a sandbox does and does not isolate

The pages implied isolation without saying that every sandbox on a host
shares its virtual network, that clones share the image's SSH host key,
or that the key in the sandbox directory is the guest credential. Say
those, plus the Accessibility and hypervisor facts, in the macOS page, the
overview, and the shipped desktop skill, and give the rule: one trust
level per host.

* fix(sandbox): give every macOS sandbox its own SSH host key

A clone inherited the source VM's SSH host key, so any guest on the host's
shared vmnet that answered for another sandbox's address could impersonate
it, and the first connection to every sandbox was trust-on-first-use.

Before a start or a clone boots the guest, the provider now writes a fresh
ed25519 host key into the stopped guest disk, in place so root ownership
and modes stay, keeps the public half in the bundle, and pins it in
known_hosts once the address is known. SSH then uses
StrictHostKeyChecking=yes for that sandbox and refuses any other key. A
guest disk that cannot be opened fails the start or clone instead of
falling back to the shared key. A freshly installed image has no host key
until Remote Login is enabled, so its first setup connection stays
trust-on-first-use.

Verified on a booted macOS guest: an existing sandbox rotated to a new
key, a clone got a different one, each strict connection ran commands, and
a wrong pin was refused.

* ci: build the docs site on every change

Nothing built apps/docs in CI, so a broken page or MDX change could only
surface in a Vercel deploy. Add a docs job that installs with the locked
pnpm version and runs the production build.

* test(sandbox): cover the per-sandbox host key in the provider suite

The start path now opens the stopped guest disk to install a host key, so
the suite needs a mount stub and a fake disk clone. It also asserts the
new contract: start writes the key and pins it, SSH verifies the pinned
key, and a disk that cannot be opened fails the start without booting.

* fix(sandbox): match the whole image path when detaching a guest disk

hdiutil info prints the image path after a colon, and the detach lookup
compared its third whitespace field, so a state root with a space in it
never matched and the guest disk stayed mounted while the VM booted. Read
the path from the whole line, and cover it with a suite path that contains
a space.

* fix(sandbox): refuse an uninstallable image before starting the install

Apple's catalog can hold a single macOS version newer than the host, so
the image lookup ran inside the background job and create answered working
before failing with no image. Resolve the catalog entry in create, refuse
with the host version and a local-image suggestion, and pass the chosen
URL and SHA-1 to the job so it only downloads and verifies. Validate the
guest user before the network read, so a bad user still writes nothing.
2026-11-10 20:24:03 -08:00
..