Files
nucleic/scripts/provision-macos-guest.sh
T

773 lines
52 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
#
# Turn a freshly-installed macOS guest into Nucleic's GOLDEN base image.
#
# ./provision-macos-guest.sh [/path/to/nucleic-id_ed25519.pub] [AGENT_PASSWORD]
#
# The optional 2nd arg (or $NUCLEIC_AGENT_PW) is the `agent` account password, needed ONLY to enable
# auto-login for the computer-use phase (Phase 7). Omit it to skip auto-login/computer-use setup.
#
# THIS RUNS INSIDE THE VM, not on the host. It's the second half of the "install + provision locally"
# base flow — scripts/build-macos-base.sh installs a clean macOS and drops you at Setup Assistant;
# after you create the `agent` account by hand, you copy THIS script (plus Nucleic's host PUBLIC key)
# into the guest and run it as `agent`. It:
#
# 1. Enables Remote Login (sshd) so Nucleic can reach the guest as agent@<ip>.
# 2. Authorizes Nucleic's host public key in ~agent/.ssh/authorized_keys.
# 3. Installs the Xcode Command Line Tools (the minimum toolchain) and documents the FULL-Xcode
# path via `xcodes` (full Xcode + simulators need an Apple ID / manual download).
# 4. Installs Homebrew + the major dev toolchains (node, python/pip, rust, go), build systems
# (cmake/make/pkg-config/autotools), git, and common CLI dev tools — baked into the base.
# 5. CRITICAL: appends the toolchain PATH to /etc/zshenv so NON-login, non-interactive SSH shells
# (how the engine runs commands) can see node/npm/python/xcodebuild. .zprofile/.zshrc are NOT
# sourced by those shells; /etc/zshenv always is.
# 6. Warms the iOS simulator.
# 7. COMPUTER USE (GUI automation): installs cliclick at /usr/local/bin, enables auto-login for the
# agent (so a headless boot has an Aqua session), pre-grants Screen Recording + Accessibility
# in the SYSTEM TCC.db so screencapture/cliclick work unattended (needs SIP off — see Phase 7),
# and installs the NATIVE IN-GUEST AGENT (NucleicVMAgent.app + its LaunchAgent + TCC grants)
# when the app is staged alongside this script — see docs/MACOS_VM_NATIVE_AGENT.md.
# 8. Shuts the guest DOWN cleanly so the base can be cloned.
#
# Run it as the `agent` user (it will `sudo` for the system-level bits). It's idempotent — safe to
# re-run. Some steps (Remote Login, first-launch) may prompt or need Full Disk Access for Terminal.
set -euo pipefail
# The account Nucleic logs in as. Must match MacVMSettings.defaultSSHUser; also the account you
# created in Setup Assistant. Provision under this user even if invoked via sudo.
AGENT_USER="agent"
AGENT_HOME="$(/usr/bin/dscl . -read "/Users/$AGENT_USER" NFSHomeDirectory 2>/dev/null | awk '{print $2}' || true)"
AGENT_HOME="${AGENT_HOME:-/Users/$AGENT_USER}"
# Nucleic's host PUBLIC key to authorize. Prefer the script argument; else a copy dropped next to the
# script or into the agent home; else the shared workspace (virtiofs) if the host staged it there.
PUBKEY_ARG="${1:-}"
SHARED_WORKSPACE="/Volumes/My Shared Files/workspace"
# The host-provisioning share (STATUS/STARTED/provision.log live here). Used to signal the host — e.g.
# to auto-confirm the Command Line Tools install-on-demand dialog via HID (see Phase 3). Only present
# during a host-driven base build; harmless when absent.
PROVISION_SHARE="/Volumes/My Shared Files/nucleic-provision"
echo "▸ Provisioning this macOS guest as Nucleic's golden base (user: $AGENT_USER, home: $AGENT_HOME)"
# ── Phase 0: sanity ──────────────────────────────────────────────────────────────────────────────
if [ "$(uname -s)" != "Darwin" ]; then
echo "✗ this script runs INSIDE a macOS guest, not on Linux/other." >&2
exit 1
fi
if ! /usr/bin/dscl . -read "/Users/$AGENT_USER" >/dev/null 2>&1; then
echo "✗ account '$AGENT_USER' does not exist. Create it in Setup Assistant first (short name: $AGENT_USER)." >&2
exit 1
fi
# We sudo repeatedly. Under a host-driven base build the bootstrap has already primed passwordless
# sudo (a NOPASSWD sudoers drop-in), so `sudo -v` returns without a prompt; a hands-on run may be
# asked for the password once here.
if sudo -n true 2>/dev/null; then
echo "▸ sudo is already primed (passwordless) — continuing without a prompt."
else
echo "▸ This uses sudo for system settings — you may be asked for the $AGENT_USER password once."
sudo -v
fi
# Keep passwordless sudo after the base is sealed. Agent execs have no TTY in which to answer a
# password prompt, and this also makes hands-on/manual base provisioning match the host bootstrap.
if ! sudo grep -Eq '^[[:space:]]*@includedir[[:space:]]+/private/etc/sudoers.d' /etc/sudoers; then
printf '%s\n' '@includedir /private/etc/sudoers.d' | sudo tee -a /etc/sudoers >/dev/null
fi
sudo mkdir -p /private/etc/sudoers.d
printf '%s ALL=(ALL) NOPASSWD: ALL\n' "$AGENT_USER" \
| sudo tee /private/etc/sudoers.d/nucleic-agent >/dev/null
sudo chmod 0440 /private/etc/sudoers.d/nucleic-agent
# ── Phases 12: legacy SSH access (OFF by default — the control plane is vsock) ───────────────────
# Nucleic reaches the guest entirely over vsock now (exec + computer-use via the native in-guest
# agent installed in Phase 7d); Remote Login and an authorized SSH key are no longer part of the
# control plane and are NOT enabled. Set NUCLEIC_PROVISION_ENABLE_SSH=1 to opt back into the old
# SSH-over-NAT surface (e.g. for manual debugging) — then a public key may be passed as $1.
if [ "${NUCLEIC_PROVISION_ENABLE_SSH:-0}" = "1" ]; then
echo "▸ [1/8] Enabling Remote Login (sshd) [opt-in] …"
if sudo systemsetup -setremotelogin on 2>/dev/null; then
echo " ✓ Remote Login on."
else
echo " ⚠ Could not toggle Remote Login via systemsetup (needs Full Disk Access for Terminal)." >&2
fi
echo "▸ [2/8] Installing Nucleic's host public key into $AGENT_HOME/.ssh/authorized_keys …"
PUBKEY_SRC=""
if [ -n "$PUBKEY_ARG" ] && [ -f "$PUBKEY_ARG" ]; then
PUBKEY_SRC="$PUBKEY_ARG"
else
for cand in \
"$(cd "$(dirname "$0")" && pwd)/id_ed25519.pub" \
"$AGENT_HOME/id_ed25519.pub" \
"$SHARED_WORKSPACE/id_ed25519.pub"; do
[ -f "$cand" ] && { PUBKEY_SRC="$cand"; break; }
done
fi
if [ -n "$PUBKEY_SRC" ] && grep -Eq '^(ssh-ed25519|ssh-rsa|ecdsa-|sk-) ' "$PUBKEY_SRC"; then
SSH_DIR="$AGENT_HOME/.ssh"; AUTH_KEYS="$SSH_DIR/authorized_keys"
mkdir -p "$SSH_DIR"; touch "$AUTH_KEYS"
KEY_LINE="$(tr -d '\r' < "$PUBKEY_SRC")"
grep -qxF "$KEY_LINE" "$AUTH_KEYS" || printf '%s\n' "$KEY_LINE" >> "$AUTH_KEYS"
chmod 700 "$SSH_DIR"; chmod 600 "$AUTH_KEYS"; sudo chown -R "$AGENT_USER":staff "$SSH_DIR"
echo " ✓ key authorized."
else
echo " ⚠ NUCLEIC_PROVISION_ENABLE_SSH=1 but no valid public key found — skipping key authorization." >&2
fi
else
echo "▸ [12/8] Skipping Remote Login + SSH key (vsock-only base; set NUCLEIC_PROVISION_ENABLE_SSH=1 to opt in)."
fi
# ── Phase 3: Xcode Command Line Tools (the minimum toolchain) ────────────────────────────────────
# The CLT give us clang, git, and (crucially) `xcodebuild`/`xcrun`/`simctl`. FULL Xcode + iOS
# simulators are a separate, larger, Apple-ID-gated download — automated below via `xcodes` but left
# for the operator to trigger, since it needs credentials.
echo "▸ [3/8] Installing the Xcode Command Line Tools …"
# CLT are "present" only when the developer dir is set AND clang is actually resolvable. `xcode-select
# -p` alone can succeed against a half-registered dir, so check `xcrun --find clang` too.
clt_present() { /usr/bin/xcode-select -p >/dev/null 2>&1 && /usr/bin/xcrun --find clang >/dev/null 2>&1; }
# macOS ships no `timeout(1)`, and `softwareupdate -l` routinely WEDGES at "Finding available software"
# on beta / VM guests. Bound any command with perl's alarm (perl is always present) so one hung network
# call can't stall the whole build. `alarm` survives the exec, so the wrapped process is killed on time.
run_timeout() { perl -e 'alarm shift; exec @ARGV' "$@"; }
if clt_present; then
echo " ✓ CLT already installed at $(xcode-select -p)."
else
# Install HEADLESSLY via softwareupdate — never a bare `xcode-select --install`, which pops a GUI
# dialog that would block this unattended build. The on-demand trigger file makes the CLT package
# appear in `softwareupdate -l`; it MUST exist *before* we query.
TRIGGER=/tmp/.com.apple.dt.CommandLineTools.installondemand.in-progress
sudo touch "$TRIGGER" 2>/dev/null || touch "$TRIGGER" 2>/dev/null || true
# Find the CLT label — each query bounded so a wedged softwareupdate can't hang us. Match the
# "* Label: Command Line Tools …" line specifically (NOT the "Title:" line), newest version last.
# The trailing `|| true` is load-bearing under `set -euo pipefail`: on a beta/VM guest whose SU
# catalog lists no CLT at all, `grep` exits 1 and would otherwise abort the WHOLE provision run
# right here — before the on-demand-installer fallback below ever gets a chance.
CLT_LABEL=""
for attempt in 1 2 3; do
CLT_LABEL="$(run_timeout 180 softwareupdate -l 2>/dev/null \
| grep -E 'Label:.*Command Line Tools' \
| sed -E 's/^.*Label: *//' | sort -V | tail -1 || true)"
[ -n "$CLT_LABEL" ] && break
echo " (softwareupdate hasn't listed the CLT package yet — attempt $attempt) …"
done
if [ -n "$CLT_LABEL" ]; then
echo " installing via softwareupdate: $CLT_LABEL"
run_timeout 1800 sudo softwareupdate -i "$CLT_LABEL" --verbose || true
else
# softwareupdate can't enumerate the CLT (the norm on betas — it's not in the SU catalog), so the
# on-demand dialog is the only installer. Signal the host to auto-confirm that dialog via HID
# (it presses Return → the default "Install"), then trigger it. The host stops when we clear the
# sentinel below (once the CLT are present).
echo " ⚠ softwareupdate couldn't enumerate the CLT; using the on-demand installer (host confirms"
echo " its dialog automatically) …" >&2
: > "$PROVISION_SHARE/CLT_PROMPT" 2>/dev/null || true
sudo xcode-select --install >/dev/null 2>&1 || xcode-select --install >/dev/null 2>&1 || true
fi
sudo rm -f "$TRIGGER" 2>/dev/null || rm -f "$TRIGGER" 2>/dev/null || true
# Block (bounded, ≤20 min) until the tools actually land, BEFORE anything invokes xcodebuild/xcrun —
# calling those while the CLT are absent is what pops the "command line developer tools" GUI prompt.
for i in $(seq 1 80); do
clt_present && { echo " ✓ CLT installed at $(xcode-select -p)."; break; }
sleep 15
done
# Tell the host to stop confirming the (now-handled) CLT dialog.
rm -f "$PROVISION_SHARE/CLT_PROMPT" 2>/dev/null || true
fi
clt_present || echo " ⚠ CLT still absent after Phase 3 — downstream steps that need them are guarded/skipped." >&2
# Accept license + run first-launch ONLY once the CLT are present — invoking `xcodebuild` without them
# triggers the interactive "install the command line developer tools" dialog that stalls the build.
# (With CLT-only and no full Xcode these just no-op on stderr, which we swallow.)
if clt_present; then
sudo xcodebuild -runFirstLaunch 2>/dev/null || true
sudo xcodebuild -license accept 2>/dev/null || true
else
echo " ⚠ CLT still not present after waiting — skipping xcodebuild first-launch to avoid a GUI prompt." >&2
fi
# ── Phase 4: Homebrew + dev tools ────────────────────────────────────────────────────────────────
echo "▸ [4/8] Installing Homebrew + dev tools …"
# Apple silicon Homebrew lives in /opt/homebrew. Install non-interactively if absent.
if [ -x /opt/homebrew/bin/brew ]; then
BREW=/opt/homebrew/bin/brew
elif [ -x /usr/local/bin/brew ]; then
BREW=/usr/local/bin/brew
else
echo " installing Homebrew (non-interactive) …"
NONINTERACTIVE=1 /bin/bash -c \
"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
BREW=/opt/homebrew/bin/brew
[ -x "$BREW" ] || BREW=/usr/local/bin/brew
fi
eval "$("$BREW" shellenv)"
echo " installing packages via brew …"
# Bake the MAJOR dev toolchains into the golden base so every session clone is born ready to build
# JS/TS, Python, Rust, Go, and C/C++ projects without a first-run install. `brew install` is
# idempotent, and one big install call lets Homebrew resolve/download in parallel.
# • Languages/runtimes:
# node → node / npm / npx / corepack (JS/TS; corepack fronts yarn + pnpm)
# python → python3 / pip3 (the `pip` toolkit ships with the python formula)
# rust → rustc / cargo / rustup (Rust toolchain; `rustup` manages channels/targets)
# go → the Go toolchain (go build/test/mod)
# • Build systems + VCS: git, cmake, make (GNU make as `gmake`), pkg-config, autoconf, automake, libtool
# • Common CLI dev tools: jq, ripgrep, fd, fzf, tree, wget, curl, coreutils, gnu-sed, gh
# Kept as one list so a single `brew install` maximizes Homebrew's parallel fetch; `|| true` so one
# unbottled/failed formula never aborts the whole base build (the rest still install).
"$BREW" install \
node python rust rustup go \
git cmake make pkg-config autoconf automake libtool \
jq ripgrep fd fzf tree wget curl coreutils gnu-sed gh \
|| true
# Rust: `rustup` needs a default toolchain selected before `rustc`/`cargo` resolve; do it here (idempotent,
# offline-safe once the toolchain is present) so the base ships a working Rust out of the box.
if command -v rustup >/dev/null 2>&1; then
rustup default stable 2>/dev/null || rustup toolchain install stable 2>/dev/null || true
fi
# OPTIONAL tooling for the FULL Xcode path (documented below): `xcodes` + `aria2` for fast, resumable
# downloads. Purely best-effort — `xcodes` has no prebuilt bottle on beta macOS, so Homebrew would try
# to build it from source and fail (harmless `make` error); we don't need it for the base to work.
# Keep it fully quiet so a build-from-source failure never looks like a provisioning error.
if "$BREW" install --quiet aria2 xcodesorg/made/xcodes >/dev/null 2>&1; then
echo " ✓ installed xcodes + aria2 (optional full-Xcode helpers)."
else
echo " (skipped xcodes — optional, only for the manual full-Xcode install; no beta bottle. aria2 …)"
"$BREW" install --quiet aria2 >/dev/null 2>&1 || true
fi
cat <<'EOF'
── FULL Xcode + iOS simulators (optional, needs an Apple ID) ─────────────────────────────────
The CLT above are the MINIMUM. To build/run iOS apps in this guest you need full Xcode + a
simulator runtime. That download is Apple-ID-gated, so it can't be fully unattended here. With
`xcodes` + `aria2` now installed, run (inside the guest, as agent):
xcodes install --latest # prompts for your Apple ID; installs & selects Xcode
sudo xcodebuild -runFirstLaunch # installs bundled components
xcodes runtimes --include-unreleased | grep iOS
xcodes runtimes install "iOS 18.0" # or whatever simulator runtime you need
──────────────────────────────────────────────────────────────────────────────────────────────
EOF
# ── Phase 5: CRITICAL — toolchain PATH into /etc/zshenv ──────────────────────────────────────────
# Nucleic runs commands over SSH via the guest's default shell (zsh) in NON-login, non-interactive
# mode. Those shells source ONLY /etc/zshenv (not /etc/zprofile, ~/.zprofile, or ~/.zshrc), so PATH
# additions MUST live in /etc/zshenv or the engine won't find node/npm/python/xcodebuild. This is the
# single most load-bearing step in provisioning — get it wrong and remote builds fail with "command
# not found" despite the tools being installed.
echo "▸ [5/8] Appending toolchain PATH to /etc/zshenv (for non-login SSH shells) …"
BREW_PREFIX="$("$BREW" --prefix)" # /opt/homebrew (arm64) or /usr/local
ZSHENV=/etc/zshenv
MARKER="# >>> nucleic macvm toolchain PATH >>>"
END_MARKER="# <<< nucleic macvm toolchain PATH <<<"
# Idempotent: strip any existing Nucleic block first, then append a fresh one — so re-runs never
# duplicate the block or leave a stale PATH. awk matches the markers as FIXED strings (index()),
# avoiding all the regex-escaping hazards of doing this with sed.
if [ -f "$ZSHENV" ] && grep -qF "$MARKER" "$ZSHENV"; then
echo " ✓ /etc/zshenv already has the Nucleic PATH block (refreshing it)."
TMP_ZSHENV="$(mktemp)"
awk -v start="$MARKER" -v end="$END_MARKER" '
index($0, start) { skip = 1 }
!skip { print }
index($0, end) { skip = 0 }
' "$ZSHENV" > "$TMP_ZSHENV"
sudo cp "$TMP_ZSHENV" "$ZSHENV"
rm -f "$TMP_ZSHENV"
fi
sudo tee -a "$ZSHENV" >/dev/null <<EOF
$MARKER
# Added by scripts/provision-macos-guest.sh so Nucleic's non-login SSH zsh shells find the toolchain.
# Homebrew bin/sbin first, then the per-user toolchain bin dirs (\$HOME evaluated by the shell at runtime)
# so \`cargo install\` (~/.cargo/bin) and \`go install\` (~/go/bin) binaries also resolve in agent shells.
export PATH="$BREW_PREFIX/bin:$BREW_PREFIX/sbin:\$HOME/.cargo/bin:\$HOME/go/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:\$PATH"
$END_MARKER
EOF
echo " ✓ PATH written to $ZSHENV (prefix: $BREW_PREFIX)."
# ── Phase 5½: disable sleep / display-blanking (the guest must never nod off) ─────────────────────
# A session clone is a headless, auto-login worker that the host drives and SCREENSHOTS over vsock.
# With Apple's default energy-saver timers, after ~10 idle minutes macOS blanks the (virtual) display
# and can idle-sleep the whole guest — so the host's captures come back BLACK and computer-use no-ops,
# exactly the "monitors go black / VM seems asleep" symptom. The Linux base already pins this off via
# dconf (idle-delay=0, sleep-inactive-*-type='nothing', screensaver off); this is the macOS parallel,
# baked into the golden base so every clone is born awake.
#
# pmset writes /Library/Preferences/SystemConfiguration/com.apple.PowerManagement.plist (persists into
# the base image, hence into every clone). `-a` = all power sources; a VM has no real battery, but -a
# covers whatever source VZ reports. Some keys (e.g. powernap) may be rejected on a VM — tolerated.
echo "▸ [5½/8] Disabling display/system sleep so headless captures never go black …"
sudo pmset -a displaysleep 0 sleep 0 disksleep 0 2>/dev/null || \
echo " ⚠ 'pmset -a displaysleep 0 sleep 0 disksleep 0' reported an error (continuing)." >&2
# powernap is best-effort — not all VM builds accept it; never fail provisioning over it.
sudo pmset -a powernap 0 2>/dev/null || true
# The SCREENSAVER blanks the screen independently of display-sleep, so pin its idle timer to 0 (off)
# for the auto-login `agent` (ByHost pref). This script runs AS agent, so no sudo — target agent's db.
defaults -currentHost write com.apple.screensaver idleTime 0 2>/dev/null || \
echo " ⚠ could not disable the screensaver idle timer for $AGENT_USER (continuing)." >&2
echo " ✓ sleep disabled. Current pmset:"
pmset -g custom 2>/dev/null | grep -Ei 'displaysleep|[^k] sleep|disksleep|powernap' | sed 's/^/ /' || true
# ── Phase 5¾: disable the "Tips" app (don't let it auto-launch in the clones) ─────────────────────
# macOS auto-launches the Tips app (via the com.apple.tipsd launch agent) on early logins to show its
# "what's new" window. On a headless, auto-login session clone that window just steals focus and clutters
# the screen we screenshot — so pin tipsd OFF in the golden base. `launchctl disable gui/<uid>/<svc>`
# writes a PERSISTENT override (/var/db/com.apple.xpc.launchd/disabled.<uid>.plist) that lives in the
# base image, hence in every clone. Target the `agent` uid explicitly (not `id -u`) so this is correct
# even if the provisioner is ever invoked via sudo. Best effort — never fail the base over a cosmetic tweak.
echo "▸ [5¾/8] Disabling the Tips app (com.apple.tipsd) so it never auto-launches in clones …"
TIPS_UID="$(id -u "$AGENT_USER" 2>/dev/null || id -u)"
if sudo launchctl disable "gui/$TIPS_UID/com.apple.tipsd" 2>/dev/null \
|| launchctl disable "gui/$TIPS_UID/com.apple.tipsd" 2>/dev/null; then
echo " ✓ tipsd disabled for uid $TIPS_UID."
else
echo " ⚠ could not disable com.apple.tipsd for uid $TIPS_UID (continuing)." >&2
fi
# ── Phase 5⅞: disable window restoration (don't reopen the build's Terminal in clones) ─────────────
# The base is built by DRIVING a Terminal window to run the host bootstrap (nucleic-bootstrap.sh, which
# runs THIS script); that window is still open when the build powers the guest off. macOS's "Resume"
# feature saves open windows at logout/shutdown and RE-OPENS them on the next login — so without this,
# every session clone boots with the leftover `nucleic-bootstrap.sh` Terminal window restored onto the
# very desktop we screenshot. Pin window restoration OFF (per the `agent` user, baked into the base) so
# clones boot to a clean desktop and no stale Terminal window lingers after the bootstrap completes:
# • TALLogoutSavesState=false → loginwindow doesn't save open windows at logout/shutdown
# • NSQuitAlwaysKeepsWindows=false → apps don't reopen their windows on relaunch (global + Terminal)
# Runs as `agent`, so these land in the auto-login user's domains that loginwindow/Terminal actually read.
echo "▸ [5⅞/8] Disabling window restoration (so clones don't reopen the build's Terminal window) …"
defaults write com.apple.loginwindow TALLogoutSavesState -bool false 2>/dev/null \
|| echo " ⚠ could not disable loginwindow state saving (continuing)." >&2
defaults write NSGlobalDomain NSQuitAlwaysKeepsWindows -bool false 2>/dev/null || true
defaults write com.apple.Terminal NSQuitAlwaysKeepsWindows -bool false 2>/dev/null || true
# Drop any Terminal window state already saved during this build, so nothing lingers to restore.
rm -rf "$AGENT_HOME/Library/Saved Application State/com.apple.Terminal.savedState" 2>/dev/null || true
echo " ✓ window restoration disabled for $AGENT_USER."
# ── Phase 5⅞+: ensure NO notification-sweeping LaunchAgent is installed (removed) ─────────────────
# An earlier base installed a LaunchAgent that ran `killall NotificationCenter` at login and every 60s
# to clear banners off the screenshotted desktop. That is REMOVED — proven wrong on a live macOS 27
# clone for two reasons:
# 1. It does not work. The recurring boot banners (Data Access Blocked / Multiple Extensions Added /
# Tips) are persistent ALERT-style notifications; NotificationCenter is only the UI, has launchd
# KeepAlive, and RE-DRAWS them the instant it relaunches (`killall usernoted` doesn't clear them
# either). They are actually REPLAYED from the sealed base snapshot — the real fix is to seal the
# base with them dismissed and to kill their SOURCES (see scripts/macos-notification-fix/README.md).
# 2. It is HARMFUL. A session may be exercising an app's OWN notifications; a blanket 60s sweep would
# dismiss those too. The base must leave the notification framework intact and remove only the
# specific noise sources (Full Disk Access for the agent → no "Data Access Blocked"; Tips
# notifications off → no "Tips").
# So this step now GUARANTEES the sweeper is gone: boot out any live instance and delete the plist.
echo "▸ [5⅞+/8] Removing the old NotificationCenter-clearing LaunchAgent (ineffective + harmful) …"
KILLNC_PLIST="/Library/LaunchAgents/xyz.blakeslee.nucleic.killnotificationcenter.plist"
KILLNC_UID="$(id -u "$AGENT_USER" 2>/dev/null || id -u)"
sudo launchctl bootout "gui/$KILLNC_UID/xyz.blakeslee.nucleic.killnotificationcenter" 2>/dev/null || true
if [ -f "$KILLNC_PLIST" ]; then
sudo rm -f "$KILLNC_PLIST" && echo " ✓ removed $KILLNC_PLIST." || echo " ⚠ could not remove $KILLNC_PLIST." >&2
else
echo " ✓ no sweeper LaunchAgent present (nothing to remove)."
fi
# ── Phase 6: warm the simulator + shut down clean ────────────────────────────────────────────────
# Pre-accept / warm the iOS simulator first-launch so the first real agent turn isn't slowed by it.
# Harmless (and skipped) if only the CLT are installed and no runtimes exist yet.
echo "▸ [6/8] Warming the iOS simulator (best effort) …"
xcrun simctl list >/dev/null 2>&1 && echo " ✓ simctl responsive." || echo " (simctl not available yet — full Xcode/runtimes not installed.)"
# ══════════════════════════════════════════════════════════════════════════════════════════════════
# ── Phase 7: COMPUTER USE (GUI automation) ────────────────────────────────────────────────────────
# ══════════════════════════════════════════════════════════════════════════════════════════════════
# This phase makes the base drivable by Nucleic's `mac_vm_computer` MCP tool, which SSHes in and
# dispatches screen/input commands into the console user's Aqua GUI session:
#
# launchctl asuser $(id -u) <ABSOLUTE-PATH> …
#
# using screencapture for screenshots (base64'd back), cliclick for mouse/keyboard, and `open -a` for
# apps. Three things must be true in the guest for that to work; this phase sets up all three,
# IDEMPOTENTLY:
#
# (a) cliclick lives at the CANONICAL path /usr/local/bin/cliclick (the engine calls it there, and
# TCC attributes Accessibility to that exact path);
# (b) `agent` AUTO-LOGS-IN, so a headless boot has an active Aqua session (screencapture and
# cliclick have no GUI session to talk to otherwise);
# (c) Screen Recording + Accessibility are PRE-GRANTED in the SYSTEM TCC.db — the only unattended
# way to do which is SIP-off + a direct sqlite3 write (see the honest caveats below).
#
# See docs/MACOS_VM.md §12 (Computer use) for the full picture and the SIP/TCC reality.
echo "▸ [7/8] Setting up computer use (cliclick + auto-login + TCC pre-grant) …"
# ── 7a: cliclick at the canonical /usr/local/bin/cliclick ─────────────────────────────────────────
# Homebrew on Apple silicon installs to /opt/homebrew/bin, but the Swift engine calls cliclick at the
# EXACT path /usr/local/bin/cliclick, and TCC must attribute Accessibility to that exact path. So we
# `brew install` it, then COPY it (NOT a symlink — a copy preserves the code signature and gives a
# real file at the TCC-granted path; a symlink's target path can confuse TCC attribution).
echo " ▸ [7a] Installing cliclick and copying it to /usr/local/bin/cliclick …"
"$BREW" install cliclick 2>/dev/null || "$BREW" install cliclick || true
CLICLICK_SRC="$("$BREW" --prefix)/bin/cliclick"
if [ -x "$CLICLICK_SRC" ]; then
sudo mkdir -p /usr/local/bin
sudo cp "$CLICLICK_SRC" /usr/local/bin/cliclick
if /usr/local/bin/cliclick p >/dev/null 2>&1; then
echo " ✓ /usr/local/bin/cliclick installed (from $CLICLICK_SRC) and responsive."
else
echo " ⚠ /usr/local/bin/cliclick exists but 'cliclick p' failed — it may need Accessibility (7c)." >&2
fi
else
echo " ✗ brew did not produce cliclick at $CLICLICK_SRC — install it by hand, then re-run." >&2
fi
# ── 7b: auto-login for agent (so a headless boot has an Aqua session) ─────────────────────────────
# screencapture and cliclick need a LIVE console GUI (Aqua) session. A headless clone that boots to
# the login window has none, so screenshots come back black and clicks no-op. Auto-login lands `agent`
# straight into the desktop on every boot.
#
# ⚠ FileVault MUST be OFF for this to work — auto-login is fundamentally incompatible with FileVault
# (FV requires the password at pre-boot to unlock the disk, which defeats auto-login). The base is
# a disposable clone, not a device holding secrets, so FileVault-off is the right trade here.
#
# The password comes from $NUCLEIC_AGENT_PW, else the 2nd script arg ($2). We do NOT hardcode it.
echo " ▸ [7b] Enabling auto-login for '$AGENT_USER' …"
AGENT_PW="${NUCLEIC_AGENT_PW:-${2:-}}"
if [ -z "$AGENT_PW" ]; then
echo " ⚠ No agent password provided (set \$NUCLEIC_AGENT_PW or pass it as the 2nd arg) — SKIPPING"
echo " auto-login. Without it, a headless boot has no Aqua session and computer use won't work."
echo " Re-run as: ./provision-macos-guest.sh /path/to/id_ed25519.pub 'AGENT_PASSWORD'" >&2
else
if sudo sysadminctl -autologin set -userName "$AGENT_USER" -password "$AGENT_PW" 2>/dev/null; then
echo " ✓ auto-login set for $AGENT_USER."
sudo sysadminctl -autologin status 2>&1 | sed 's/^/ /' || true
else
echo " ⚠ sysadminctl could not set auto-login (is FileVault ON? it must be OFF)." >&2
fi
fi
# ── 7c: pre-grant TCC (Screen Recording + Accessibility) ──────────────────────────────────────────
# This is the hard part, and we're honest about it. The two grants we need:
#
# • kTCCServiceScreenCapture for /usr/sbin/screencapture (so `screencapture -x` isn't black)
# • kTCCServiceAccessibility for /usr/local/bin/cliclick (so cliclick can post events)
#
# Both live in the SYSTEM TCC db at /Library/Application Support/com.apple.TCC/TCC.db (NOT the per-user
# ~/Library one — Screen Recording & Accessibility are system-scoped).
#
# HONEST REALITY about unattended granting:
# • `tccutil` CANNOT grant — it only RESETs. There is no supported CLI to *add* a TCC grant.
# • PPPC/MDM profiles can pre-approve Accessibility, but can NEVER silently grant Screen Recording
# (Apple deliberately forces a manual user click for kTCCServiceScreenCapture — a PPPC payload for
# it only PRE-CONFIGURES the toggle, it does not turn it on).
# • The ONLY unattended path is: SIP DISABLED in the guest, then a direct sqlite3 INSERT into the
# system TCC.db. SIP protects that db from writes; with SIP on, the INSERT is denied.
#
# And `csrutil disable` is a ONE-TIME MANUAL step: it must run from recoveryOS, which has NO SSH and
# no scripting hook — a human boots the BASE VM into recoveryOS once and runs `csrutil disable`. So
# below we DETECT whether SIP is off; if it's on we print exactly what to do and SKIP gracefully
# (never failing the whole provisioner over it).
echo " ▸ [7c] Pre-granting TCC (Screen Recording + Accessibility) in the system TCC.db …"
SIP_OFF=0
if csrutil status 2>/dev/null | grep -qiE 'disabled|System Integrity Protection status: disabled'; then
SIP_OFF=1
fi
if [ "$SIP_OFF" -ne 1 ]; then
echo " ⚠ SIP is ENABLED — cannot write the system TCC.db, so computer-use TCC grants are SKIPPED."
echo " This is a ONE-TIME MANUAL step (recoveryOS has no SSH, so it can't be scripted):"
echo " 1. Fully shut down this guest."
echo " 2. Boot the BASE VM into recoveryOS (hold the power button / Options at boot in the"
echo " VZ window), open Terminal, and run: csrutil disable"
echo " 3. Reboot back into macOS and RE-RUN this provisioner — it will then grant TCC here."
echo " (Screen Recording in particular has NO other unattended grant path — PPPC/MDM can't do"
echo " it, and tccutil can only reset, never add.)" >&2
else
echo " ✓ SIP is disabled — writing TCC grants directly into the system TCC.db."
TCC_DB="/Library/Application Support/com.apple.TCC/TCC.db"
# grant_tcc <service> <abs-binary-path>: build the binary's code-signing designated requirement,
# compile it to a csreq blob, and INSERT (or REPLACE) a fully NAMED-COLUMN row allowing that
# service for that client path. We ALWAYS name columns because macOS keeps adding columns to
# `access` across releases (26 added several) — a positional INSERT breaks on the next OS. The
# csreq is OS-build-specific, so it's (re)generated here per build rather than hardcoded.
#
# client_type = 1 → the client is identified by ABSOLUTE PATH (0 would be a bundle id)
# auth_value = 2 → ALLOWED (0 denied, 1 unknown, 2 allowed, 3 limited)
# auth_reason = 4 → system set / MDM-style (a plausible provenance for a pre-grant)
# auth_version= 1 → current row schema version
# flags = 0
grant_tcc() { # $1=service $2=abs-binary-path
local req hexreq
# Extract the designated requirement; on some platform binaries this yields nothing usable.
req=$(codesign -d -r- "$2" 2>&1 | sed -n 's/^designated => //p' || true)
if [ -n "$req" ] && echo "$req" | csreq -r- -b /tmp/csreq.bin 2>/dev/null; then
hexreq="X'$(xxd -p /tmp/csreq.bin | tr -d '\n')'"
echo " • $1$2 (with code requirement)"
else
# FALLBACK: /usr/sbin/screencapture is a platform binary whose `codesign -d -r-` can come back
# empty/unusable. A NULL csreq still allows by PATH on many builds — TCC matches client_type=1
# rows on the client path alone when csreq is NULL. Less strict, but it unblocks the grant.
hexreq="NULL"
echo " • $1$2 (csreq NULL fallback — no usable designated requirement)"
fi
sudo sqlite3 "$TCC_DB" \
"INSERT OR REPLACE INTO access
(service,client,client_type,auth_value,auth_reason,auth_version,csreq,policy_id,indirect_object_identifier_type,indirect_object_identifier,indirect_object_code_identity,flags,last_modified)
VALUES('$1','$2',1,2,4,1,$hexreq,NULL,0,'UNUSED',NULL,0,strftime('%s','now'));"
}
grant_tcc kTCCServiceScreenCapture /usr/sbin/screencapture
grant_tcc kTCCServiceAccessibility /usr/local/bin/cliclick
# Bounce tccd so it reloads the db and the fresh grants take effect without a reboot.
sudo killall tccd 2>/dev/null || true
rm -f /tmp/csreq.bin 2>/dev/null || true
echo " ✓ TCC grants written. Current computer-use rows:"
sudo sqlite3 "$TCC_DB" \
"SELECT service,client,auth_value FROM access WHERE service IN ('kTCCServiceScreenCapture','kTCCServiceAccessibility');" \
2>/dev/null | sed 's/^/ /' || true
fi
# ── 7d: the NATIVE IN-GUEST AGENT (NucleicVMAgent.app + LaunchAgent + TCC) ────────────────────────
# The preferred computer-use path (docs/MACOS_VM_NATIVE_AGENT.md): a small signed .app, run as a
# per-user LaunchAgent in the auto-login Aqua session, that the host reaches over VSOCK (no SSH) and
# that drives the guest with Apple's frameworks directly — AXUIElement (semantic observe/control,
# and the ONLY observation channel on a macOS 26 guest whose framebuffer captures come back blank),
# ScreenCaptureKit (capture), CGEvent (raw input). Entirely OPTIONAL: when the app isn't staged, the
# base still works via the SSH + cliclick path above (Nucleic probes and falls back automatically).
#
# Build it on the HOST with scripts/build-vm-agent.sh and stage dist/* (the .app + the LaunchAgent
# plist) next to this script, in the agent home, or in the shared workspace.
echo " ▸ [7d] Installing the native in-guest agent (NucleicVMAgent.app) …"
AGENT_APP_DEST="/Applications/NucleicVMAgent.app"
AGENT_LA_PLIST="/Library/LaunchAgents/xyz.blakeslee.nucleic.vmagent.plist"
AGENT_BUNDLE_ID="xyz.blakeslee.nucleic.vmagent"
AGENT_APP_SRC=""
for cand in \
"$(cd "$(dirname "$0")" && pwd)/NucleicVMAgent.app" \
"$AGENT_HOME/NucleicVMAgent.app" \
"$SHARED_WORKSPACE/NucleicVMAgent.app"; do
[ -d "$cand" ] && { AGENT_APP_SRC="$cand"; break; }
done
if [ -z "$AGENT_APP_SRC" ]; then
echo " ⚠ NucleicVMAgent.app not staged — SKIPPING the native agent. Computer use will fall back"
echo " to the SSH + cliclick path (no ax_* semantic actions, and NO computer-use at all on a"
echo " macOS 26 guest whose screenshots are blank). To add it: on the host run"
echo " scripts/build-vm-agent.sh, stage its dist/* next to this script, and re-run."
else
# Install the app (a fresh copy every run — the bundle is tiny) and strip any quarantine xattr.
sudo rm -rf "$AGENT_APP_DEST"
sudo cp -R "$AGENT_APP_SRC" "$AGENT_APP_DEST"
sudo xattr -dr com.apple.quarantine "$AGENT_APP_DEST" 2>/dev/null || true
echo " ✓ installed $AGENT_APP_DEST (from $AGENT_APP_SRC)."
# LaunchAgent: Aqua-session-only, RunAtLoad + KeepAlive (staged copy preferred, generated else).
AGENT_LA_SRC="$(dirname "$AGENT_APP_SRC")/xyz.blakeslee.nucleic.vmagent.plist"
if [ -f "$AGENT_LA_SRC" ]; then
sudo cp "$AGENT_LA_SRC" "$AGENT_LA_PLIST"
else
sudo tee "$AGENT_LA_PLIST" >/dev/null <<'PLIST'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>xyz.blakeslee.nucleic.vmagent</string>
<key>ProgramArguments</key>
<array>
<string>/Applications/NucleicVMAgent.app/Contents/MacOS/NucleicVMAgent</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>LimitLoadToSessionType</key>
<string>Aqua</string>
<key>AssociatedBundleIdentifiers</key>
<string>xyz.blakeslee.nucleic.vmagent</string>
<key>StandardOutPath</key>
<string>/tmp/nucleic-vmagent.log</string>
<key>StandardErrorPath</key>
<string>/tmp/nucleic-vmagent.log</string>
</dict>
</plist>
PLIST
fi
sudo chown root:wheel "$AGENT_LA_PLIST"
sudo chmod 644 "$AGENT_LA_PLIST"
echo " ✓ LaunchAgent at $AGENT_LA_PLIST (Aqua session, RunAtLoad, KeepAlive)."
# TCC: the agent needs THREE grants (docs/MACOS_VM_NATIVE_AGENT.md §11), each a separate service
# row, keyed by BUNDLE ID (client_type=0) + the app's designated-requirement csreq blob:
# • kTCCServiceAccessibility — AXUIElement observe/control (AXIsProcessTrusted)
# • kTCCServicePostEvent — CGEvent input synthesis (a DISTINCT row from Accessibility)
# • kTCCServiceScreenCapture — ScreenCaptureKit
# Same SIP-off reality as 7c: with SIP on, the system TCC.db is not writable — skip gracefully.
if [ "$SIP_OFF" -ne 1 ]; then
echo " ⚠ SIP is ENABLED — the agent's TCC grants are SKIPPED (see 7c for the one-time"
echo " recoveryOS 'csrutil disable' step, then re-run this provisioner)." >&2
else
TCC_DB="/Library/Application Support/com.apple.TCC/TCC.db"
AGENT_HEXREQ="NULL"
# `|| true`: an unsigned/odd bundle makes codesign exit nonzero, which under pipefail would
# abort the whole run; an empty AGENT_REQ already degrades gracefully to csreq NULL below.
AGENT_REQ=$(codesign -d -r- "$AGENT_APP_DEST" 2>&1 | sed -n 's/^designated => //p' || true)
if [ -n "$AGENT_REQ" ] && echo "$AGENT_REQ" | csreq -r- -b /tmp/csreq-vmagent.bin 2>/dev/null; then
AGENT_HEXREQ="X'$(xxd -p /tmp/csreq-vmagent.bin | tr -d '\n')'"
else
echo " ⚠ no usable designated requirement (ad-hoc signed?) — writing csreq NULL rows."
echo " NOTE an ad-hoc identity changes every rebuild; sign with a stable identity" >&2
echo " (scripts/build-vm-agent.sh + NUCLEIC_VMAGENT_SIGN_IDENTITY) for durable grants." >&2
fi
for svc in kTCCServiceAccessibility kTCCServicePostEvent kTCCServiceScreenCapture; do
echo " • $svc ← $AGENT_BUNDLE_ID"
sudo sqlite3 "$TCC_DB" \
"INSERT OR REPLACE INTO access
(service,client,client_type,auth_value,auth_reason,auth_version,csreq,policy_id,indirect_object_identifier_type,indirect_object_identifier,indirect_object_code_identity,flags,last_modified)
VALUES('$svc','$AGENT_BUNDLE_ID',0,2,4,1,$AGENT_HEXREQ,NULL,0,'UNUSED',NULL,0,strftime('%s','now'));"
done
rm -f /tmp/csreq-vmagent.bin 2>/dev/null || true
# tccd must reload the db, and the agent must RESTART to pick the grants up (Screen Recording
# in particular is only read at process start).
sudo killall tccd 2>/dev/null || true
AGENT_UID="$(id -u "$AGENT_USER")"
sudo launchctl bootout "gui/$AGENT_UID/$AGENT_BUNDLE_ID" 2>/dev/null || true
sudo launchctl bootstrap "gui/$AGENT_UID" "$AGENT_LA_PLIST" 2>/dev/null || \
echo " (LaunchAgent will start on next login/boot — no live Aqua session to bootstrap into.)"
echo " ✓ agent TCC grants written; agent (re)started. Log: /tmp/nucleic-vmagent.log"
fi
fi
echo ""
echo " ── Computer use needs ALL THREE of the above (cliclick + auto-login + TCC) ─────────────────"
echo " If screenshots come back BLACK or clicks NO-OP after this base is live, check, in the guest:"
echo " • SIP is disabled: csrutil status"
echo " • TCC rows exist & allowed: sudo sqlite3 \"/Library/Application Support/com.apple.TCC/TCC.db\" \\"
echo " \"SELECT service,client,auth_value FROM access;\""
echo " • agent is auto-logged-in: an Aqua/desktop session is active (not the login window)"
echo " • native agent alive (7d): cat /tmp/nucleic-vmagent.log — should show 'starting on"
echo " vsock port 2035' and readiness true×3 (if the app was staged)"
echo " ──────────────────────────────────────────────────────────────────────────────────────────────"
# ══════════════════════════════════════════════════════════════════════════════════════════════════
# ── Phase 7½: user-provided apps → /Applications ────────────────────────────────────────────────────
# ══════════════════════════════════════════════════════════════════════════════════════════════════
# Nucleic stages any .app bundles the operator picked in Settings ("Base image" → "Included apps")
# into a `user-apps/` subdir of the provisioning share; bake each into the base's /Applications so
# every session clone has them. Best effort + idempotent: no share/subdir (a hands-on run) or a bad
# bundle just logs and moves on. Quarantine is stripped so the copied apps launch without Gatekeeper
# prompting inside the (disposable, NAT-isolated) guest.
USER_APPS_DIR="$PROVISION_SHARE/user-apps"
if [ -d "$USER_APPS_DIR" ]; then
echo "▸ Installing user-provided apps into /Applications …"
shopt -s nullglob
for app in "$USER_APPS_DIR"/*.app; do
name="$(basename "$app")"
dest="/Applications/$name"
if sudo rm -rf "$dest" && sudo cp -R "$app" "$dest"; then
sudo xattr -dr com.apple.quarantine "$dest" 2>/dev/null || true
echo " ✓ installed $dest"
else
echo " ⚠ failed to install $name — skipping." >&2
fi
done
shopt -u nullglob
fi
# ══════════════════════════════════════════════════════════════════════════════════════════════════
# ── Phase 7¾: common packages (operator-selected) → install ─────────────────────────────────────────
# ══════════════════════════════════════════════════════════════════════════════════════════════════
# When the operator opts into any "Common packages" in Settings, the host stages a generated
# `install-packages.sh` (from Sources/NucleicCore/MacVM/MacVMPackage.swift) into the provisioning
# share; each package fetches + installs itself into the guest (e.g. the latest Google Chrome, over
# the guest's NAT network — provisioning has network up by this point). Best effort + idempotent: no
# staged installer (a hands-on run, or nothing selected) just skips this. Runs as `agent` (admin, so
# /Applications is writable) — the same script the after-the-fact vsock path runs into a live guest.
PKG_INSTALLER="$PROVISION_SHARE/install-packages.sh"
[ -f "$PKG_INSTALLER" ] || PKG_INSTALLER="$(cd "$(dirname "$0")" && pwd)/install-packages.sh"
if [ -f "$PKG_INSTALLER" ]; then
echo "▸ Installing operator-selected common packages …"
/bin/bash "$PKG_INSTALLER" || echo " ⚠ common-package install reported errors (continuing)." >&2
fi
# ══════════════════════════════════════════════════════════════════════════════════════════════════
# ── Phase 7⅞: FULL Xcode first-launch + Metal toolchain (bake into the base, not per-run) ───────────
# ══════════════════════════════════════════════════════════════════════════════════════════════════
# If the operator baked a FULL Xcode into the base (staged as an "Included app" in Phase 7½, or dropped
# in by a common package), finish its one-time setup HERE so every disposable session clone is born
# build-ready. Without this, the first agent turn that builds a full-Xcode / Metal target pays for the
# first-launch component install AND the Metal toolchain download itself — on EVERY clone, since clones
# are thrown away. That's the "Xcode[-beta] needs its first-launch component install, then retry the
# Metal toolchain download" churn this phase eliminates.
#
# Runs AFTER Phase 7½/7¾ (that's when Xcode.app lands) and is fully guarded + idempotent: no full Xcode
# in /Applications ⇒ skip silently (CLT-only bases are unaffected). `run_timeout` bounds each step so a
# wedged download can't stall the base build; failures never abort provisioning.
echo "▸ [7⅞/8] Finalizing full Xcode (first-launch components + Metal toolchain), if present …"
# Locate a FULL Xcode by BUNDLE, not by a fixed name — it may be "Xcode.app" (stable) or "Xcode-beta.app"
# (beta channel), and the operator could stage either depending on their OS version/channel. Accept any
# /Applications/Xcode*.app that carries a real xcodebuild; prefer stable, then beta, then any other.
XCODE_APP=""
for cand in /Applications/Xcode.app /Applications/Xcode-beta.app /Applications/Xcode*.app; do
if [ -x "$cand/Contents/Developer/usr/bin/xcodebuild" ]; then XCODE_APP="$cand"; break; fi
done
if [ -z "$XCODE_APP" ]; then
echo " (no full Xcode in /Applications — skipping; the CLT-only base is unaffected.)"
else
XCODE_DEVDIR="$XCODE_APP/Contents/Developer"
echo " ▸ Using $XCODE_APP"
# Point the toolchain at full Xcode (overrides the CLT selected in Phase 3) so xcodebuild/xcrun and
# every agent shell resolve into it — and so first-launch/Metal act on THIS Xcode, not the CLT.
sudo /usr/bin/xcode-select -s "$XCODE_DEVDIR" 2>/dev/null \
&& echo " ✓ xcode-select → $XCODE_DEVDIR" \
|| echo " ⚠ could not xcode-select $XCODE_DEVDIR (continuing)." >&2
# Accept the license non-interactively (a fresh Xcode refuses to build until its license is accepted).
sudo "$XCODE_DEVDIR/usr/bin/xcodebuild" -license accept 2>/dev/null \
&& echo " ✓ Xcode license accepted" \
|| echo " ⚠ 'xcodebuild -license accept' reported an error (continuing)." >&2
# First-launch component install — the exact step the runtime message calls out. Installs Xcode's
# bundled packages (device support, dsym services, etc.). Bounded so it can't hang the base build.
echo " installing first-launch components (xcodebuild -runFirstLaunch) …"
run_timeout 1200 sudo "$XCODE_DEVDIR/usr/bin/xcodebuild" -runFirstLaunch 2>/dev/null \
&& echo " ✓ first-launch components installed" \
|| echo " ⚠ -runFirstLaunch reported an error/timeout (continuing)." >&2
# Pre-download the Metal toolchain. In Xcode 16+ it's a SEPARATE downloadable component
# (`-downloadComponent MetalToolchain`); older Xcodes bundle it and lack that verb. Probe first so a
# base that already has `metal` skips the (network) download and stays idempotent; then try the
# modern verb, falling back to the legacy `-downloadPlatform macOS`. Needs the guest's NAT network,
# which is up by this point (the common-packages phase above relies on it too).
if "$XCODE_DEVDIR/usr/bin/xcrun" --find metal >/dev/null 2>&1; then
echo " ✓ Metal toolchain already present (xcrun found 'metal') — skipping download."
else
echo " downloading the Metal toolchain …"
if run_timeout 1800 sudo "$XCODE_DEVDIR/usr/bin/xcodebuild" -downloadComponent MetalToolchain 2>/dev/null; then
echo " ✓ Metal toolchain downloaded (-downloadComponent)."
elif run_timeout 1800 sudo "$XCODE_DEVDIR/usr/bin/xcodebuild" -downloadPlatform macOS 2>/dev/null; then
echo " ✓ Metal toolchain downloaded (-downloadPlatform macOS fallback)."
else
echo " ⚠ Metal toolchain download failed/timed out — the first agent build may re-attempt it." >&2
fi
fi
fi
echo ""
echo "────────────────────────────────────────────────────────────────────────────────────────────────"
echo "✓ Provisioning complete. This guest is now Nucleic's golden base."
echo " Shutting DOWN cleanly so the host can clone the base bundle (a running/dirty guest can't be)."
echo " After it powers off, verify from the host with the default macvm-spike round-trip."
echo "────────────────────────────────────────────────────────────────────────────────────────────────"
# When Nucleic drives this over SSH it wants to read back the provisioning result (SIP/TCC/agent
# state) BEFORE the guest powers off, so it sets NUCLEIC_PROVISION_NO_SHUTDOWN=1 and owns the
# shutdown itself. A hands-on run leaves it unset and shuts down here as before.
if [ "${NUCLEIC_PROVISION_NO_SHUTDOWN:-0}" = "1" ]; then
echo "▸ [8/8] Skipping shutdown (NUCLEIC_PROVISION_NO_SHUTDOWN=1 — the caller will power off)."
exit 0
fi
echo "▸ [8/8] Shutting down …"
# `shutdown -h now` requires root; run last so nothing after it matters.
sudo shutdown -h now