Nucleic-Session: 307EEE49-F1A9-4AF7-B2C1-72C8473CD6B4 Co-authored-by: Nucleic <[email protected]>
575 lines
36 KiB
Bash
Executable File
575 lines
36 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 + 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"
|
||
# 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
|
||
|
||
# ── 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.
|
||
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)"
|
||
[ -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 …"
|
||
# 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
|
||
# 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.
|
||
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 " ──────────────────────────────────────────────────────────────────────────────────────────────"
|
||
|
||
# ══════════════════════════════════════════════════════════════════════════════════════════════════
|
||
# ── 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
|
||
|
||
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
|