Nucleic: The MacOS VM Base Image Provisioning

This commit is contained in:
2026-07-22 22:49:52 -07:00
parent 52a28e7012
commit 1c7fc786ad
7 changed files with 284 additions and 95 deletions
@@ -23,16 +23,55 @@ extension MacVMEngine {
public enum MDMOrchestrationError: Error, CustomStringConvertible {
case guestCommandFailed(String)
case enrollmentNotApproved(String)
case missingPolicyAssets([String])
case drainTimedOut(pending: Int)
public var description: String {
switch self {
case .guestCommandFailed(let s): return "MDM guest step failed: \(s)"
case .enrollmentNotApproved(let s): return "MDM enrollment not approved: \(s)"
case .missingPolicyAssets(let names):
return "required MDM policy assets are missing: \(names.joined(separator: ", "))"
case .drainTimedOut(let p): return "MDM command drain timed out with \(p) still queued"
}
}
}
/// Profiles that make the ordinary VM-agent path non-interactive. Keep this list small: these
/// policies are installed into every golden base, unlike optional semantic-AX declarations.
static let builtInMDMPolicyProfileNames = [
"nucleic-vmagent-fda.mobileconfig",
"nucleic-suppress-notifications.mobileconfig",
]
/// Resolve the complete built-in policy set from a packaged app first, then the source tree for
/// SwiftPM/development runs. A partial set is never accepted: silently omitting one policy would
/// turn a deterministic build failure back into a GUI prompt later in provisioning.
static func builtInMDMPolicyProfileURLs(
bundleResourceURL: URL?, devRepoRoot: URL?
) -> [URL]? {
let fm = FileManager.default
let candidateDirectories = [
bundleResourceURL?.appendingPathComponent("macvm", isDirectory: true),
devRepoRoot?.appendingPathComponent("scripts/macos-notification-fix", isDirectory: true),
]
for directory in candidateDirectories.compactMap({ $0 }) {
let urls = builtInMDMPolicyProfileNames.map { directory.appendingPathComponent($0) }
if urls.allSatisfy({ fm.isReadableFile(atPath: $0.path) }) { return urls }
}
return nil
}
/// Load the exact policy payloads to queue during Mode A enrollment.
func resolveBuiltInMDMPolicyProfiles() throws -> [Data] {
guard let urls = Self.builtInMDMPolicyProfileURLs(
bundleResourceURL: Bundle.main.resourceURL,
devRepoRoot: Self.devRepoRoot())
else {
throw MDMOrchestrationError.missingPolicyAssets(Self.builtInMDMPolicyProfileNames)
}
return try urls.map { try Data(contentsOf: $0) }
}
// MARK: - Mode A enroll the base at build time
/// Enroll the running base guest in the local MDM and install `profiles`, then verify the Mode A
@@ -1,12 +1,8 @@
import Foundation
import os
#if arch(arm64)
import Virtualization
#endif
/// Diagnostics for the host-driven base-image provisioning pass.
private let provisionLog = Logger(subsystem: "com.nucleic", category: "macvm.provision")
/// **Toolchain + native-agent provisioning of the golden base, driven over SSH** the second half of
/// the one-click "Build base image" flow (docs/MACOS_VM.md §4). After the install +
/// account provisioning (`MacVMEngine+Provision27.swift`) leaves a key-authorized `agent` account,
@@ -339,52 +335,20 @@ extension MacVMEngine {
// provisioning surface binds its `VZVirtualMachineView` on the main thread, but the VM
// itself runs off-main like every other VM.
let mac = Self.randomMAC()
// The staged directory is attached TWICE, because the build needs two different things from it:
//
// an ENTRY point reachable cold. This share MUST opt out of host-path recreation
// (`recreateHostPath: false`) so it retains Apple's automount at
// `/Volumes/My Shared Files/nucleic-provision`. Unlike a session repo which the in-guest
// agent mounts at its original host path with `mount_virtiofs` once it's ready this base
// build is driven entirely by HID *before any agent exists* and before sudo is passwordless,
// so nothing in the guest can run `mount_virtiofs` yet. Automount is the only way the staged
// bootstrap becomes reachable at the fixed guest path the HID launch types. (Leaving this a
// default direct mount is what silently broke provisioning when the mounting system moved
// repo shares off `/Volumes/My Shared Files`.)
// a WORKING path without spaces. `/Volumes/My Shared Files/` has to be quoted correctly by
// every one of the provisioner's shell steps, and by the tools they invoke; one lost quote
// fails a 30-minute build. So the same directory also gets a custom-tagged single-directory
// device, which the bootstrap mounts at `provisionMountPoint` the moment it has passwordless
// sudo then runs the provisioner from THERE, so `$0`-derived lookups, the log redirection,
// and the STATUS readback are all space-free. The bootstrap falls back to the automount if
// that mount doesn't take, so this is an improvement, never a new dependency.
// The cold bootstrap must enter through Apple's automount: no agent or MDM policy exists yet.
// Do not attach and mount a second, custom-tagged copy of this directory here macOS treats
// that direct mount as a protected network volume and can display a permission sheet before
// the policy plane is available. A space-free direct mount may return only after Mode A has
// installed and verified the Network Volumes profile.
let entryMount = MacVMSpec.Mount(
host: stageDir.path, name: Self.provisionShareName, readOnly: false,
recreateHostPath: false)
let workMount = MacVMSpec.Mount(
host: stageDir.path, name: Self.provisionShareName, readOnly: false,
recreateHostPath: false, guestPath: Self.provisionMountPoint)
// `workMount` last: its virtiofs tag is derived from its index in this array.
let mounts = [entryMount, workMount]
var mountTag: String? = Self.macOSShareTag(index: mounts.count - 1)
let config: VZVirtualMachineConfiguration
do {
config = try Self.makeConfiguration(
bundle: bundle, cpus: MacVMSettings.vmCPUs, memoryGiB: MacVMSettings.vmMemoryGiB,
mac: mac, mounts: mounts)
} catch {
// Sharing one host directory over two devices is the only novel thing here; if a future
// OS rejects it, provision from the automount alone rather than failing the build.
provisionLog.error(
"custom provisioning mountpoint unavailable, using the automount: \(error, privacy: .public)")
mountTag = nil
config = try Self.makeConfiguration(
bundle: bundle, cpus: MacVMSettings.vmCPUs, memoryGiB: MacVMSettings.vmMemoryGiB,
mac: mac, mounts: [entryMount])
}
let config = try Self.makeConfiguration(
bundle: bundle, cpus: MacVMSettings.vmCPUs, memoryGiB: MacVMSettings.vmMemoryGiB,
mac: mac, mounts: [entryMount])
// Composed last: the bootstrap has to know whether the custom mountpoint is actually attached.
let bootstrapURL = stageDir.appendingPathComponent(Self.bootstrapScriptName)
try Self.provisionBootstrapScript(user: user, mountTag: mountTag).write(
try Self.provisionBootstrapScript(user: user, mountTag: nil).write(
to: bootstrapURL, atomically: true, encoding: .utf8)
try? fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: bootstrapURL.path)
@@ -419,10 +383,9 @@ extension MacVMEngine {
let statusURL = stageDir.appendingPathComponent("STATUS")
let startedURL = stageDir.appendingPathComponent("STARTED")
let logURL = stageDir.appendingPathComponent("provision.log")
let cltPromptURL = stageDir.appendingPathComponent("CLT_PROMPT")
let flags = await driveHIDBootstrap(
surface: surface, name: surfaceName, phase: activePhase,
startedURL: startedURL, statusURL: statusURL, logURL: logURL, cltPromptURL: cltPromptURL)
startedURL: startedURL, statusURL: statusURL, logURL: logURL)
baseProgress = MacVMBaseProgress(phase: .finalizing, fraction: nil)
await surface.detach(name: surfaceName)
@@ -479,7 +442,7 @@ extension MacVMEngine {
/// image" row, by contrast, holds a single steady "Provisioning" for the whole build.
private func driveHIDBootstrap(
surface: any MacVMSurfaceHost, name: String, phase: MacVMBaseProgress.Phase,
startedURL: URL, statusURL: URL, logURL: URL, cltPromptURL: URL
startedURL: URL, statusURL: URL, logURL: URL
) async -> ProvisionStatusFlags? {
let fm = FileManager.default
let deadline = Date().addingTimeInterval(1800) // 30 min: first login + toolchain install
@@ -498,14 +461,6 @@ extension MacVMEngine {
await Self.typeBootstrapLaunch(surface: surface, name: name)
nextLaunch = Date().addingTimeInterval(40) // retry login-timing until STARTED appears
}
// On betas the CLT aren't in the SU catalog, so the provisioner falls back to the
// `xcode-select --install` on-demand DIALOG and drops this sentinel. Confirm it via HID
// Return activates the default "Install" (and any follow-on "Agree"). The guest removes the
// sentinel once the tools are present, so we stop. (A stray Return into Terminal is a
// harmless newline.)
if fm.fileExists(atPath: cltPromptURL.path) {
await surface.send(name: name, .key(chord: "return"))
}
let detail = running
? (Self.lastProvisionLogLine(at: logURL) ?? "Running the provisioner in the guest…")
: nil
@@ -554,8 +509,8 @@ extension MacVMEngine {
// that harmless space rather than the slash (bash ignores leading whitespace before a command).
await surface.send(name: name, .text(" "))
await pause(0.5)
// Run the staged bootstrap from the automount the only share path that exists this early.
// (The bootstrap itself then moves to the space-free `provisionMountPoint`.)
// Run the staged bootstrap from the automount the only share path allowed before the MDM
// Network Volumes policy has been installed and verified.
let entry = guestSharePath(os: .macOS, name: provisionShareName)
let command = " /bin/bash '\(entry)/\(bootstrapScriptName)'"
await surface.send(name: name, .text(command))
+47 -7
View File
@@ -1466,21 +1466,61 @@ import Testing
}
}
// MARK: - Built-in MDM policy assets
@Test func builtInMDMPolicyResolutionRequiresACompleteSet() throws {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("mdm-policy-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: root) }
let bundled = root.appendingPathComponent("bundle", isDirectory: true)
let bundledMacVM = bundled.appendingPathComponent("macvm", isDirectory: true)
let repo = root.appendingPathComponent("repo", isDirectory: true)
let sourcePolicies = repo.appendingPathComponent(
"scripts/macos-notification-fix", isDirectory: true)
try FileManager.default.createDirectory(
at: bundledMacVM, withIntermediateDirectories: true)
try FileManager.default.createDirectory(
at: sourcePolicies, withIntermediateDirectories: true)
let names = MacVMEngine.builtInMDMPolicyProfileNames
#expect(names == [
"nucleic-vmagent-fda.mobileconfig",
"nucleic-suppress-notifications.mobileconfig",
])
// A partial packaged set is skipped in favor of a complete source-tree set.
try Data("partial".utf8).write(to: bundledMacVM.appendingPathComponent(names[0]))
for name in names {
try Data(name.utf8).write(to: sourcePolicies.appendingPathComponent(name))
}
let fallback = try #require(MacVMEngine.builtInMDMPolicyProfileURLs(
bundleResourceURL: bundled, devRepoRoot: repo))
#expect(fallback.map(\.lastPathComponent) == names)
#expect(fallback.allSatisfy { $0.path.contains("macos-notification-fix") })
// Once packaged assets are complete they take precedence.
try Data("complete".utf8).write(to: bundledMacVM.appendingPathComponent(names[1]))
let packaged = try #require(MacVMEngine.builtInMDMPolicyProfileURLs(
bundleResourceURL: bundled, devRepoRoot: repo))
#expect(packaged.map(\.lastPathComponent) == names)
#expect(packaged.allSatisfy { $0.path.contains("/bundle/macvm/") })
}
// MARK: - Failed-provisioning wedge (base without the in-guest agent)
/// Regression: on a macOS beta guest `softwareupdate -l` lists no Command Line Tools, so the CLT
/// label lookup's `grep` matches nothing. Under the script's `set -euo pipefail` an unguarded
/// pipeline there aborts the ENTIRE provision run before the on-demand-installer fallback
/// which is exactly how the 2026-07-14 base ended up published with no in-guest agent (every
/// clone then sat at "Booting" until the vsock deadline). The `|| true` guards are load-bearing.
@Test func macOSGuestProvisionerSurvivesEmptyCLTCatalog() throws {
/// Regression: an empty CLT catalog used to launch a graphical installer and ask the host to
/// press Return repeatedly. The unattended path must retain the pipefail guard, then stop with an
/// actionable error without creating a sentinel or launching any graphical installer.
@Test func macOSGuestProvisionerFailsClosedForEmptyCLTCatalog() throws {
let repoRoot = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent()
let provisioner = repoRoot.appendingPathComponent("scripts/provision-macos-guest.sh")
let script = try String(contentsOf: provisioner, encoding: .utf8)
#expect(script.contains("set -euo pipefail")) // the hazard the guards exist for
// CLT label lookup: no match must yield an empty label, not a dead script.
// CLT label lookup: no match must reach our explicit diagnostic, not die in the pipeline.
#expect(script.contains("sort -V | tail -1 || true"))
#expect(script.contains("Refusing to open the graphical xcode-select installer"))
#expect(!script.contains("sudo xcode-select --install"))
#expect(!script.contains("CLT_PROMPT"))
// codesign designated-requirement extraction: an unsigned bundle degrades to csreq NULL.
#expect(script.contains("sed -n 's/^designated => //p' || true"))
}
+135
View File
@@ -0,0 +1,135 @@
# macOS VM base-image provisioning repair
**Status:** implementation in progress
**Opened:** 2026-07-23
## Objective
Make the macOS 27 golden-base build genuinely unattended: no unrecognized GUI permission sheets,
no blind keystrokes into whichever dialog happens to have focus, and no base marked ready until a
production-shaped clone can boot, mount its workspace, and execute through the in-guest agent.
The existing lightweight MDM is the policy plane for permissions macOS allows device management to
grant. Host-side display capture and virtual HID remain the default computer-use path, so guest
Screen Recording and Accessibility are not prerequisites for an ordinary usable base.
## Findings
### 1. MDM is implemented but disconnected
`MacVMEngine+MDM.swift` contains Mode A enrollment and Mode B profile-push helpers, while
`NucleicMDM` contains a CA, enrollment profile, HTTPS server, command queue, and tests. The
production base build never calls those helpers. It runs the shell provisioner and then answers the
Network Volumes dialog with a separate OCR/click pass.
The `MacVMBaseStatus.mdmEnrolled` and `profilesInstalled` fields therefore describe planned state,
not a readiness invariant. Recipe v7 can be stamped while both remain false.
### 2. Provisioning creates policy prompts before policy exists
The bootstrap enters through Apple's VirtioFS automount, then remounts the same directory at a
custom path and executes the full provisioner there. The custom mount is treated as a network
volume, so the build touches a protected resource before any Network Volumes policy is installed.
The later consent pass repairs only `NucleicVMAgent` access. It is exact-English OCR for `Allow`,
cannot establish which dialog owns that button, and is fragile when more than one sheet is visible.
### 3. GUI fallback handling is not a state machine
When Command Line Tools are absent from the Software Update catalog, the guest launches
`xcode-select --install` and writes a sentinel. While the sentinel exists, the host sends Return
every three seconds. Return may accept the CLT sheet, an unrelated privacy sheet, or any other
focused control. Provisioning must recognize the expected UI state or stop; it must never treat all
dialogs as equivalent.
### 4. The MDM integration is still spike-grade
- UAMDM approval opens Device Management and sends one Return; Install, authentication, and
verification are placeholders.
- The built-in FDA and notification profiles are not copied into the shipped app.
- The CA, MDM configuration, device identity, and installed profile identifiers are not persisted
per base.
- Enrollment orchestration uses registry-based `run(name:)`, but maintenance boots hold a direct
`MacVMInstance`.
- A drained command queue may contain errored commands, and enrollment returning false is not
currently fatal.
- CA trust, guest-to-host TLS reachability, initial no-APNs command drain, real `mdmclient`
signatures, and headless UAMDM approval still need a live macOS 27 validation pass.
### 5. macOS 27 narrows the policy scope
Classic MDM profiles remain appropriate for Full Disk Access, Network Volumes, and targeted
notification settings. Classic PPPC Accessibility grants are removed in macOS 27 in favor of the
declarative `com.apple.configuration.app-settings` privacy model. Screen Recording cannot be
silently granted.
This does not block the base: pixel computer use is host-side and needs no guest TCC. Semantic AX
remains optional until the lightweight MDM supports Declarative Device Management.
## Implementation plan
### P0 — deterministic bootstrap
- [x] Package the built-in MDM policy profiles in `Resources/macvm`.
- [x] Add Network Volumes to the VM-agent PPPC policy.
- [x] Resolve packaged policy assets through one tested engine helper.
- [x] Stop blindly confirming the graphical CLT installer.
- [x] Keep the unattended path headless; if CLT cannot be installed headlessly, fail with an
actionable diagnostic rather than wait behind an unknown sheet.
- [x] Do not execute the full provisioner from a custom network-volume mount before policy exists.
- [ ] Record screenshots, recognized text, frontmost UI state, and TCC attribution when an unexpected
modal blocks progress.
### P1 — production Mode A enrollment
- [ ] Split the base build into:
1. declarative account creation and minimal exec-agent install through the automount;
2. MDM enrollment and required policy installation;
3. full toolchain provisioning after policy is active.
- [ ] Refactor enrollment to operate on a direct `MacVMInstance`.
- [ ] Persist the MDM CA, configuration, device identity, and profile identifiers beside the base
metadata with credential-appropriate permissions.
- [ ] Replace the UAMDM placeholder with a bounded, state-aware Device Management flow that enters
the generated guest password only in the expected authentication sheet.
- [ ] Treat enrollment false, command errors, missing profile identifiers, or failed functional
probes as fatal.
- [ ] Make re-provisioning update policy without attempting a second enrollment.
- [ ] Require `mdmEnrolled`, required profiles, agent readiness, and a production-shaped workspace
mount before stamping the provisioning recipe.
- [ ] Remove `MacVMEngine+ConsentGrant.swift`'s Network Volumes click path once the MDM path is proven.
### P2 — live macOS 27 validation
- [ ] Validate local-CA trust without a SecurityAgent prompt.
- [ ] Validate guest-to-host gateway/TLS reachability and check whether the listener introduces host
Local Network or incoming-connection prompts.
- [ ] Validate enrollment's initial command drain without an MDM APNs push certificate.
- [ ] Capture real `mdmclient` request signatures, then enable strict signature verification.
- [ ] Verify reboot, clone, reprovision, and app-update behavior.
- [ ] Test a non-English guest and reject any automation dependent on English-only button matching.
### P3 — optional semantic AX policy
- [ ] Implement Declarative Device Management enablement and declaration endpoints.
- [ ] Deliver macOS 27 app-settings privacy defaults for Accessibility/PostEvent only when semantic AX
is enabled.
- [ ] Keep Screen Recording and semantic AX out of ordinary base readiness.
## Release gate
A base is releasable only when a clean macOS 27 build proves:
- no unrecognized GUI sheets from restore through shutdown;
- required MDM enrollment and exact profile identifiers survive reboot;
- the first production-shaped workspace mount succeeds without a prompt;
- VM-agent FDA works without a Data Access Blocked notification;
- Tips/BTM noise is suppressed without disabling arbitrary app notifications;
- re-provisioning performs no duplicate enrollment;
- a fresh clone boots, mounts, executes, and shuts down unattended.
## Verification notes
- The two existing policy files parse as configuration-profile plists.
- `scripts/provision-macos-guest.sh` and `scripts/build-macos-base.sh` pass `bash -n`.
- The MDM protocol unit tests cover generated artifacts and a synthetic TLS command drain, but do not
exercise a real macOS guest or `mdmclient`.
@@ -3,9 +3,10 @@
<!--
Nucleic VM agent — Privacy Preferences Policy Control (PPPC) profile.
Grants Full Disk Access (SystemPolicyAllFiles) to the in-guest agent
Grants Full Disk Access (SystemPolicyAllFiles) and Network Volumes access to the in-guest agent
(xyz.blakeslee.nucleic.vmagent), which supersedes the per-folder "Files &
Folders" data-access checks. This removes the recurring boot-time banner:
Folders" data-access checks and lets agent execs touch custom-mounted VirtioFS workspaces without
a GUI consent sheet. This removes the recurring boot-time banner:
"Data Access Blocked — NucleicVMAgent tried to access your data from
other apps and was blocked."
@@ -13,11 +14,8 @@
IMPORTANT — PPPC payloads are ONLY honored when the profile is delivered
through an approved MDM (user-approved MDM or Automated Device Enrollment).
A manually-installed PPPC .mobileconfig is IGNORED by TCC on macOS 10.15+.
This base image runs with SIP disabled and already writes the agent's other
TCC grants directly into the system TCC.db (provision-macos-guest.sh, Phase
7d) — for that environment prefer the direct-write path documented in
README.md (one extra service in the existing grant loop), which needs no MDM.
Ship THIS profile only if the base is enrolled in an MDM.
The golden-base provisioning flow must therefore install this profile through
Nucleic's integrated MDM before it treats either permission as ready.
-->
<plist version="1.0">
<dict>
@@ -30,9 +28,9 @@
<key>PayloadUUID</key>
<string>e5bc298e-77a8-473a-a84e-8faee7297561</string>
<key>PayloadDisplayName</key>
<string>Nucleic VM Agent — Full Disk Access</string>
<string>Nucleic VM Agent — File Access</string>
<key>PayloadDescription</key>
<string>Grants Full Disk Access to the Nucleic in-guest agent so it is not blocked from reading app data (removes the "Data Access Blocked" notification).</string>
<string>Grants Full Disk Access and Network Volumes access to the Nucleic in-guest agent so app data and VirtioFS workspaces do not raise permission prompts.</string>
<key>PayloadOrganization</key>
<string>Nucleic</string>
<key>PayloadScope</key>
@@ -73,6 +71,23 @@
<string>Nucleic in-guest computer-use agent</string>
</dict>
</array>
<key>SystemPolicyNetworkVolumes</key>
<array>
<dict>
<key>Identifier</key>
<string>xyz.blakeslee.nucleic.vmagent</string>
<key>IdentifierType</key>
<string>bundleID</string>
<key>CodeRequirement</key>
<string>identifier "xyz.blakeslee.nucleic.vmagent" and anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] /* exists */ and certificate leaf[field.1.2.840.113635.100.6.1.13] /* exists */ and certificate leaf[subject.OU] = L7UDTQ6F5W</string>
<key>Authorization</key>
<string>Allow</string>
<key>StaticCode</key>
<false/>
<key>Comment</key>
<string>Nucleic in-guest agent access to VirtioFS workspace mounts</string>
</dict>
</array>
</dict>
</dict>
</array>
+8 -5
View File
@@ -181,17 +181,19 @@ for b in "$BIN_DIR"/*.bundle; do
done
shopt -u nullglob
# ── macOS-VM service: in-guest agent + provisioner ───────────────────────────────────────
# ── macOS-VM service: in-guest agent + provisioner + base policy ─────────────────────────
# The one-click "Build base image" (Settings ▸ Virtual Machines) provisions the golden base fully:
# it stages the native NucleicVMAgent.app (computer use over vsock) and scripts/provision-macos-guest.sh
# into the guest over SSH. Bake both into Resources/macvm so the shipped app needs no Swift toolchain
# at runtime — MacVMEngine+Provision.swift resolves them from there. Best-effort: a build host without
# the Swift toolchain (or a non-arm64 build) ships without the agent, so computer use is unavailable
# but the exec-only base still works. The .app is signed inside-out below (nested code).
# into the guest. Bake them and the required MDM policy profiles into Resources/macvm so the shipped
# app needs neither a source checkout nor a Swift toolchain at runtime. Best-effort: a build host
# without the Swift toolchain (or a non-arm64 build) ships without the agent, so computer use is
# unavailable but the exec-only base still works. The .app is signed inside-out below (nested code).
MACVM_RES="$CONTENTS/Resources/macvm"
VMAGENT_OUT="$ROOT/dist/.vmagent"
mkdir -p "$MACVM_RES"
cp "$ROOT/scripts/provision-macos-guest.sh" "$MACVM_RES/"
cp "$ROOT/scripts/macos-notification-fix/nucleic-vmagent-fda.mobileconfig" "$MACVM_RES/"
cp "$ROOT/scripts/macos-notification-fix/nucleic-suppress-notifications.mobileconfig" "$MACVM_RES/"
if NUCLEIC_VMAGENT_SIGN_IDENTITY="$SIGN_ID" "$ROOT/scripts/build-vm-agent.sh" "$VMAGENT_OUT" >/dev/null 2>&1 \
&& [ -d "$VMAGENT_OUT/NucleicVMAgent.app" ]; then
cp -R "$VMAGENT_OUT/NucleicVMAgent.app" "$MACVM_RES/"
@@ -200,6 +202,7 @@ if NUCLEIC_VMAGENT_SIGN_IDENTITY="$SIGN_ID" "$ROOT/scripts/build-vm-agent.sh" "$
else
echo " • NucleicVMAgent.app not built (no Swift toolchain?) — computer use unavailable; provisioner still embedded"
fi
echo " • embedded macOS VM policy (FDA + Network Volumes; targeted notification suppression)"
# nash — the Nucleic agent shell (docs/NASH.md §7.5, §8). Bake the universal darwin binary into
# Resources/macvm so base provisioning can stage it next to provision-macos-guest.sh
+18 -16
View File
@@ -172,31 +172,33 @@ else
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
if [ -z "$CLT_LABEL" ]; then
sudo rm -f "$TRIGGER" 2>/dev/null || rm -f "$TRIGGER" 2>/dev/null || true
echo " ✗ Command Line Tools are unavailable through the headless Software Update catalog." >&2
echo " Refusing to open the graphical xcode-select installer during unattended provisioning." >&2
echo " Seed a base with CLT or make the CLT product visible to softwareupdate, then retry." >&2
exit 1
fi
echo " installing via softwareupdate: $CLT_LABEL"
if ! run_timeout 1800 sudo softwareupdate -i "$CLT_LABEL" --verbose; then
sudo rm -f "$TRIGGER" 2>/dev/null || rm -f "$TRIGGER" 2>/dev/null || true
echo " ✗ Headless Command Line Tools installation failed." >&2
exit 1
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.
# calling those while the CLT are absent is what pops the interactive installer.
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
if ! clt_present; then
echo " ✗ Command Line Tools did not become usable after the headless installation." >&2
exit 1
fi
# 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.