#!/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] # # 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@. # 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 + node, python, git, and common dev tools. # 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), and pre-grants Screen Recording + Accessibility # in the SYSTEM TCC.db so screencapture/cliclick work unattended (needs SIP off — see Phase 7). # 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}')" 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" 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; prompt once up front so the rest flows. echo "▸ This uses sudo for system settings — you may be prompted for the $AGENT_USER password." sudo -v # ── Phase 1: enable Remote Login (sshd) ────────────────────────────────────────────────────────── # Nucleic reaches the guest over SSH; without this the base is unreachable. On recent macOS, # `systemsetup -setremotelogin on` may require the caller (Terminal) to have Full Disk Access and can # prompt interactively — if it fails, enable it by hand in System Settings ▸ General ▸ Sharing. echo "▸ [1/8] Enabling Remote Login (sshd) …" 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, or a" echo " GUI prompt). Enable it manually: System Settings ▸ General ▸ Sharing ▸ Remote Login." >&2 fi # ── Phase 2: authorize Nucleic's host public key ───────────────────────────────────────────────── 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 # Look in a few obvious places the operator may have staged the key. for cand in \ "$(cd "$(dirname "$0")" && pwd)/id_ed25519.pub" \ "$(cd "$(dirname "$0")" && pwd)/nucleic-id_ed25519.pub" \ "$AGENT_HOME/id_ed25519.pub" \ "$AGENT_HOME/nucleic-id_ed25519.pub" \ "$SHARED_WORKSPACE/id_ed25519.pub"; do [ -f "$cand" ] && { PUBKEY_SRC="$cand"; break; } done fi if [ -z "$PUBKEY_SRC" ]; then echo "✗ No Nucleic public key found. Pass it as the first argument, e.g." >&2 echo " ./provision-macos-guest.sh /path/to/id_ed25519.pub" >&2 echo " The host prints its path (…/Nucleic/macvms/ssh/id_ed25519.pub) in build-macos-base.sh." >&2 exit 1 fi # Basic shape check so we don't authorize garbage. if ! grep -Eq '^(ssh-ed25519|ssh-rsa|ecdsa-|sk-) ' "$PUBKEY_SRC"; then echo "✗ $PUBKEY_SRC doesn't look like an OpenSSH public key." >&2 exit 1 fi SSH_DIR="$AGENT_HOME/.ssh" AUTH_KEYS="$SSH_DIR/authorized_keys" mkdir -p "$SSH_DIR" touch "$AUTH_KEYS" # Idempotent: only append if this exact key isn't already authorized. KEY_LINE="$(tr -d '\r' < "$PUBKEY_SRC")" if grep -qxF "$KEY_LINE" "$AUTH_KEYS"; then echo " ✓ key already authorized." else printf '%s\n' "$KEY_LINE" >> "$AUTH_KEYS" echo " ✓ key appended." fi # Ownership + perms sshd insists on (StrictModes): 700 dir, 600 file, owned by agent. chmod 700 "$SSH_DIR" chmod 600 "$AUTH_KEYS" sudo chown -R "$AGENT_USER":staff "$SSH_DIR" # ── 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 …" if xcode-select -p >/dev/null 2>&1; then echo " ✓ CLT already installed at $(xcode-select -p)." else # `xcode-select --install` shows a GUI installer; `softwareupdate` can do it headless on newer OSes. # Use the softwareupdate "label" trick so this can complete without a click when possible. touch /tmp/.com.apple.dt.CommandLineTools.installondemand.in-progress 2>/dev/null || true CLT_LABEL="$(softwareupdate -l 2>/dev/null \ | grep -E 'Command Line Tools' | tail -1 | sed -E 's/^[* ]*Label: //')" || true if [ -n "${CLT_LABEL:-}" ]; then echo " installing via softwareupdate: $CLT_LABEL" sudo softwareupdate -i "$CLT_LABEL" --verbose || xcode-select --install || true else echo " triggering the GUI CLT installer (complete it in the VM window if it appears) …" xcode-select --install || true fi rm -f /tmp/.com.apple.dt.CommandLineTools.installondemand.in-progress 2>/dev/null || true fi # Accept license + run first-launch for whatever developer tools are selected. Harmless if CLT-only. sudo xcodebuild -runFirstLaunch 2>/dev/null || true sudo xcodebuild -license accept 2>/dev/null || true # ── 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 …" # node → node/npm/npx; python → python3/pip3; git + common CLI dev tools. `brew install` is idempotent. "$BREW" install node python git jq ripgrep coreutils || true # Tooling for the FULL Xcode path (documented below): `xcodes` + `aria2` for fast, resumable downloads. "$BREW" install xcodesorg/made/xcodes aria2 2>/dev/null || "$BREW" install xcodes aria2 || true 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 </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) … # # 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 §14 (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 : 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') 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 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 " ──────────────────────────────────────────────────────────────────────────────────────────────" 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 "────────────────────────────────────────────────────────────────────────────────────────────────" echo "▸ [8/8] Shutting down …" # `shutdown -h now` requires root; run last so nothing after it matters. sudo shutdown -h now