diff --git a/CHANGELOG.md b/CHANGELOG.md index b4ed56d..e278de8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## Unreleased + +### Added + +- Optional Lume macOS backend with identity-pinned clones, durable sandbox + metadata, snapshot recovery, SSH guest execution, and SFTP file transfer. +- Mac platform dispatch and the Tahoe desktop seed catalog entry, qualified + through deployed MCP on `agentcompute01`. + +### Fixed + +- Negotiate SSH host-key algorithms from the pinned known-host entries. +- Omit unchanged disk sizes from Lume resource updates. +- Run host scripts under `/bin/sh` so empty sandbox discovery works with a + zsh login shell. + ## [0.1.1](https://github.com/GilmanLab/agentcompute/compare/v0.1.0...v0.1.1) (2026-09-15) diff --git a/README.md b/README.md index 1c2ad91..efc9b10 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ images_file: images/catalog.yaml OVN requires the fleet-managed central, chassis TLS configuration, and physical uplink. To use the bridge fallback, set `default_network_kind: bridge` and configure `incus.host`. -Pass its path with `--config` or `AGENTCOMPUTE_CONFIG`. YAML and TOML are strict: unknown keys fail startup. Catalog and certificate paths resolve relative to the configuration file. +Pass its path with `--config` or `AGENTCOMPUTE_CONFIG`. YAML and TOML are strict: unknown keys fail startup. Catalog and credential paths resolve relative to the configuration file. Run the local STDIO transport: @@ -57,7 +57,9 @@ Run Streamable HTTP on its loopback default: go run ./cmd/agentcompute http --config agentcompute.yaml --addr localhost:8080 ``` -Both commands build shared Incus, catalog, compute, and CodeMode dependencies once. HTTP reuses them across sessions. Startup reconciles digest-pinned images; upstream `remote:alias` images are fetched on first use. +Both commands build shared backend, catalog, compute, and CodeMode dependencies once. HTTP reuses them across sessions. Startup reconciles digest-pinned Incus images; upstream `remote:alias` images are fetched on first use. Mac `seed:` entries remain host-local and bypass Incus reconciliation. + +For Mac guests, configure the optional Lume backend using the [macOS runbook](images/macos/README.md#configure-the-server). Create a sandbox with `platform="mac"` and select `macos/tahoe/desktop`. Mac guests use Lume's NAT network; cluster placement and network overrides are unsupported. For a local MCP client, build the binary and configure its absolute path: @@ -97,9 +99,9 @@ OVN instances may use an explicit online `host`. Without one, placement prefers An OVN network with `nat=false` is isolated: it has no direct outside path and consumes no external address. Attach a router instance or use `net.peer` for reachability. Isolated networks reject external forwards. Operator-management and OOB denies are immutable, including against broader user allow rules. -Restoring a snapshot stages a copy before deleting the current instance, then recreates it under the same agent-visible name and ownership. The Incus identity and NIC MAC can change, so the DHCP lease can change too; the original instance's snapshots are consumed. Low-level Incus access remains blocked. +On Incus, restoring a snapshot stages a copy before deleting the current instance, then recreates it under the same agent-visible name and ownership. The Incus identity and NIC MAC can change, so the DHCP lease can change too; the original instance's snapshots are consumed. Low-level Incus access remains blocked. Lume snapshots are stopped host-local clones: creating one temporarily stops a running guest, and restoring one retains the named snapshot for reuse. -Exec retains 64 KiB per stream while draining the rest. `stdout_truncated` and `stderr_truncated` report overflow. An exec-only timeout returns `timed_out=true`; caller cancellation remains an error. `user` accepts a numeric UID or `root`. macOS is not available. +Exec retains 64 KiB per stream while draining the rest. `stdout_truncated` and `stderr_truncated` report overflow. An exec-only timeout returns `timed_out=true`; caller cancellation remains an error. Linux `user` accepts a numeric UID or `root`. Mac exec defaults to `lume` (UID 501); other numeric UIDs and `root` require guest sudo access. Windows exec uses the Incus agent service identity and does not accept `user`. Only `main()`'s final converted value is returned. Intermediate capability results remain inside the worker and do not enter the model's context. diff --git a/go.mod b/go.mod index cd19f9a..73edf12 100644 --- a/go.mod +++ b/go.mod @@ -9,10 +9,13 @@ require ( github.com/meigma/codemode v0.2.1 github.com/modelcontextprotocol/go-sdk v1.7.0 github.com/pelletier/go-toml/v2 v2.4.3 + github.com/pkg/sftp v1.13.11 + github.com/skeema/knownhosts v1.3.3 github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.12.1 go.yaml.in/yaml/v3 v3.0.5 + golang.org/x/crypto v0.55.0 golang.org/x/image v0.31.0 ) @@ -51,7 +54,6 @@ require ( github.com/opencontainers/runtime-spec v1.3.0 // indirect github.com/opencontainers/umoci v0.6.1-0.20251213054154-70fc5ee1f4df // indirect github.com/pkg/errors v0.9.1 // indirect - github.com/pkg/sftp v1.13.11 // indirect github.com/pkg/xattr v0.4.12 // indirect github.com/rogpeppe/go-internal v1.15.0 // indirect github.com/rootless-containers/proto/go-proto v0.0.0-20260207013450-f6ee952d53d9 // indirect @@ -77,7 +79,6 @@ require ( go.opentelemetry.io/otel/trace v1.44.0 // indirect go.starlark.net v0.0.0-20260708150628-5395d018f003 // indirect go.yaml.in/yaml/v4 v4.0.0-rc.6 // indirect - golang.org/x/crypto v0.55.0 // indirect golang.org/x/net v0.57.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.22.0 // indirect diff --git a/go.sum b/go.sum index 9e37de9..1f454ae 100644 --- a/go.sum +++ b/go.sum @@ -230,6 +230,8 @@ github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5 github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/skeema/knownhosts v1.3.3 h1:tiFvsjIW+xqP5cqVsod7atn/xm57OY6v9KwxB1xWUyA= +github.com/skeema/knownhosts v1.3.3/go.mod h1:2y0ntYxeqSNxOw5pfqSVDcRyBt2FqfxhzNDPoKz/ifQ= github.com/smartystreets/assertions v1.0.0/go.mod h1:kHHU4qYBaI3q23Pp3VPrmWhuIUrLW/7eUrw0BU5VaoM= github.com/smartystreets/go-aws-auth v0.0.0-20180515143844-0c1422d1fdb9/go.mod h1:SnhjPscd9TpLiy1LpzGSKh3bXCfxxXuqd9xmQJy3slM= github.com/smartystreets/gunit v1.0.0/go.mod h1:qwPWnhz6pn0NnRBP++URONOVyNkPyr4SauJk4cUOwJs= diff --git a/images/catalog.yaml b/images/catalog.yaml index 981cf31..43799da 100644 --- a/images/catalog.yaml +++ b/images/catalog.yaml @@ -80,3 +80,17 @@ images: memory_mb: 4096 disk_gb: 100 alias: windows/server-2025-26100.32230 +# macOS images are host-local Lume seeds, not registry digests. The seed name +# is the stopped VM on the Mac host; clones inherit operator TCC consent. +- name: macos/tahoe/desktop + os: macos + version: '26.6.2' + platform: mac + kinds: + - vm + kind: vm + desktop: true + cpus: 4 + memory_mb: 8192 + disk_gb: 100 + seed: ac-seed-macos-tahoe-desktop diff --git a/images/macos/README.md b/images/macos/README.md index 81d427a..65cfae9 100644 --- a/images/macos/README.md +++ b/images/macos/README.md @@ -9,7 +9,7 @@ desktop-control credential. `provision.sh`, `verify.sh`, and `lib.sh` are shared by every train; each train directory holds only `image.yaml` and `unattended.yaml`. -## Status: built and qualified +## Seed qualification `macos/tahoe/desktop` exists and passes the gate on `studio-1` (Mac Studio M2 Max, 64 GB, macOS 26.6.2 / 25G83). The host is the owner's amendment to @@ -18,6 +18,10 @@ mini, with the backend confined to a dedicated `agentcompute` account. Migrating to a mini is moving that account's `~/.lume` directory and re-issuing one SSH key. +First-boot clones require the identity-pinning step below. The clone gate now +rejects Setup Assistant rather than skipping the desktop check. The seed and +deployed MCP backend are qualified; see [Verify the backend](#verify-the-backend). + `macos/sequoia/desktop` is recorded but **not** the qualified train. Sequoia's last full restore image is 15.6.1 (24G90, 2025-08-20) — a year behind that train's security level — and its unattended install behaves no better than @@ -32,21 +36,259 @@ current, matches the host's own build, and has a publisher-independent digest. | Backend account | `agentcompute`, standard (not admin), hidden, member of `com.apple.access_ssh` | | VM store | `/Users/agentcompute/.lume` | | API | `lume serve --port 7777`, `/Library/LaunchDaemons/io.gilman.agentcompute.lume-serve.plist`, bound to `127.0.0.1` only | -| Server key | authorized only for `agentcompute`, `restrict,port-forwarding,permitopen="127.0.0.1:7777",command=/usr/local/libexec/agentcompute-no-shell` | -| Root-owned duplicate of those limits | `/etc/ssh/sshd_config.d/110-agentcompute.conf` (`Match User agentcompute`) | -| Guest key | `/Users/agentcompute/.ssh/guest_ed25519`; only the public half enters a guest | +| Approved server key policy | `from="",no-agent-forwarding,no-X11-forwarding`; normal shell and local TCP forwarding | +| Root-owned account policy | `/etc/ssh/sshd_config.d/110-agentcompute.conf` (`Match User agentcompute`) | +| Guest key | Per-image key on the server; the seed's provisioned key is `/Users/agentcompute/.ssh/guest_ed25519` on Studio | + +The dedicated account is the containment boundary, not a forwarding-only key. +It must remain hidden, standard, and unable to use sudo. The owner's home must +deny traversal by this account; a `staff`-readable home is insufficient because +both accounts belong to `staff`. + +Phase 9a installed this policy for `agentcompute01` (`100.65.152.20`, +`agentcompute01.tailda715.ts.net`). Qualification confirmed that its private key +matches Studio's installed public key and that both source restrictions name +that address. Phase 9a proved same-key refusal from a different source. +Studio remains untagged; the minimal tailnet policy is +[networking#22](https://github.com/GilmanLab/networking/pull/22), not the older +`tag:macbackend` proposal. Qualification changed no Mac permissions or ACLs. + +## Authorize the remote transport + +First verify the agentcompute server VM's tailnet IPv4 address. Restrict its +public key in `~agentcompute/.ssh/authorized_keys`: + +```text +from="",no-agent-forwarding,no-X11-forwarding ssh-ed25519 agentcompute server +``` + +Replace the forwarding-only root drop-in with the following policy, substituting +that same verified address. Keep it owned by `root:wheel`, mode `0644`. + +```text +Match User agentcompute + AllowUsers agentcompute@ + AuthenticationMethods publickey + PasswordAuthentication no + KbdInteractiveAuthentication no + AllowTcpForwarding local + PermitTunnel no + X11Forwarding no + AllowAgentForwarding no + GatewayPorts no +Match all +``` + +Remove `ForceCommand` and `PermitOpen`; do not retain `restrict` on the key. +The root-owned source and forwarding restrictions prevent the account from +widening its access by editing `authorized_keys`. Password and keyboard-interactive +authentication must stay disabled for this account. + +Before opening a new connection, run `sudo sshd -t` and inspect +`sudo sshd -T -C user=agentcompute,host=studio-1,addr=`. +Keep an operator session available during the change. From the server, verify +normal shell access and that `sudo -n true` fails. Check that the account cannot +read or traverse subdirectories of `/Users/josh`; on Studio the home directory +is owner-only (`0700`). Attempt authentication with the **same key** from a +different source address and require refusal. A failure caused by the tailnet +ACL alone does not prove the SSH source restriction. + +Lifecycle remains HTTP over the SSH connection to `127.0.0.1:7777`. Guest exec +and file transfer use system SSH through `ssh -J agentcompute@studio-1` to +the NAT lease, with a separate per-image guest key. Files use the SFTP subsystem +over that connection. Verify both SSH host keys; +do not forward an agent or install private keys in a guest. Host metadata uses +same-directory temporary files followed by `mv`; identity pinning edits a +stopped clone's configuration before its first run. + +Lume 0.5.3 has no HTTP routes for guest exec, sidecar I/O, or setting a clone's +machine identifier. Its clone request always regenerates the identifier. + +Source: Lume 0.5.3's [HTTP routes](https://github.com/trycua/cua/blob/754eec754991e1760100621e9bfe7ec1395cc7db/libs/lume/src/Server/Server.swift#L203-L401), +[clone implementation](https://github.com/trycua/cua/blob/754eec754991e1760100621e9bfe7ec1395cc7db/libs/lume/src/LumeController.swift#L296-L386), +and [host-status handler](https://github.com/trycua/cua/blob/754eec754991e1760100621e9bfe7ec1395cc7db/libs/lume/src/Server/Handlers.swift#L901-L940). + +## Configure the server + +Add this section to the server's existing Incus configuration. Use Studio's +tailnet **IPv4** address so the connection uses the source family authorized +above; do not use either LAN address. Replace the example credential paths +with files owned by the server's service account. + +```yaml +lume: + host: + identity_file: /etc/agentcompute/ssh/studio_ed25519 + known_hosts_file: /etc/agentcompute/ssh/known_hosts + guest_known_hosts_file: /etc/agentcompute/ssh/guest_known_hosts + guest_keys: + macos/tahoe/desktop: /etc/agentcompute/ssh/tahoe_ed25519 +``` + +Private keys must be readable only by the service account. The host key and +guest key are distinct. Key-map entries use catalog image names, not VM names. +Relative paths resolve beside the server configuration file. + +Verify Studio's SSH host key through operator access, and the guest host key +through the qualified seed. The host known-hosts file identifies Studio's +tailnet address. The guest known-hosts file identifies the **seed name**: + +```text +ac-seed-macos-tahoe-desktop ssh-ed25519 +``` + +Clones retain that SSH host key while their NAT leases change. Do not enroll +each new lease with `StrictHostKeyChecking=no` or trust an unverified +`ssh-keyscan` result. The generated jump configuration selects the host and +guest identities separately; neither needs an SSH agent. + +The catalog's `seed:` value refers to the stopped VM in this account's Lume +store. Mac entries bypass Incus image reconciliation. Omitting the `lume` +section leaves the Incus-only runtime available. + +Keep Lume's LaunchDaemon stdout and stderr at +`/Users/agentcompute/lume-serve.out.log` and +`/Users/agentcompute/lume-serve.err.log`. The capacity failure check reads only +new log bytes from the attempted run. Keep sandbox sidecars under +`/Users/agentcompute/.agentcompute/sandboxes`; do not discard them when +restarting or upgrading the server, since they are also the reaper's ownership +records. + +## Verify the backend + +Local checks pass with Go 1.26.6: repository lint, `go test -race ./...`, +`go build ./...`, a Linux/amd64 build with `CGO_ENABLED=0`, and the compiled +artifact's offline startup smoke check. Transport tests use real system SSH +with ProxyJump and a native SFTP server; the Lume HTTP service is a fixture. +These checks do not qualify a deployed MCP service. + +After installing and proving the source-restricted SSH policy, run the live +backend lane from the authorized server VM. Set `STUDIO_TAILNET_IP` to Studio's +verified tailnet IPv4 address and adjust the credential paths to match its +configuration: + +```sh +AGENTCOMPUTE_TEST_LUME=1 \ +AGENTCOMPUTE_TEST_LUME_HOST="$STUDIO_TAILNET_IP" \ +AGENTCOMPUTE_TEST_LUME_IDENTITY_FILE=/etc/agentcompute/ssh/studio_ed25519 \ +AGENTCOMPUTE_TEST_LUME_KNOWN_HOSTS=/etc/agentcompute/ssh/known_hosts \ +AGENTCOMPUTE_TEST_LUME_GUEST_KNOWN_HOSTS=/etc/agentcompute/ssh/guest_known_hosts \ +AGENTCOMPUTE_TEST_LUME_GUEST_KEY=/etc/agentcompute/ssh/tahoe_ed25519 \ +go test ./internal/lume -run '^TestLiveLumeLifecycle$' -count=1 -timeout=25m +``` -`restrict` alone does **not** stop command execution — a key with -`restrict,port-forwarding,permitopen=…` and no forced command still runs -`ssh host `. The forced command is what makes it forwarding-only, and -the `sshd_config.d` drop-in repeats every limit somewhere the account cannot -edit (it owns its own `authorized_keys`, and `lume serve` runs as it). +The lane creates a uniquely named sandbox and guest, waits for desktop/Driver +readiness, checks guest execution, and deletes the sandbox on cleanup. It is +a backend API check, not a substitute for deployed MCP acceptance. + +Live qualification ran from `agentcompute01` on 2026-09-16 UTC, using the +existing source-pinned key and a build based on v0.1.1. The final acceptance +build identifies source commit `4f7024d`. It retains the release's bearer +authentication and loopback reverse-proxy fixes. + +MCP checks passed: Mac sandbox creation; Running guests without Setup +Assistant on the fetched screenshot; `sw_vers`; Driver `list_apps`; screenshot +URL retrieval from the agent workstation; snapshot restore recovering a +pre-snapshot file; two running guests and third-guest refusal; unsupported +network creation; restart rediscovery; and explicit sandbox deletion leaving +only the stopped seed. A running TTL guest survived a server restart and was +observed deleted 17.61 s after expiry. The final backend integration lane +passed in 74.58 s. + +The final complete MCP run measured 37.46 s and 51.05 s for the two creates, +and 4.74 s for screenshot creation plus HTTPS retrieval. These are individual +observations, not cold-start guarantees: earlier creates took 80–98 s. +Machine-readable evidence is in +[`spikes/lume/qualification.json`](../../spikes/lume/qualification.json). + +Qualification used a runtime-only systemd override, then restored the +fleet-pinned v0.1.1 binary, configuration, and catalog. Temporary credentials +and binaries were removed. This is qualification evidence, not a permanent +Lume rollout. PF and tailnet ACLs were not changed; the VNC restriction below +remains an operator rollout prerequisite. + +Three live-only defects were fixed and regression-tested: SSH negotiated an +unpinned host-key algorithm; Lume rejected an equal-size disk PATCH; and zsh +rejected an empty sidecar glob after deletion. The backend now negotiates from +known-host pins, omits unchanged disk sizes, and executes host scripts under +`/bin/sh`. + +One concurrent-listing finding remains outside this qualification fix: +`sandbox.list` can return a transient not-found error if another process deletes +a listed sandbox before its details are read. This occurred during parallel +live-lane teardown; the sequential lifecycle and TTL checks passed. + +## Restrict VNC before serving workers + +Lume's VNC listener uses an ephemeral TCP port, not port 5900. The observed +host range is 49152–65535 (`sysctl net.inet.ip.portrange.first +net.inet.ip.portrange.last`). `pf.anchor` admits that range only through +loopback and the current Tailscale interface. + +**Impact:** this range also contains non-Lume applications. Review +`sudo lsof -nP -iTCP -sTCP:LISTEN` before applying it. Do not apply it if +another application requires LAN access in this range; resolve that conflict +with the owner first. These rules were syntax-checked, not installed during +the identity experiment. + +1. Stop all worker VMs through the HTTP API so no pre-existing VNC states + survive the policy change. Leave the seed stopped. +2. Find the current Tailscale interface with + `route -n get `; use its `interface` value below. + It was `utun9` during the experiment, but the number can change. +3. Validate and install only the anchor file: + + ```sh + tailnet_if=utun9 # replace with the observed interface + sudo pfctl -n -D "tailnet_if=$tailnet_if" -f images/macos/pf.anchor + sudo install -o root -g wheel -m 0644 images/macos/pf.anchor \ + /etc/pf.anchors/io.gilman.agentcompute + ``` + +4. Back up `/etc/pf.conf`. Add `anchor "io.gilman.agentcompute" quick` + immediately before its existing `anchor "com.apple/*"` filter anchor. + Preserve every existing scrub, NAT, redirect, and Apple anchor. Compare + the live root rules (`sudo pfctl -sr`, `sudo pfctl -sn`) with the file: + reloading it can discard dynamically inserted root rules. If they differ, + stop and reconcile with the owner rather than losing another service's + rules. Check `sudo pfctl -nf /etc/pf.conf` before the maintenance-window + reload with `sudo pfctl -f /etc/pf.conf`. Never use a global flush. +5. Load the child rules: + + ```sh + sudo pfctl -a io.gilman.agentcompute \ + -D "tailnet_if=$tailnet_if" -f /etc/pf.anchors/io.gilman.agentcompute + sudo pfctl -a io.gilman.agentcompute -sr + sudo pfctl -s info + ``` + + If PF is disabled, enable it with `sudo pfctl -E` and retain its reference + token. Never use `pfctl -d`: other host services also depend on PF. +6. Start one disposable worker. Read its actual `vncUrl` port and verify a + fresh TCP connection from loopback and an allowed tailnet peer succeeds, + while fresh connections to both LAN addresses fail. Also test IPv6 if a + wildcard IPv6 listener exists. Stop the worker after checking. + +The child rules must be reloaded after a reboot or Tailscale interface +change, **before** starting workers. The main anchor declaration alone does +not load them. An unattended host boot loader is not installed by this +procedure. If the interface cannot be identified or the anchor is absent, +do not serve workers. + +Rollback: stop workers, then unload only this anchor with +`sudo pfctl -a io.gilman.agentcompute -F rules`. Release only the PF reference +token acquired by this procedure with `sudo pfctl -X `. Restore the +saved main configuration if its declaration must also be removed. + +Upstream bind-address request: +[trycua/cua#3878](https://github.com/trycua/cua/issues/3878). ## Build a seed -All commands run on the Mac host from a checkout of this repository. The pin -reader is the recipe's own, so a runbook command cannot disagree with -`pins.lock.yaml`: +Run the seed procedures on the Mac from a checkout readable by `agentcompute`, +for example under `/Users/agentcompute/src/agentcompute`, not the owner's +protected home. All relative paths below refer to that checkout. The pin reader +is the recipe's own, so a command cannot disagree with `pins.lock.yaml`: ```sh pins=images/macos/pins.lock.yaml @@ -182,9 +424,10 @@ sudo -u agentcompute -H images/macos/verify.sh \ --identity /Users/agentcompute/.ssh/guest_ed25519 ``` -All ten checks must pass (6 s measured). `--identity` exercises the scp pull -the server will use; without it that leg is skipped and the run is not a full -gate. Reboot the seed once and re-run it before declaring the seed done, then +All ten checks must pass (6 s measured). `--identity` exercises an independent +SCP pull of the screenshot; the backend uses SFTP over the same SSH jump path. +Without it that leg is skipped and the run is not a full gate. Reboot the seed +once and re-run it before declaring the seed done, then stop the VM and leave it stopped. ### 8. Clone smoke @@ -192,17 +435,103 @@ stop the VM and leave it stopped. ```sh curl -sS -X POST http://127.0.0.1:7777/lume/vms/clone -H 'Content-Type: application/json' \ -d '{"name":"ac-seed-macos-tahoe-desktop","newName":"ac-smoke-1"}' +``` + +Before the clone's **first boot**, copy only `machineIdentifier` from the +stopped seed. Keep the clone's generated MAC; copying the MAC would create a +network collision. Run this as the `agentcompute` account, either locally or +through its source-restricted SSH shell: + +```sh +sudo -u agentcompute -H /bin/bash -euo pipefail <<'PIN' +store="$HOME/.lume" +seed="$store/ac-seed-macos-tahoe-desktop/config.json" +clone="$store/ac-smoke-1/config.json" +identifier=$(/usr/bin/plutil -extract machineIdentifier raw -o - "$seed") +tmp=$(/usr/bin/mktemp "$store/ac-smoke-1/.config.XXXXXX") +trap 'rm -f "$tmp"' EXIT +cp "$clone" "$tmp" +/usr/bin/plutil -replace machineIdentifier -string "$identifier" "$tmp" +jq -e --arg id "$identifier" '.machineIdentifier == $id' "$tmp" >/dev/null +mv -f "$tmp" "$clone" +PIN +``` + +The rename replaces the configuration atomically on the same filesystem. +Never run it while the clone is running. Then start through HTTP: + +```sh curl -sS -X POST http://127.0.0.1:7777/lume/vms/ac-smoke-1/run -H 'Content-Type: application/json' \ -d '{"noDisplay":true}' +``` + +Wait until `GET /lume/vms/ac-smoke-1` reports a NAT address and SSH accepts +connections. Start the already-installed Driver LaunchAgent if it is pending; +this does not grant or alter TCC permissions: + +```sh +ip=$(curl -fsS http://127.0.0.1:7777/lume/vms/ac-smoke-1 | jq -er .ipAddress) +sudo -u agentcompute ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new \ + -i /Users/agentcompute/.ssh/guest_ed25519 "lume@$ip" \ + 'launchctl kickstart gui/$(id -u)/com.trycua.cua_driver_daemon' sudo -u agentcompute -H images/macos/verify.sh --vm ac-smoke-1 --clone \ --recipe images/macos/tahoe/desktop/image.yaml \ --identity /Users/agentcompute/.ssh/guest_ed25519 ``` -Measured over four clone cycles: clone 2–3 s, boot to NAT address 10–11 s, -Driver answering with both grants about 30 s, `verify.sh --clone` 6–7 s, host -allocation ~26.5 GB per running clone (APFS copy-on-write; the seed's own -allocation does not change). No clone ever asked for consent again. +The earlier unpinned measurements were clone 2–3 s, NAT address 10–11 s, +and host allocation about 26.5 GB per clone. Those runs skipped the wizard +check and are not desktop-readiness measurements. + +On 2026-09-15, a fresh pre-boot-pinned clone and a second pinned clone both +ran concurrently, kept their distinct MACs, retained both TCC grants, and +passed the strict gate after the Driver kickstart. Screenshots showed the +desktop, not Setup Assistant. A fresh unpinned control still showed the +wizard: the old gate exited 0, while the corrected gate exited 1. +The comparison and gate output are retained in +[`spikes/lume/identity.json`](../../spikes/lume/identity.json). + +The pinned UUID matched the seed. System loginwindow and system/user Setup +Assistant plists were identical in the initial seed/control comparison; +the clone had acquired an additional UUID-named ByHost loginwindow plist. +Changing only `machineIdentifier` restored the seed's guest UUID and serial. +No dismissal agent or TCC database modification was used. Rebooting an +already-booted unpinned clone also cleared the wizard in one experiment: +use **fresh first-boot controls**, not a reboot alone, to qualify pinning. + +Both pinned and unpinned clones sometimes left the Driver LaunchAgent in +`pended nondemand spawn = speculative`, with zero runs. `launchctl kickstart` +started the existing app-owned daemon; both grants then remained true. + +A subsequent disposable-clone investigation found a second seed LaunchAgent, +`io.gilman.agentcompute.cua-driver`, which runs `open -g -a`. The canonical job +sometimes exits with `daemon already running`, while the daemon belongs to an +`application.com.trycua.driver.*` launchd job. Removing the stale launcher from +that clone and rebooting did not eliminate this behavior. Both grants remained +true after kickstart. The cold-boot scheduling cause remains unresolved; this +experiment did not modify the seed or grant new permissions. + +## Host-wide guest-count rule + +Before a create/start request makes any Lume HTTP call, inspect the dedicated +account's inventory with `/usr/local/bin/lume ls --format json`. Count running +**macOS** guests regardless of name, including a running seed, and serialize +pending starts. Refuse a third Lume-owned running macOS guest: + +```text +macOS guest limit reached (2 per host); the owner's macOS VMs share this budget +``` + +This inventory is not a host-wide authority: another account's VM is invisible +to it. Apple enforces the host-wide limit. If HTTP accepts a run but the VM +stays stopped and the daemon's new log output reports the limit, return an +explicit error naming Apple's two-guest limit and the possibility that another +account holds a slot. Do not mistake an old log entry for this run's failure, +or report Running from HTTP's `202` response alone. + +Start and restart require the same gate. Reaper rediscovery must not start a +stopped guest merely because its sidecar exists. +See [trycua/cua#3880](https://github.com/trycua/cua/issues/3880). ## Measurements @@ -222,15 +551,10 @@ allocation does not change). No clone ever asked for consent again. ## Findings -1. **A clone re-runs Setup Assistant.** The seed stays clean across its own - reboots once `AccountInfo:FirstLogins:lume` is raised in - `/Library/Preferences/com.apple.loginwindow.plist`, but every clone boots - into the Apple Account step again, and killing it logs the session out. The - Driver is unaffected — grants, accessibility tree and capture all pass — so - `verify.sh --clone` skips only the desktop-session check. An agent handed - such a clone sees the wizard, so this is a real defect for the desktop - image, not a cosmetic one. The documented `com.apple.SetupAssistant` - `DidSee*` keys do not suppress it on 26.6.2. +1. **Clone identity triggers first-boot Setup Assistant.** Pinning the seed's + `machineIdentifier` before first boot removed the wizard while preserving + the clone's generated MAC. The strict `verify.sh --clone` gate always + checks the desktop. See the clone smoke procedure and its control evidence. 2. **Lume's API accepts a third macOS guest and silently does nothing.** `POST /lume/vms//run` returns `202 {"status":"pending"}`; the VM stays `stopped` and only `lume serve`'s log carries `The number of virtual @@ -240,7 +564,7 @@ allocation does not change). No clone ever asked for consent again. and the guest re-parses the result, so `lume ssh vm /bin/bash -c '