Files
nucleic/scripts/provision-macos-guest.sh
T
abkslmandnucleic e06155c92a Begin MACOS_VM_NATIVE_AGENT_Markdown
Nucleic-Session: 8BBA8B40-FA38-4556-8B3A-7A2DFD4C87B2
Co-authored-by: Nucleic <[email protected]>
2026-07-06 03:45:05 -07:00

503 lines
30 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 + 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), 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}')"
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 <<EOF
$MARKER
# Added by scripts/provision-macos-guest.sh so Nucleic's non-login SSH zsh shells find the toolchain.
export PATH="$BREW_PREFIX/bin:$BREW_PREFIX/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:\$PATH"
$END_MARKER
EOF
echo " ✓ PATH written to $ZSHENV (prefix: $BREW_PREFIX)."
# ── 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')
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"
AGENT_REQ=$(codesign -d -r- "$AGENT_APP_DEST" 2>&1 | sed -n 's/^designated => //p')
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 " ──────────────────────────────────────────────────────────────────────────────────────────────"
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