From e2d3eefc8553c342148be015675bbd849148f85e Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Mon, 14 Sep 2026 20:44:26 -0700 Subject: [PATCH 1/2] feat(macos): add the Lume seed recipe, pins, and operator runbook Phase 8 inputs for the macOS backend, reviewed but never executed: design prerequisite 5 (a dedicated Apple Silicon host in the lab, reachable over SSH) is not satisfied, so no VM, TCC, or clone experiment has run and none may run on a personal workstation. What is verified here: every pin resolves out of pins.lock.yaml and the recipe, the scripts parse, the pin reader fails closed on a renamed key, and the Driver archive digest, bundle id (com.trycua.driver), and signing team (YCK386LBJ7) were measured from the pinned release. The Sequoia IPSW digest is republished by AppleDB, not by Apple, so its pin gate stays open until it is measured on the Mac host. Two findings the host will have to settle: 15.6.1 (24G90) is the last Sequoia release Apple published as a full restore image, so a Sequoia seed sits about a year behind that train's security level, and upstream now calls `tahoe` the verified unattended preset while still warning that `sequoia` can stop on Setup Assistant. Changing trains is the design owner's call. No catalog row is added: the server still answers `platform "mac" is not available yet`, and a catalog entry would advertise an image no instance.create can launch. --- .github/workflows/images-validate.yml | 24 +++ images/README.md | 30 ++- images/macos/lib.sh | 96 +++++++++ images/macos/pins.lock.yaml | 145 +++++++++++++ images/macos/sequoia/desktop/README.md | 196 ++++++++++++++++++ images/macos/sequoia/desktop/image.yaml | 77 +++++++ images/macos/sequoia/desktop/provision.sh | 200 ++++++++++++++++++ images/macos/sequoia/desktop/unattended.yaml | 33 +++ images/macos/sequoia/desktop/verify.sh | 205 +++++++++++++++++++ 9 files changed, 1004 insertions(+), 2 deletions(-) create mode 100755 images/macos/lib.sh create mode 100644 images/macos/pins.lock.yaml create mode 100644 images/macos/sequoia/desktop/README.md create mode 100644 images/macos/sequoia/desktop/image.yaml create mode 100755 images/macos/sequoia/desktop/provision.sh create mode 100644 images/macos/sequoia/desktop/unattended.yaml create mode 100755 images/macos/sequoia/desktop/verify.sh diff --git a/.github/workflows/images-validate.yml b/.github/workflows/images-validate.yml index eb6f718..d1413bf 100644 --- a/.github/workflows/images-validate.yml +++ b/.github/workflows/images-validate.yml @@ -87,3 +87,27 @@ jobs: run: | bash -n images/smoke.sh python3 -m py_compile images/runner/smoke.py images/ubuntu-24.04-desktop/smoke.py images/publish.py images/catalog-pr.py + + # The macOS seed scripts only run on an Apple Silicon host with Lume, so + # CI checks what it can without one: they parse, and every pin the + # scripts read still resolves out of images/macos/pins.lock.yaml and the + # recipe. A renamed or deleted pin fails here instead of on the Mac. + - name: Check the macOS seed recipe + run: | + bash -n images/macos/lib.sh + bash -n images/macos/sequoia/desktop/provision.sh + bash -n images/macos/sequoia/desktop/verify.sh + . images/macos/lib.sh + pins=images/macos/pins.lock.yaml + recipe=images/macos/sequoia/desktop/image.yaml + for key in url sha256; do + pin "$pins" lume "$key" >/dev/null + pin "$pins" sequoia_ipsw "$key" >/dev/null + done + for key in version url sha256 archive archive_root bundle_id team_identifier app_install_path; do + pin "$pins" cua_driver "$key" >/dev/null + done + for key in product_version build_version architecture; do + pin "$recipe" expect "$key" >/dev/null + done + echo "macOS seed pins resolve" diff --git a/images/README.md b/images/README.md index e3354b4..851fdc9 100644 --- a/images/README.md +++ b/images/README.md @@ -1,7 +1,9 @@ # images -Lab-built Incus images for agentcompute: the Alpine `router` system container, -two minimal Ubuntu 24.04 runner VMs, and an Ubuntu 24.04 Xorg desktop VM. +Lab-built guest images for agentcompute: the Alpine `router` system container, +two minimal Ubuntu 24.04 runner VMs, an Ubuntu 24.04 Xorg desktop VM, the +cluster-local Windows VMs, and one non-Incus family — a host-local macOS seed +built by Lume on an Apple Silicon host. `runner` has no sudo grant; `runner-publisher` permits only the root-owned image-build wrapper. @@ -19,6 +21,9 @@ image-build wrapper. | `publish.py` | Protected-source gate, immutable-tag lookup, assembly, qualification, publication, and verified fetch-back. | | `catalog-pr.py` | Opens the public catalog digest PR from verified release evidence. | | `ci-incus.py` | Configures the pinned Incus CLI and restricted HTTPS identity. | +| `macos/pins.lock.yaml` | Lume, Cua Driver, and Sequoia IPSW pins for the host-local macOS seed, with provenance labels and one open digest gate. | +| `macos/lib.sh` | Host helpers shared by the seed scripts: the pin reader and the `lume ssh` command transport. | +| `macos/sequoia/desktop/` | Seed recipe and operator runbook: `image.yaml`, `unattended.yaml`, `provision.sh`, `verify.sh`, `README.md`. Produces no publishable artifact. | | `../cmd/image-publish` | Immutable imgoci publication and verified fetch-back CLI. | Phase 7 router and Windows findings are maintained in the central @@ -91,6 +96,27 @@ guest-file API and return as URLs rather than embedded image data. See the [Phase 6 desktop spike report](../spikes/desktop/README.md) for direct Driver results, timing, screenshot scaling, VNC, and reboot evidence. +### macOS seed (not built) + +`macos/sequoia/desktop/` is the one family that produces no artifact. The +deliverable is a stopped Lume VM on a dedicated Apple Silicon host, holding an +operator's one-time Accessibility and Screen Recording consent; workers are +`lume clone` copies. Apple's license grants no redistribution and Cua's +guidance is to keep a consented seed private, so it is never pushed to a +registry and carries a `seed:` name instead of a digest or alias. + +Nothing in it has run. The design's prerequisite 5 host — a dedicated Apple +Silicon machine in the lab, not a personal workstation — does not exist, so the +recipe, pins, and runbook are reviewed inputs to that first build and no more. +There is deliberately no `catalog.yaml` row yet: the server still answers +`platform "mac" is not available yet`, and a catalog entry would advertise an +image no `instance.create` can launch. + +`images-validate.yml` checks what a Linux runner can: the scripts parse and +every pin they read still resolves. Everything else waits on the host. The +operator procedure, its gates, and the clone-smoke evidence live in +[`macos/sequoia/desktop/README.md`](macos/sequoia/desktop/README.md). + ## Publication and import The public `images-publish.yml` validates on a GitHub-hosted runner and diff --git a/images/macos/lib.sh b/images/macos/lib.sh new file mode 100755 index 0000000..75ae2c7 --- /dev/null +++ b/images/macos/lib.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# Shared helpers for the macOS seed scripts. Source it; do not execute it. +# +# Everything here runs on the Apple Silicon host that owns the Lume VMs. Guest +# commands travel through `lume ssh`, which knows the guest's NAT address and +# the documented lume/lume account, so no guest IP or key is needed to bootstrap. + +# shellcheck shell=bash + +die() { + printf 'error: %s\n' "$*" >&2 + exit 1 +} + +log() { + printf '== %s\n' "$*" +} + +require_cmd() { + for cmd in "$@"; do + command -v "$cmd" >/dev/null 2>&1 || die "$cmd is required on the host" + done +} + +# pin
+# +# Reads one scalar from the flat two-level subset of YAML these recipes use: +# a top-level `section:` line followed by indented `key: value` lines. Quotes +# and inline comments are stripped. A missing section or key is a hard error, +# so a renamed pin fails the script instead of silently defaulting. +pin() { + local file=$1 section=$2 key=$3 value + [ -r "$file" ] || die "cannot read $file" + value=$(awk -v section="$section" -v key="$key" ' + index($0, section ":") == 1 { inside = 1; next } + /^[^[:space:]#]/ { inside = 0 } + inside { + line = $0 + sub(/^[[:space:]]+/, "", line) + if (index(line, key ":") == 1) { + sub(/^[^:]+:[[:space:]]*/, "", line) + sub(/[[:space:]]+#.*$/, "", line) + gsub(/^"|"$/, "", line) + print line + exit + } + } + ' "$file") + [ -n "$value" ] || die "$file: $section.$key is missing or empty" + printf '%s' "$value" +} + +# guest_run [NAME=value ...] <<'SCRIPT' +# +# Runs a bash script inside the guest. The script is base64-encoded so host +# quoting never reaches the guest, and it runs under `bash -euo pipefail`. +# Each NAME=value pair becomes a readonly variable at the top of the script. +# Guest stdout and stderr pass through; the guest exit status is the result. +guest_run() { + local vm=$1 + shift + local prelude="" assignment name value payload + for assignment in "$@"; do + name=${assignment%%=*} + value=${assignment#*=} + prelude+=$(printf 'readonly %s=%q' "$name" "$value")$'\n' + done + payload=$({ printf '%s' "$prelude"; cat; } | base64 | tr -d '\n') + lume ssh "$vm" -u "$LUME_GUEST_USER" -p "$LUME_GUEST_PASSWORD" -t "$LUME_SSH_TIMEOUT" -- \ + /bin/bash -c "printf %s $payload | base64 -D | /bin/bash -euo pipefail -s" +} + +# guest_state — `stopped`, `running`, or a Lume provisioning state. +guest_state() { + lume ls --format json | jq -r --arg name "$1" '.[] | select(.name == $name) | .state' | head -n 1 +} + +# guest_address — the NAT address Lume assigned, empty while not running. +guest_address() { + lume get "$1" --format json | jq -r '.[0].ipAddress // empty' +} + +require_running() { + local vm=$1 state + state=$(guest_state "$vm") + [ -n "$state" ] || die "no Lume VM named $vm" + [ "$state" = "running" ] || die "$vm is $state; start it with: lume run $vm --display none" +} + +# Guest credentials. Lume's unattended presets create lume/lume and the seed +# keeps them: the guest is NAT-only, and rotating the password would break +# `lume ssh`, `lume shutdown`, and `lume restart` defaults. Automation +# authenticates with the public key provision.sh installs. +: "${LUME_GUEST_USER:=lume}" +: "${LUME_GUEST_PASSWORD:=lume}" +: "${LUME_SSH_TIMEOUT:=120}" diff --git a/images/macos/pins.lock.yaml b/images/macos/pins.lock.yaml new file mode 100644 index 0000000..b024db9 --- /dev/null +++ b/images/macos/pins.lock.yaml @@ -0,0 +1,145 @@ +schema_version: 1 +recipe_revision: 1 +architecture: arm64 + +# Pins for the host-local macOS seed. Provenance labels follow +# images/windows/pins.lock.yaml: `published` means the publisher documents the +# exact digest recorded here; `derived` means it was computed from a verified +# container; `measured` means no publisher digest exists. macOS adds one case +# the Windows family did not need: `aggregated`, where a third party republishes +# a digest that Apple itself does not document. An `aggregated` entry never +# gates a build on its own — see `gate` on the IPSW entry. +# +# Nothing here has run yet. The Mac host that Phase 8 requires (design +# prerequisite 5) does not exist, so every value below is verified by +# publisher metadata only, never by a local build. + +host: + # Lume's documented host floor, not a purchasing recommendation. + # https://cua.ai/docs/reference/lume/limits + platform: Apple Silicon + macos_minimum: "13" + memory_minimum_gb: 16 + free_disk_minimum_gb: 50 + # Apple's Virtualization framework caps concurrent macOS guests at two per + # host. The seed counts against that cap whenever it is running. + max_concurrent_macos_guests: 2 + +lume: + version: 0.5.3 + archive: lume-0.5.3-darwin-arm64.tar.gz + archive_root: lume + url: https://github.com/trycua/cua/releases/download/lume-v0.5.3/lume-0.5.3-darwin-arm64.tar.gz + size: 6009673 + sha256: af5d0556763a7f0116153c220aaabe44974e775091ac57e38da2abb2959c63e8 + provenance: published + published_by: https://api.github.com/repos/trycua/cua/releases (asset digest) + prerelease: false + license: MIT + # The binary-only tarball, not the .pkg and not a curl-pipe installer. The + # .pkg variant (lume-0.5.3-darwin-arm64.pkg.tar.gz, + # sha256:3da80c0577b34a8e4df61e719962e7506664c0fa660ac1a130c03e9248a31e6f) + # installs the same binary through Installer.app and is not used. + serve: + # `lume serve` binds loopback by default. Reaching it from the + # agentcompute server requires an explicit bind address or an SSH tunnel; + # that decision belongs to the deployment, not to this image. + port: 7777 + cli_surface: + # Commands this recipe depends on, all present in 0.5.3. + - create + - setup + - run + - stop + - clone + - delete + - ls + - get + - ssh + - attach + +cua_driver: + version: 0.28.1 + archive: cua-driver-rs-0.28.1-darwin-arm64.tar.gz + archive_root: cua-driver-rs-0.28.1-darwin-arm64 + url: https://github.com/trycua/cua/releases/download/cua-driver-rs-v0.28.1/cua-driver-rs-0.28.1-darwin-arm64.tar.gz + size: 70074352 + sha256: b3cb3781feb9855dfcdd616bc5c9c8e627453e3e16a073c1446326691a549b24 + provenance: published + published_by: https://api.github.com/repos/trycua/cua/releases (asset digest) + # Every cua-driver-rs release in the monorepo is marked prerelease, including + # the newest (0.28.1). There is no non-prerelease Driver to pin. 0.28.1 is the + # same version the Linux desktop (images/pins.yaml) and Windows images + # (images/windows/pins.lock.yaml) already run, under the same project-specific + # pin exception. + prerelease: true + # TCC grants are attached to the code-signing identity plus bundle id, which + # is what makes an operator's one-time consent survive `lume clone`. These + # values are measured from the pinned archive and must be re-asserted in the + # guest after installation (verify.sh does). + bundle_id: com.trycua.driver + bundle_short_version: 0.28.1 + team_identifier: YCK386LBJ7 + signing_authority: "Developer ID Application: Cua AI, Inc. (YCK386LBJ7)" + app_install_path: /Applications/CuaDriver.app + # Upstream's release installer symlinks the CLI into ~/.local/bin. The seed + # adds /usr/local/bin/cua-driver so the guest path matches the Linux desktop + # image's constant and the server needs no macOS-specific binary path. + cli_paths: + - /Users/lume/.local/bin/cua-driver + - /usr/local/bin/cua-driver + +sequoia_ipsw: + train: sequoia + product_version: "15.6.1" + build: 24G90 + released: 2025-08-20 + device_class: VirtualMac2,1 + url: https://updates.cdn-apple.com/2025SummerFCS/fullrestores/093-10809/CFD6DD38-DAF0-40DA-854F-31AAD1294C6F/UniversalMac_15.6.1_24G90_Restore.ipsw + size: 16814137790 + sha256: 3d87686b691ac765eb6a6b3082b2334e2af9710096a00432dd519af89ff2ea78 + provenance: aggregated + published_by: null + aggregated_by: https://api.appledb.dev/ios/macOS/main.json + # Apple's own IPSW catalog + # (https://mesu.apple.com/assets/macos/com_apple_macOSIPSW/com_apple_macOSIPSW.xml) + # lists only the current train and carries no digest for the restore image, + # so no Apple-published SHA-256 exists for this file. The URL and size were + # confirmed live (HTTP 200, Content-Length 16814137790, Last-Modified + # 2025-08-18) on 2026-09-14. + gate: blocked + gate_reason: the SHA-256 above is republished by AppleDB, not documented by + Apple, and has not been measured locally. Closing this gate requires + downloading the IPSW on the Mac host, measuring it, and recording the match + here. A mismatch is a stop, not a pin update. + # 15.6.1 is the LAST Sequoia release Apple published as a full restore image. + # 15.7.x and 15.8 (24H23) ship only as InstallAssistant packages and OTA + # assets, so a Sequoia seed built from an IPSW is pinned roughly a year behind + # the train's current security level. macOS 26 (Tahoe) 26.6.2 (25G83, + # 2026-08-17, + # sha256:885503b7f4b06609e9a512f2befd40f59730640a3f1233e3892d60affdd51c95) + # is both current and covered by Lume's `tahoe` preset, which upstream calls + # the verified one. Changing trains is the design owner's call; this file does + # not make it. + license: Apple macOS Software License Agreement. At most two additional + virtual instances per Apple-branded host, development and testing use, no + redistribution. Host-local seed only, never a registry. + +unattended: + # `lume create --unattended` accepts a built-in preset name or a YAML path. + # This recipe passes the path so the health-check budget is explicit and + # reviewable; the file is otherwise the upstream sequoia preset. + preset_source: https://github.com/trycua/cua/blob/lume-v0.5.3/libs/lume/src/Resources/unattended-presets/sequoia.yml + recipe_file: images/macos/sequoia/desktop/unattended.yaml + # Upstream still reports that the Sequoia preset can stop on Setup Assistant's + # Accessibility step at first display boot (trycua/cua#2155 closed, docs not). + # First GUI boot is therefore a blocking qualification, not an assumption. + first_display_boot_gate: required + default_account: + user: lume + password: lume + # Lume documents this account and its password. The seed keeps them: the + # guest is NAT-only and reachable only from the Mac host, and rotating the + # password would break `lume ssh` and `lume shutdown` defaults. Automation + # authenticates with an installed public key instead. + rotated: false diff --git a/images/macos/sequoia/desktop/README.md b/images/macos/sequoia/desktop/README.md new file mode 100644 index 0000000..9e31851 --- /dev/null +++ b/images/macos/sequoia/desktop/README.md @@ -0,0 +1,196 @@ +# macOS Sequoia desktop seed + +A stopped Lume VM on one Apple Silicon host, carrying macOS, the pinned Cua +Driver, and an operator's one-time Accessibility and Screen Recording consent. +Workers are `lume clone` copies of it. It is never published to a registry: +Apple's license grants no redistribution, and a consented seed is a +desktop-control credential. + +## Status: not built + +This recipe has never run. Design prerequisite 5 — a dedicated Apple Silicon +machine in the lab, on the network, reachable over SSH, not a personal +workstation — is not satisfied, and the lab inventory contains no Apple +hardware. Do not run these steps on a laptop or on the Mac Studio workstation: +the consented seed is a security-relevant artifact, the backend must not +disappear when a lid closes, and a personal machine's two-guest budget is not +the lab's to spend. + +Every pin in `../../pins.lock.yaml` is therefore publisher-verified only. The +IPSW digest gate is open by construction (see `gate_reason` there). + +## Host prerequisites + +| Requirement | Value | Source | +| --- | --- | --- | +| Architecture | Apple Silicon | Lume runs nowhere else | +| Host macOS | same or newer than the guest | Lume limits | +| Memory | 16 GB for one 8 GB guest | Lume limits | +| Free disk | 50 GB plus each clone's growth | Lume limits | +| Concurrent macOS guests | 2, host-wide, seed included | Apple's Virtualization framework | +| Host tools | `lume`, `jq` | these scripts and upstream's guide | + +`lume serve` is only needed by the server, not by this recipe; the scripts +drive the CLI directly. Run it as a launchd service when the server lands, and +decide its bind address there: it listens on loopback, so reaching it from the +cluster needs an explicit bind or an SSH tunnel. + +## Build the seed + +All commands run on the Mac host from a checkout of this repository. + +### 1. Install the pinned Lume + +Both steps read the pins through the recipe's own reader, so a runbook command +can never disagree with `pins.lock.yaml`: + +```sh +pins=images/macos/pins.lock.yaml +read_pin() { ( . images/macos/lib.sh; pin "$pins" "$1" "$2" ); } + +curl -fsSL -o /tmp/lume.tar.gz "$(read_pin lume url)" +shasum -a 256 /tmp/lume.tar.gz # must equal $(read_pin lume sha256) +tar xzf /tmp/lume.tar.gz -C /tmp +install -m 0755 /tmp/lume /usr/local/bin/lume +lume --version # must print 0.5.3 +``` + +### 2. Fetch and gate the IPSW + +The `sequoia_ipsw` pin records Apple's URL, size, and a SHA-256 that Apple does +not publish (Apple's IPSW catalog carries no digest). Measure it here: + +```sh +curl -fL -o /tmp/sequoia.ipsw "$(read_pin sequoia_ipsw url)" +shasum -a 256 /tmp/sequoia.ipsw # must equal $(read_pin sequoia_ipsw sha256) +``` + +A match closes the pin's gate — record it in `pins.lock.yaml` in the same +change that first builds a seed. A mismatch stops the build; it is not a +licence to update the pin. + +### 3. Create the seed + +```sh +lume create ac-seed-macos-sequoia-desktop \ + --ipsw /tmp/sequoia.ipsw \ + --unattended images/macos/sequoia/desktop/unattended.yaml \ + --cpu 4 --memory 8GB --disk-size 100GB --display 1440x900 +``` + +Lume installs macOS, boots once, patches the Data volume offline (the `lume` +account, autologin, SSH, no sleep), boots again, and verifies SSH. + +### 4. Qualify the first display boot + +```sh +lume run ac-seed-macos-sequoia-desktop # opens a viewer +``` + +Upstream still reports that the Sequoia preset can stop on Setup Assistant's +Accessibility screen at first display boot. **This is a gate, not a +formality.** A guest that shows Setup Assistant is not a seed; report it and +stop. Tahoe is upstream's verified preset and the alternative on the table. + +### 5. Provision + +```sh +images/macos/sequoia/desktop/provision.sh \ + --vm ac-seed-macos-sequoia-desktop \ + --key ~/.ssh/agentcompute_seed.pub +``` + +It asserts the guest's `sw_vers` against the recipe, installs the **public** +half of the server's key, installs the checksum-verified Driver into +`/Applications/CuaDriver.app`, refuses any signing identity other than the +pinned release one, symlinks the CLI at `/usr/local/bin/cua-driver`, loads a +LaunchAgent that starts the daemon through LaunchServices, and enables Screen +Sharing. It is idempotent and installs no secrets. + +Generate that key pair on the Mac host and keep the private half there. No +private key, registry token, or source-control credential goes into a guest. + +### 6. Grant consent — the human step + +TCC has no supported unattended path without MDM, and Cua explicitly rejects +hand-editing `TCC.db` for a reusable seed. One operator, once, in the guest's +graphical session: + +```sh +lume attach ac-seed-macos-sequoia-desktop +``` + +Then, in the guest's Terminal (not over SSH — SSH is not the foreground Aqua +session that owns the desktop): + +```sh +/usr/local/bin/cua-driver permissions grant # approve Accessibility and Screen Recording +/usr/local/bin/cua-driver call list_apps '{}' # allow controlling System Events +/usr/local/bin/cua-driver call get_desktop_state '{}' # allow direct capture +``` + +Approve each dialog for **CuaDriver**, then run the same three commands again: +they must complete with no dialog. From the host you can watch progress +without touching the GUI: + +```sh +lume ssh ac-seed-macos-sequoia-desktop -- /usr/local/bin/cua-driver permissions status --json +``` + +That call is read-only and cannot raise a prompt, which is exactly why a human +is required above. + +### 7. Gate the seed + +```sh +images/macos/sequoia/desktop/verify.sh \ + --vm ac-seed-macos-sequoia-desktop \ + --identity ~/.ssh/agentcompute_seed +``` + +All checks must pass. `--identity` exercises the scp pull the server will use; +without it that leg is skipped and the run is not a full gate. + +### 8. Prove the grants survive cloning + +```sh +lume stop ac-seed-macos-sequoia-desktop +lume clone ac-seed-macos-sequoia-desktop ac-smoke-1 +lume run ac-smoke-1 --detach --display none +images/macos/sequoia/desktop/verify.sh --vm ac-smoke-1 --identity ~/.ssh/agentcompute_seed +lume stop ac-smoke-1 && lume delete ac-smoke-1 --force +``` + +Repeat once. Two clone cycles that pass with no new consent, and a seed that is +still stopped and unmodified afterwards, is the decision evidence for Lume. A +clone that asks for permission again is a **NOT CONFIRMED** result for the +backend, not a step to retry by hand. + +Record, per run: create wall time, clone wall time, boot-to-SSH, boot-to-driver +-ready, screenshot round trip, and host disk growth per clone. + +## Why this is a manual procedure + +A self-hosted `lume-builder` runner with concurrency 1 can automate steps 1–3 +and 5–8. Step 6 cannot be automated and cannot be waited on usefully inside a +job: GitHub Actions has no "pause mid-job for a human" primitive, and the +nearest approximation — splitting the workflow and gating the second half on an +environment approval — parks the single Mac runner for an unbounded interval +while still holding one of the host's two guest slots. Until the host exists +and the path is proven once by hand, the automation would be scaffolding around +an unexecuted procedure. + +`verify.sh` is therefore the gate, not a workflow status check: it is the same +command a future job would run, and it fails closed. Register the runner and +wrap steps 1–3 and 5–8 after the first successful manual seed, with the consent +step staying a runbook entry. + +## Rules + +- Never `lume push` this seed or any clone. Host-local only. +- Never copy credentials into a guest. The public key in step 5 is the only + material that crosses the boundary. +- Two running macOS guests per host, seed included. Stop the seed before + running two workers. +- Keep the seed stopped and immutable. Never overwrite it with a failed clone. +- `sudo lume` looks in root's home for VMs. Always run as the owning account. diff --git a/images/macos/sequoia/desktop/image.yaml b/images/macos/sequoia/desktop/image.yaml new file mode 100644 index 0000000..3d1e7fe --- /dev/null +++ b/images/macos/sequoia/desktop/image.yaml @@ -0,0 +1,77 @@ +schema_version: 1 + +# Host-local macOS seed definition. Unlike every other family in images/, this +# recipe produces no artifact to publish: the deliverable is a stopped Lume VM +# on one Apple Silicon host, carrying an operator's one-time TCC consent. +# Apple's license does not grant redistribution and Cua's own guidance is to +# keep a consented seed private, so there is no registry reference here. + +name: macos/sequoia/desktop +platform: mac +os: macos +version: "15.6.1" +kind: vm +kinds: + - vm +desktop: true +description: macOS Sequoia desktop with Cua Driver and operator-granted TCC + +# The catalog row this recipe backs. `seed` is the Lume VM name on the Mac +# host, which replaces the digest or alias the Incus families carry. +seed: ac-seed-macos-sequoia-desktop + +expect: + # `sw_vers` must report exactly this. A different build means a different + # IPSW than images/macos/pins.lock.yaml pins. + product_version: "15.6.1" + build_version: 24G90 + architecture: arm64 + +# `lume create` geometry. The disk is sparse: the configured size is the +# guest's logical disk, and host allocation grows with writes. 100GB matches +# Lume's macOS default and leaves room for the Driver and a desktop session +# without making each clone's worst case 150GB on a shared host. +create: + cpu: 4 + memory: 8GB + disk_size: 100GB + display: 1440x900 + unattended: images/macos/sequoia/desktop/unattended.yaml + +# Defaults the catalog reports to agents. Two running guests is Apple's +# ceiling for the whole host, so an 8GB guest is the largest size that leaves +# the second slot usable on a 32GB host. +defaults: + cpus: 4 + memory_mb: 8192 + disk_gb: 100 + +guest: + account: lume + # Installed by provision.sh; the private half never enters the guest. + authorized_key_owner: agentcompute server on the Mac host + driver: + app: /Applications/CuaDriver.app + cli: /usr/local/bin/cua-driver + launch_agent: /Users/lume/Library/LaunchAgents/io.gilman.agentcompute.cua-driver.plist + # The daemon is started through LaunchServices so the responsible process + # is CuaDriver.app and the TCC grants apply to it. + start_command: /usr/bin/open -g -a /Applications/CuaDriver.app --args serve + vnc_fallback: + # macOS Screen Sharing inside the guest, reachable from the Mac host only. + # Lume also exposes its own VNC listener for every running VM + # (`lume run` reports vncUrl), which serves the same console purpose from + # outside the guest; see README.md for why both exist for now. + service: system/com.apple.screensharing + port: 5900 + +# Consent is a human step and cannot be scripted. verify.sh is the gate that +# decides whether the seed is usable, and clone-smoke is what proves the grant +# survives duplication. +qualification: + operator_consent_required: true + gates: + - first display boot shows no Setup Assistant + - verify.sh passes against the seed + - verify.sh passes against a fresh clone with no new consent prompt + - the seed is stopped and untouched after two clone cycles diff --git a/images/macos/sequoia/desktop/provision.sh b/images/macos/sequoia/desktop/provision.sh new file mode 100755 index 0000000..732c7a8 --- /dev/null +++ b/images/macos/sequoia/desktop/provision.sh @@ -0,0 +1,200 @@ +#!/usr/bin/env bash +# Provision the macOS seed guest: base assertions, an automation public key, +# the pinned Cua Driver, its login-session LaunchAgent, and Screen Sharing. +# +# Runs on the Apple Silicon host against a RUNNING Lume VM. Idempotent: every +# step is safe to repeat against an already provisioned seed. +# +# It deliberately does NOT grant TCC. Accessibility and Screen Recording need a +# human in the guest's graphical session; see README.md, then run verify.sh. +# +# Usage: +# images/macos/sequoia/desktop/provision.sh --vm --key +# [--pins ] [--recipe ] +# [--no-screen-sharing] +set -euo pipefail + +here=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=images/macos/lib.sh +. "$here/../../lib.sh" + +vm="" +key_file="" +pins="$here/../../pins.lock.yaml" +recipe="$here/image.yaml" +screen_sharing=1 + +while [ $# -gt 0 ]; do + case $1 in + --vm) vm=${2:?--vm needs a value}; shift 2 ;; + --key) key_file=${2:?--key needs a value}; shift 2 ;; + --pins) pins=${2:?--pins needs a value}; shift 2 ;; + --recipe) recipe=${2:?--recipe needs a value}; shift 2 ;; + --no-screen-sharing) screen_sharing=0; shift ;; + -h|--help) sed -n '2,13p' "$BASH_SOURCE"; exit 0 ;; + *) die "unknown argument: $1" ;; + esac +done + +[ -n "$vm" ] || die "--vm is required" +[ -n "$key_file" ] || die "--key is required (the PUBLIC half only)" +[ -r "$key_file" ] || die "cannot read $key_file" +grep -qE '^(ssh-ed25519|ssh-rsa|ecdsa-sha2-nistp[0-9]+) ' "$key_file" || + die "$key_file does not look like an OpenSSH public key" +grep -q 'PRIVATE KEY' "$key_file" && die "$key_file is a private key; pass the .pub file" + +require_cmd lume jq awk base64 +require_running "$vm" + +expect_version=$(pin "$recipe" expect product_version) +expect_build=$(pin "$recipe" expect build_version) +expect_arch=$(pin "$recipe" expect architecture) + +driver_version=$(pin "$pins" cua_driver version) +driver_url=$(pin "$pins" cua_driver url) +driver_sha=$(pin "$pins" cua_driver sha256) +driver_archive=$(pin "$pins" cua_driver archive) +driver_root=$(pin "$pins" cua_driver archive_root) +driver_bundle=$(pin "$pins" cua_driver bundle_id) +driver_team=$(pin "$pins" cua_driver team_identifier) +app_path=$(pin "$pins" cua_driver app_install_path) + +launch_label=io.gilman.agentcompute.cua-driver + +log "asserting the guest base ($expect_version / $expect_build / $expect_arch)" +guest_run "$vm" \ + "WANT_VERSION=$expect_version" "WANT_BUILD=$expect_build" "WANT_ARCH=$expect_arch" <<'SCRIPT' +got_version=$(sw_vers -productVersion) +got_build=$(sw_vers -buildVersion) +got_arch=$(uname -m) +[ "$got_version" = "$WANT_VERSION" ] || { echo "guest is macOS $got_version, want $WANT_VERSION" >&2; exit 1; } +[ "$got_build" = "$WANT_BUILD" ] || { echo "guest is build $got_build, want $WANT_BUILD" >&2; exit 1; } +[ "$got_arch" = "$WANT_ARCH" ] || { echo "guest is $got_arch, want $WANT_ARCH" >&2; exit 1; } +echo "base ok: macOS $got_version ($got_build) on $got_arch" +SCRIPT + +log "installing the automation public key" +guest_run "$vm" "PUBKEY=$(cat "$key_file")" <<'SCRIPT' +mkdir -p "$HOME/.ssh" +chmod 700 "$HOME/.ssh" +touch "$HOME/.ssh/authorized_keys" +chmod 600 "$HOME/.ssh/authorized_keys" +if grep -qxF "$PUBKEY" "$HOME/.ssh/authorized_keys"; then + echo "key already present" +else + printf '%s\n' "$PUBKEY" >> "$HOME/.ssh/authorized_keys" + echo "key installed" +fi +SCRIPT + +log "installing Cua Driver $driver_version" +guest_run "$vm" \ + "DRIVER_URL=$driver_url" "DRIVER_SHA=$driver_sha" "DRIVER_ARCHIVE=$driver_archive" \ + "DRIVER_ROOT=$driver_root" "DRIVER_VERSION=$driver_version" "DRIVER_BUNDLE=$driver_bundle" \ + "DRIVER_TEAM=$driver_team" "APP_PATH=$app_path" "GUEST_PASSWORD=$LUME_GUEST_PASSWORD" <<'SCRIPT' +sudo_pw() { printf '%s\n' "$GUEST_PASSWORD" | sudo -S -p '' "$@"; } + +work=$(mktemp -d /tmp/ac-driver.XXXXXX) +trap 'rm -rf "$work"' EXIT + +curl -fsSL --retry 3 -o "$work/$DRIVER_ARCHIVE" "$DRIVER_URL" +got_sha=$(shasum -a 256 "$work/$DRIVER_ARCHIVE" | awk '{print $1}') +[ "$got_sha" = "$DRIVER_SHA" ] || { echo "driver digest $got_sha != pinned $DRIVER_SHA" >&2; exit 1; } + +tar xzf "$work/$DRIVER_ARCHIVE" -C "$work" +src="$work/$DRIVER_ROOT/CuaDriver.app" +[ -d "$src" ] || { echo "archive has no CuaDriver.app at $src" >&2; exit 1; } + +# TCC follows the code-signing identity, which is what makes an operator's +# consent survive `lume clone`. Refuse anything but the pinned release identity. +codesign --verify --deep --strict "$src" +got_bundle=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$src/Contents/Info.plist") +got_team=$(codesign -dv "$src" 2>&1 | awk -F= '/^TeamIdentifier=/ {print $2}') +[ "$got_bundle" = "$DRIVER_BUNDLE" ] || { echo "bundle id $got_bundle != $DRIVER_BUNDLE" >&2; exit 1; } +[ "$got_team" = "$DRIVER_TEAM" ] || { echo "team $got_team != $DRIVER_TEAM" >&2; exit 1; } + +sudo_pw rm -rf "$APP_PATH" +sudo_pw ditto "$src" "$APP_PATH" +sudo_pw codesign --verify --deep --strict "$APP_PATH" + +# Register synchronously so `open -a CuaDriver` resolves this bundle. +lsregister=/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/Support/lsregister +sudo_pw "$lsregister" -f "$APP_PATH" + +# Upstream's release installer symlinks ~/.local/bin/cua-driver. The extra +# /usr/local/bin symlink makes the guest CLI path identical to the Linux +# desktop image's, so the server needs no macOS-specific binary path. +mkdir -p "$HOME/.local/bin" +ln -sf "$APP_PATH/Contents/MacOS/cua-driver" "$HOME/.local/bin/cua-driver" +sudo_pw mkdir -p /usr/local/bin +sudo_pw ln -sf "$APP_PATH/Contents/MacOS/cua-driver" /usr/local/bin/cua-driver + +got_driver=$(/usr/local/bin/cua-driver --version | awk '{print $NF}') +case "$got_driver" in + *"$DRIVER_VERSION"*) : ;; + *) echo "installed driver reports $got_driver, want $DRIVER_VERSION" >&2; exit 1 ;; +esac +echo "driver ok: $got_driver ($got_bundle, team $got_team)" +SCRIPT + +log "installing the login-session LaunchAgent" +guest_run "$vm" "LABEL=$launch_label" "APP_PATH=$app_path" <<'SCRIPT' +plist="$HOME/Library/LaunchAgents/$LABEL.plist" +mkdir -p "$HOME/Library/LaunchAgents" +cat >"$plist" < + + + + Label$LABEL + ProgramArguments + + /usr/bin/open + -g + -a + $APP_PATH + --args + serve + + RunAtLoad + StandardOutPath$HOME/Library/Logs/cua-driver-serve.out.log + StandardErrorPath$HOME/Library/Logs/cua-driver-serve.err.log + + +PLIST +plutil -lint "$plist" >/dev/null + +# The daemon is started through LaunchServices rather than by executing the +# bundle binary directly, so the responsible process is CuaDriver.app and the +# app-owned TCC grants apply. KeepAlive is deliberately absent: `open` exits +# immediately after handing off, so a KeepAlive agent would respawn forever. +mkdir -p "$HOME/Library/Logs" +launchctl bootout "gui/$(id -u)/$LABEL" >/dev/null 2>&1 || true +launchctl bootstrap "gui/$(id -u)" "$plist" +launchctl kickstart "gui/$(id -u)/$LABEL" +echo "launch agent loaded: $LABEL" +SCRIPT + +if [ "$screen_sharing" -eq 1 ]; then + log "enabling Screen Sharing (host-reachable VNC fallback)" + guest_run "$vm" "GUEST_PASSWORD=$LUME_GUEST_PASSWORD" <<'SCRIPT' +sudo_pw() { printf '%s\n' "$GUEST_PASSWORD" | sudo -S -p '' "$@"; } +sudo_pw launchctl enable system/com.apple.screensharing +sudo_pw launchctl kickstart -k system/com.apple.screensharing +echo "screen sharing enabled on 5900 (guest NAT address only)" +SCRIPT +fi + +log "provisioned $vm" +cat <` loads a +# preset name first and falls back to a file path, so this file is passed by +# path. +# +# This is upstream's `sequoia` preset with a longer health-check budget. The +# preset installs macOS from the IPSW, boots once, patches the Data volume +# offline (account, autologin, SSH, no sleep), boots again, and verifies SSH. +# Nothing here logs into a GUI or clicks Setup Assistant. + +boot_wait: 0 +boot_commands: [] + +health_check: + type: ssh + user: lume + password: lume + timeout: 5 + # Upstream allows 60 retries at 5s (5 minutes). A headless lab host with no + # display attached and a cold IPSW install is slower than a developer laptop + # watching the console, so the budget is 15 minutes. A seed that needs more + # than that is a failed create, not a slow one. + retries: 180 + retry_delay: 5 + +# Deliberately empty. Provisioning is images/macos/sequoia/desktop/provision.sh, +# which is idempotent, re-runnable against an existing seed, and checksum-gated. +# post_ssh_commands would run the same work inside `lume create`, where a +# failure destroys the install instead of leaving a VM to inspect. +post_ssh_commands: [] diff --git a/images/macos/sequoia/desktop/verify.sh b/images/macos/sequoia/desktop/verify.sh new file mode 100755 index 0000000..c790349 --- /dev/null +++ b/images/macos/sequoia/desktop/verify.sh @@ -0,0 +1,205 @@ +#!/usr/bin/env bash +# Gate for the macOS seed and for every clone made from it. +# +# Runs on the Apple Silicon host against a RUNNING Lume VM and answers one +# question: can an agent drive this guest's desktop right now, with no operator +# present and no consent dialog? Every check is read-only except the screenshot +# it takes and removes. +# +# This is the qualification gate referenced by README.md. Run it against the +# seed after consent, and against a fresh clone to prove the TCC grants +# survived cloning. +# +# Usage: +# images/macos/sequoia/desktop/verify.sh --vm [--identity ] +# [--pins ] [--recipe ] +# [--no-screen-sharing] +set -euo pipefail + +here=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=images/macos/lib.sh +. "$here/../../lib.sh" + +vm="" +identity="" +pins="$here/../../pins.lock.yaml" +recipe="$here/image.yaml" +screen_sharing=1 + +while [ $# -gt 0 ]; do + case $1 in + --vm) vm=${2:?--vm needs a value}; shift 2 ;; + --identity) identity=${2:?--identity needs a value}; shift 2 ;; + --pins) pins=${2:?--pins needs a value}; shift 2 ;; + --recipe) recipe=${2:?--recipe needs a value}; shift 2 ;; + --no-screen-sharing) screen_sharing=0; shift ;; + -h|--help) sed -n '2,16p' "$BASH_SOURCE"; exit 0 ;; + *) die "unknown argument: $1" ;; + esac +done + +[ -n "$vm" ] || die "--vm is required" +require_cmd lume jq awk base64 +require_running "$vm" + +expect_version=$(pin "$recipe" expect product_version) +expect_build=$(pin "$recipe" expect build_version) +expect_arch=$(pin "$recipe" expect architecture) +driver_version=$(pin "$pins" cua_driver version) +driver_bundle=$(pin "$pins" cua_driver bundle_id) +driver_team=$(pin "$pins" cua_driver team_identifier) +app_path=$(pin "$pins" cua_driver app_install_path) +launch_label=io.gilman.agentcompute.cua-driver + +failures=0 +check() { + local name=$1 + shift + if "$@"; then + printf 'PASS %s\n' "$name" + else + printf 'FAIL %s\n' "$name" + failures=$((failures + 1)) + fi +} + +# json_between extracts the payload a guest script wrapped in sentinels, so +# transport noise on the `lume ssh` channel cannot corrupt a jq parse. +json_between() { + sed -n '/^---AC-JSON---$/,/^---AC-END---$/p' | sed '1d;$d' +} + +check_base() { + guest_run "$vm" \ + "WANT_VERSION=$expect_version" "WANT_BUILD=$expect_build" "WANT_ARCH=$expect_arch" <<'SCRIPT' +[ "$(sw_vers -productVersion)" = "$WANT_VERSION" ] || { echo "productVersion mismatch" >&2; exit 1; } +[ "$(sw_vers -buildVersion)" = "$WANT_BUILD" ] || { echo "buildVersion mismatch" >&2; exit 1; } +[ "$(uname -m)" = "$WANT_ARCH" ] || { echo "architecture mismatch" >&2; exit 1; } +SCRIPT +} + +check_driver_identity() { + guest_run "$vm" \ + "APP_PATH=$app_path" "WANT_VERSION=$driver_version" \ + "WANT_BUNDLE=$driver_bundle" "WANT_TEAM=$driver_team" <<'SCRIPT' +[ -d "$APP_PATH" ] || { echo "$APP_PATH is missing" >&2; exit 1; } +codesign --verify --deep --strict "$APP_PATH" +bundle=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$APP_PATH/Contents/Info.plist") +team=$(codesign -dv "$APP_PATH" 2>&1 | awk -F= '/^TeamIdentifier=/ {print $2}') +[ "$bundle" = "$WANT_BUNDLE" ] || { echo "bundle id $bundle != $WANT_BUNDLE" >&2; exit 1; } +[ "$team" = "$WANT_TEAM" ] || { echo "team $team != $WANT_TEAM" >&2; exit 1; } +for cli in /usr/local/bin/cua-driver "$HOME/.local/bin/cua-driver"; do + target=$(readlink "$cli" || true) + [ "$target" = "$APP_PATH/Contents/MacOS/cua-driver" ] || + { echo "$cli points at '$target'" >&2; exit 1; } +done +version=$(/usr/local/bin/cua-driver --version | awk '{print $NF}') +case "$version" in *"$WANT_VERSION"*) : ;; *) echo "driver $version != $WANT_VERSION" >&2; exit 1 ;; esac +SCRIPT +} + +check_launch_agent() { + guest_run "$vm" "LABEL=$launch_label" <<'SCRIPT' +launchctl print "gui/$(id -u)/$LABEL" >/dev/null +SCRIPT +} + +# The daemon, not the shell, owns the TCC grants: `source.attribution` must be +# the driver daemon. A `host` attribution means something else answered and the +# grant evidence does not apply to the app-owned identity. +check_permissions() { + local status + status=$(guest_run "$vm" <<'SCRIPT' | json_between +echo '---AC-JSON---' +/usr/local/bin/cua-driver permissions status --json +echo '---AC-END---' +SCRIPT + ) + [ -n "$status" ] || { echo "no permissions payload" >&2; return 1; } + printf '%s' "$status" | jq -e ' + .accessibility == true + and .screen_recording == true + and .source.attribution == "driver-daemon" + ' >/dev/null +} + +check_accessibility_tree() { + local tree + tree=$(guest_run "$vm" <<'SCRIPT' | json_between +echo '---AC-JSON---' +/usr/local/bin/cua-driver call get_accessibility_tree '{}' +echo '---AC-END---' +SCRIPT + ) + [ -n "$tree" ] || { echo "empty accessibility tree" >&2; return 1; } + printf '%s' "$tree" | jq -e 'if type == "object" then (. | length) > 0 else length > 0 end' >/dev/null +} + +# Proves the exact path the server uses: one-shot `cua-driver call` with +# --screenshot-out-file, then a binary pull. With --identity it pulls over scp +# from the guest's NAT address, which is the only route the Mac host has. +check_screenshot() { + local guest_png="/tmp/ac-verify-$$.png" report local_png address + report=$(guest_run "$vm" "PNG=$guest_png" <<'SCRIPT' | json_between +/usr/local/bin/cua-driver call --screenshot-out-file "$PNG" get_desktop_state '{}' >/dev/null +magic=$(xxd -p -l 4 "$PNG") +bytes=$(stat -f %z "$PNG") +echo '---AC-JSON---' +printf '{"magic":"%s","bytes":%s}\n' "$magic" "$bytes" +echo '---AC-END---' +SCRIPT + ) + printf '%s' "$report" | jq -e '.magic == "89504e47" and .bytes > 1024' >/dev/null || { + echo "guest screenshot is not a PNG: $report" >&2 + guest_run "$vm" "PNG=$guest_png" <<<'rm -f "$PNG"' || true + return 1 + } + + if [ -n "$identity" ]; then + address=$(guest_address "$vm") + [ -n "$address" ] || { echo "no NAT address for $vm" >&2; return 1; } + local_png=$(mktemp /tmp/ac-verify.XXXXXX.png) + scp -q -o BatchMode=yes -o StrictHostKeyChecking=accept-new \ + -i "$identity" "$LUME_GUEST_USER@$address:$guest_png" "$local_png" || { + echo "scp from $address failed" >&2 + return 1 + } + file "$local_png" | grep -q 'PNG image data' || { + echo "pulled file is not a PNG" >&2 + rm -f "$local_png" + return 1 + } + printf 'pulled %s (%s bytes) over scp from %s\n' \ + "$local_png" "$(stat -f %z "$local_png")" "$address" + else + printf 'note: --identity not given, skipping the scp pull the server uses\n' + fi + + guest_run "$vm" "PNG=$guest_png" <<<'rm -f "$PNG"' +} + +check_screen_sharing() { + guest_run "$vm" <<'SCRIPT' +nc -z -G 2 127.0.0.1 5900 +SCRIPT +} + +log "verifying $vm at $(guest_address "$vm")" +check "guest base matches the pinned IPSW" check_base +check "driver app identity matches the pin" check_driver_identity +check "driver LaunchAgent is loaded" check_launch_agent +check "TCC grants are held by the driver daemon" check_permissions +check "accessibility tree is readable" check_accessibility_tree +check "desktop capture and file pull work" check_screenshot +if [ "$screen_sharing" -eq 1 ]; then + check "screen sharing is listening" check_screen_sharing +fi +# Second pass over the consent-sensitive calls. A grant that only works once is +# a prompt that a human happened to answer, not a seeded grant. +check "grants survive a repeat call" check_permissions +check "accessibility tree survives a repeat call" check_accessibility_tree + +if [ "$failures" -ne 0 ]; then + die "$failures check(s) failed for $vm" +fi +log "$vm is qualified" From 03d5664105368b98d40ae2f100378a290c4e907f Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Tue, 15 Sep 2026 01:06:20 -0700 Subject: [PATCH 2/2] feat(macos): qualify the Tahoe Lume seed on the lab Mac Studio Built and gated the seed end to end on studio-1 (Mac Studio M2 Max, macOS 26.6.2) under a dedicated `agentcompute` account, per the owner's amendment to design prerequisite 5. Every value in pins.lock.yaml is now measured locally, not just publisher-verified. Tahoe is the qualified train. Sequoia stays recorded: its last full restore image is 15.6.1 (2025-08-20), a year behind that train's security level, and it clears no gate Tahoe does not. What the spike changed in the recipe, all measured rather than assumed: - The Driver LaunchAgent execs the bundle binary under launchd (com.trycua.cua_driver_daemon). Upstream's `open -g -a CuaDriver --args serve` makes `open` the responsible process and the Driver then refuses to read its own TCC state. - provision.sh raises AccountInfo:FirstLogins in loginwindow's preferences. Lume's offline setup does not complete Setup Assistant, and the documented com.apple.SetupAssistant keys do not suppress it on 26.6.2. - lib.sh composes one shell line per guest command: `lume ssh` joins argv with spaces and the guest re-parses it, so a quoted script arrives mangled. - The display is 1920x1200; at 1440x900 macOS clips Setup Assistant's buttons off-screen, which makes the console pass impossible. - In-guest Screen Sharing is dropped. `launchctl enable system/com.apple.screensharing` fails on macOS 26 and Lume already serves a per-VM VNC console. - verify.sh gained a desktop-session check, `--clone`, and a BSD-correct mktemp template. Clone evidence over four cycles: clone 2-3 s, boot to NAT address 10-11 s, Driver answering with both TCC grants about 30 s, no clone ever asking for consent again. Two findings for the backend are in images/macos/README.md: a clone re-runs Setup Assistant, and Lume's API accepts a third macOS guest with 202 while silently leaving it stopped. --- .github/workflows/images-validate.yml | 13 +- images/README.md | 44 +-- images/macos/README.md | 265 ++++++++++++++++++ images/macos/lib.sh | 17 +- images/macos/pins.lock.yaml | 50 +++- .../macos/{sequoia/desktop => }/provision.sh | 107 ++++--- images/macos/sequoia/desktop/README.md | 196 ------------- images/macos/sequoia/desktop/image.yaml | 33 ++- images/macos/tahoe/desktop/image.yaml | 86 ++++++ images/macos/tahoe/desktop/unattended.yaml | 33 +++ images/macos/{sequoia/desktop => }/verify.sh | 55 ++-- 11 files changed, 595 insertions(+), 304 deletions(-) create mode 100644 images/macos/README.md rename images/macos/{sequoia/desktop => }/provision.sh (65%) delete mode 100644 images/macos/sequoia/desktop/README.md create mode 100644 images/macos/tahoe/desktop/image.yaml create mode 100644 images/macos/tahoe/desktop/unattended.yaml rename images/macos/{sequoia/desktop => }/verify.sh (80%) diff --git a/.github/workflows/images-validate.yml b/.github/workflows/images-validate.yml index d1413bf..171537d 100644 --- a/.github/workflows/images-validate.yml +++ b/.github/workflows/images-validate.yml @@ -95,19 +95,22 @@ jobs: - name: Check the macOS seed recipe run: | bash -n images/macos/lib.sh - bash -n images/macos/sequoia/desktop/provision.sh - bash -n images/macos/sequoia/desktop/verify.sh + bash -n images/macos/provision.sh + bash -n images/macos/verify.sh . images/macos/lib.sh pins=images/macos/pins.lock.yaml - recipe=images/macos/sequoia/desktop/image.yaml + recipes="images/macos/tahoe/desktop/image.yaml images/macos/sequoia/desktop/image.yaml" for key in url sha256; do pin "$pins" lume "$key" >/dev/null pin "$pins" sequoia_ipsw "$key" >/dev/null + pin "$pins" tahoe_ipsw "$key" >/dev/null done for key in version url sha256 archive archive_root bundle_id team_identifier app_install_path; do pin "$pins" cua_driver "$key" >/dev/null done - for key in product_version build_version architecture; do - pin "$recipe" expect "$key" >/dev/null + for recipe in $recipes; do + for key in product_version build_version architecture; do + pin "$recipe" expect "$key" >/dev/null + done done echo "macOS seed pins resolve" diff --git a/images/README.md b/images/README.md index 851fdc9..c9809e9 100644 --- a/images/README.md +++ b/images/README.md @@ -21,9 +21,12 @@ image-build wrapper. | `publish.py` | Protected-source gate, immutable-tag lookup, assembly, qualification, publication, and verified fetch-back. | | `catalog-pr.py` | Opens the public catalog digest PR from verified release evidence. | | `ci-incus.py` | Configures the pinned Incus CLI and restricted HTTPS identity. | -| `macos/pins.lock.yaml` | Lume, Cua Driver, and Sequoia IPSW pins for the host-local macOS seed, with provenance labels and one open digest gate. | -| `macos/lib.sh` | Host helpers shared by the seed scripts: the pin reader and the `lume ssh` command transport. | -| `macos/sequoia/desktop/` | Seed recipe and operator runbook: `image.yaml`, `unattended.yaml`, `provision.sh`, `verify.sh`, `README.md`. Produces no publishable artifact. | +| `macos/pins.lock.yaml` | Lume, Cua Driver, and Tahoe/Sequoia IPSW pins for the host-local macOS seed, with provenance labels and locally measured digests. | +| `macos/lib.sh` | Host helpers shared by the seed scripts: the pin reader and the quoting-safe `lume ssh` transport. | +| `macos/provision.sh` | Idempotent seed provisioning: base assertions, automation public key, pinned Driver, first-login suppression, Driver LaunchAgent. | +| `macos/verify.sh` | The qualification gate: guest build, Driver identity, TCC grants held by the daemon, accessibility tree, capture plus scp pull. `--clone` for workers. | +| `macos/{tahoe,sequoia}/desktop/` | Per-train `image.yaml` and `unattended.yaml`. `tahoe` is the qualified train. | +| `macos/README.md` | Operator runbook, measurements, and findings. Produces no publishable artifact. | | `../cmd/image-publish` | Immutable imgoci publication and verified fetch-back CLI. | Phase 7 router and Windows findings are maintained in the central @@ -96,26 +99,31 @@ guest-file API and return as URLs rather than embedded image data. See the [Phase 6 desktop spike report](../spikes/desktop/README.md) for direct Driver results, timing, screenshot scaling, VNC, and reboot evidence. -### macOS seed (not built) +### macOS seed on Lume -`macos/sequoia/desktop/` is the one family that produces no artifact. The -deliverable is a stopped Lume VM on a dedicated Apple Silicon host, holding an -operator's one-time Accessibility and Screen Recording consent; workers are -`lume clone` copies. Apple's license grants no redistribution and Cua's -guidance is to keep a consented seed private, so it is never pushed to a -registry and carries a `seed:` name instead of a digest or alias. +`macos/` is the one family that produces no artifact. The deliverable is a +stopped Lume VM on a dedicated Apple Silicon host, holding an operator's +one-time Accessibility and Screen Recording consent; workers are `lume clone` +copies. Apple's license grants no redistribution and Cua's guidance is to keep +a consented seed private, so it is never pushed to a registry and the catalog +would carry a `seed:` name instead of a digest or alias. -Nothing in it has run. The design's prerequisite 5 host — a dedicated Apple -Silicon machine in the lab, not a personal workstation — does not exist, so the -recipe, pins, and runbook are reviewed inputs to that first build and no more. -There is deliberately no `catalog.yaml` row yet: the server still answers +`macos/tahoe/desktop` is built and qualified on the lab's Mac Studio: clone in +2–3 s, Driver answering with both TCC grants about 30 s after boot, and no +clone ever needing consent again. `macos/sequoia/desktop` is recorded but not +qualified — Sequoia's last full restore image is a year old, and it clears no +gate that Tahoe does not. + +There is still deliberately no `catalog.yaml` row: the server answers `platform "mac" is not available yet`, and a catalog entry would advertise an -image no `instance.create` can launch. +image no `instance.create` can launch. `internal/lume` is the next change, not +this one. `images-validate.yml` checks what a Linux runner can: the scripts parse and -every pin they read still resolves. Everything else waits on the host. The -operator procedure, its gates, and the clone-smoke evidence live in -[`macos/sequoia/desktop/README.md`](macos/sequoia/desktop/README.md). +every pin they read still resolves. The operator procedure, its gates, the +measurements, and the open findings (a clone re-runs Setup Assistant; Lume's +API accepts a third guest and silently ignores it) are in +[`macos/README.md`](macos/README.md). ## Publication and import diff --git a/images/macos/README.md b/images/macos/README.md new file mode 100644 index 0000000..81d427a --- /dev/null +++ b/images/macos/README.md @@ -0,0 +1,265 @@ +# macOS seed on Lume + +A stopped Lume VM on one Apple Silicon host, carrying macOS, the pinned Cua +Driver, and an operator's one-time Accessibility and Screen Recording consent. +Workers are `lume clone` copies of it. It is never published to a registry: +Apple's license grants no redistribution, and a consented seed is a +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 + +`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 +design prerequisite 5: an always-on Mac Studio rather than a dedicated Mac +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. + +`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 +Tahoe's (both leave Setup Assistant running; see Findings). Tahoe 26.6.2 is +current, matches the host's own build, and has a publisher-independent digest. + +## Host layout + +| Piece | Where | +| --- | --- | +| Lume 0.5.3 | `/usr/local/bin/lume` plus `/usr/local/bin/lume.app` (the wrapper execs the bundle) | +| 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 | + +`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). + +## 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`: + +```sh +pins=images/macos/pins.lock.yaml +read_pin() { ( . images/macos/lib.sh; pin "$pins" "$1" "$2" ); } +``` + +### 1. Install the pinned Lume + +```sh +curl -fsSL -o /tmp/lume.tar.gz "$(read_pin lume url)" +shasum -a 256 /tmp/lume.tar.gz # must equal $(read_pin lume sha256) +tar xzf /tmp/lume.tar.gz -C /tmp +sudo ditto /tmp/lume.app /usr/local/bin/lume.app +sudo install -m 0755 /tmp/lume /usr/local/bin/lume +lume --version # 0.5.3 +sudo -u agentcompute -H lume config telemetry disable +``` + +The archive ships a shell wrapper plus `lume.app`; both must land in the same +directory or the wrapper cannot find the binary. + +### 2. Fetch and verify the IPSW + +```sh +sudo -u agentcompute curl -fL -o /Users/agentcompute/ipsw/UniversalMac_26.6.2_25G83_Restore.ipsw \ + "$(read_pin tahoe_ipsw url)" +shasum -a 256 /Users/agentcompute/ipsw/UniversalMac_26.6.2_25G83_Restore.ipsw +``` + +Apple publishes no digest for restore images, so the pin carries AppleDB's +value and the `gate` field records that it was measured locally. Measured here: +212 s to download 19.7 GB, digest matched. + +### 3. Create the seed + +```sh +sudo -u agentcompute -H lume create ac-seed-macos-tahoe-desktop \ + --ipsw /Users/agentcompute/ipsw/UniversalMac_26.6.2_25G83_Restore.ipsw \ + --unattended images/macos/tahoe/desktop/unattended.yaml \ + --cpu 4 --memory 8GB --disk-size 100GB --display 1920x1200 +``` + +287 s measured. Lume installs macOS, boots once, patches the Data volume +offline (the `lume` account, autologin, SSH, no sleep), boots again, verifies +SSH, and stops the VM. + +`--display 1920x1200` is not cosmetic: at 1440x900 macOS clips Setup +Assistant's button row off-screen, which makes the console pass below +impossible. + +### 4. Console pass — completes Setup Assistant once + +Lume's offline setup does **not** remove Setup Assistant. The guest boots with +autologin working, Finder and Dock running, and MiniBuddy full-screen on top. +Start the VM and drive its console: + +```sh +curl -sS -X POST http://127.0.0.1:7777/lume/vms/ac-seed-macos-tahoe-desktop/run \ + -H 'Content-Type: application/json' -d '{"noDisplay":false}' +lume get ac-seed-macos-tahoe-desktop --format json | jq -r '.[0].vncUrl' +``` + +Open that `vnc://` URL with Screen Sharing, or drive it headlessly with +`vncdotool` (`uv run --with vncdotool`). Work through the remaining screens: +automatic updates → Continue; Apple Account → Other Sign-In Options → Sign in +Later in Settings → Skip; Age Range → Adult; FileVault → Cancel the password +prompt, Not Now, Continue. Do **not** enable FileVault: Lume cannot resize a +FileVault disk and the seed must stay cloneable. + +Never `killall "Setup Assistant"`. Measured: it logs the session out to the +login window, with or without Finder running. + +### 5. Provision + +```sh +sudo -u agentcompute -H images/macos/provision.sh \ + --vm ac-seed-macos-tahoe-desktop \ + --key /Users/agentcompute/.ssh/guest_ed25519.pub \ + --recipe images/macos/tahoe/desktop/image.yaml +``` + +6 s measured, idempotent. It asserts `sw_vers` against the recipe, installs the +public key, installs the checksum-verified Driver into +`/Applications/CuaDriver.app`, refuses any signing identity but the pinned +release one, symlinks `/usr/local/bin/cua-driver`, suppresses macOS's +first-login flow, turns off the guest's software-update schedule, and loads the +Driver LaunchAgent. + +The LaunchAgent label is `com.trycua.cua_driver_daemon` and it execs +`/Applications/CuaDriver.app/Contents/MacOS/cua-driver serve` directly. +`open -g -a CuaDriver --args serve` — which upstream's troubleshooting +suggests — makes `open` the responsible process, and the Driver then refuses to +read its own TCC state (`no CuaDriver daemon is running under the driver's own +identity`) even though the process is the same binary. Under launchd the daemon +is its own responsible process and `permissions status` reports attribution +`driver-daemon`. + +### 6. Grant consent — the human step + +TCC has no supported unattended path without MDM, and Cua rejects editing +`TCC.db` for a reusable seed. Once, on the seed, through the console: + +```sh +ssh -i /Users/agentcompute/.ssh/guest_ed25519 lume@ \ + /usr/local/bin/cua-driver permissions grant +``` + +Then, in the guest's GUI: + +1. **Accessibility.** The prompt offers *Open System Settings*; in Privacy & + Security → Accessibility enable **CuaDriver**, authenticate as `lume`. +2. **Screen Recording.** macOS does not add the app by itself. Open + `x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture`, + click **+**, choose `/Applications/CuaDriver.app` (select Applications in the + sidebar and type the name), then enable its switch. Dismiss the "may not be + able to record until it is quit" sheet with **Later** — clicking *Quit & + Reopen* here left the switch off in testing. +3. Restart the daemon so it picks the grants up: + `launchctl kickstart -k gui/$(id -u)/com.trycua.cua_driver_daemon`. + +`cua-driver permissions status --json` must then report `accessibility: true`, +`screen_recording: true`, and `source.attribution: "driver-daemon"` with +`bundle_id: com.trycua.driver`. Until both grants are in place every tool call +returns `permissions_pending`; `permissions grant` itself times out after about +three minutes with `Timed out waiting on: Screen Recording`. + +### 7. Gate the seed + +```sh +sudo -u agentcompute -H images/macos/verify.sh \ + --vm ac-seed-macos-tahoe-desktop \ + --recipe images/macos/tahoe/desktop/image.yaml \ + --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 +stop the VM and leave it stopped. + +### 8. Clone smoke + +```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"}' +curl -sS -X POST http://127.0.0.1:7777/lume/vms/ac-smoke-1/run -H 'Content-Type: application/json' \ + -d '{"noDisplay":true}' +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. + +## Measurements + +| Step | Measured | +| --- | --- | +| IPSW download (19.7 GB, Tahoe) | 212 s | +| IPSW download (15.7 GB, Sequoia) | 191 s | +| `lume create` (Tahoe, 100 GB disk) | 287 s | +| `lume create` (Sequoia) | 275 s | +| Clone | 2–3 s | +| Boot to NAT address | 10–11 s | +| Boot to Driver ready with grants | ~30 s | +| `provision.sh` | 6 s | +| `verify.sh` (incl. capture + scp pull) | 6–7 s | +| Screenshot | 1.3–1.5 MB on the seed, ~0.37 MB on an idle clone | +| Seed on disk | 26.6 GB allocated of a 100 GB logical disk | + +## 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. +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 + machines exceeds the limit`. Any caller must count running guests itself and + refuse with its own error. +3. **`lume ssh` is not argv-safe.** It joins its command arguments with spaces + and the guest re-parses the result, so `lume ssh vm /bin/bash -c '