773 lines
52 KiB
Bash
Executable File
773 lines
52 KiB
Bash
Executable File
#!/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 1–2: 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 "▸ [1–2/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
|