diff --git a/Sources/NucleicCore/MacVM/MacVMEngine+MDM.swift b/Sources/NucleicCore/MacVM/MacVMEngine+MDM.swift index 2c731d25..14502698 100644 --- a/Sources/NucleicCore/MacVM/MacVMEngine+MDM.swift +++ b/Sources/NucleicCore/MacVM/MacVMEngine+MDM.swift @@ -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 diff --git a/Sources/NucleicCore/MacVM/MacVMEngine+Provision.swift b/Sources/NucleicCore/MacVM/MacVMEngine+Provision.swift index 60e9b6d8..341f09e9 100644 --- a/Sources/NucleicCore/MacVM/MacVMEngine+Provision.swift +++ b/Sources/NucleicCore/MacVM/MacVMEngine+Provision.swift @@ -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)) diff --git a/Tests/NucleicCoreTests/MacVMTests.swift b/Tests/NucleicCoreTests/MacVMTests.swift index 2d6643df..eb569516 100644 --- a/Tests/NucleicCoreTests/MacVMTests.swift +++ b/Tests/NucleicCoreTests/MacVMTests.swift @@ -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")) } diff --git a/docs/MACOS_VM_PROVISIONING_REPAIR_PLAN.md b/docs/MACOS_VM_PROVISIONING_REPAIR_PLAN.md new file mode 100644 index 00000000..74247001 --- /dev/null +++ b/docs/MACOS_VM_PROVISIONING_REPAIR_PLAN.md @@ -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`. diff --git a/scripts/macos-notification-fix/nucleic-vmagent-fda.mobileconfig b/scripts/macos-notification-fix/nucleic-vmagent-fda.mobileconfig index bf00ae12..31daf23b 100644 --- a/scripts/macos-notification-fix/nucleic-vmagent-fda.mobileconfig +++ b/scripts/macos-notification-fix/nucleic-vmagent-fda.mobileconfig @@ -3,9 +3,10 @@ @@ -30,9 +28,9 @@ PayloadUUID e5bc298e-77a8-473a-a84e-8faee7297561 PayloadDisplayName - Nucleic VM Agent — Full Disk Access + Nucleic VM Agent — File Access PayloadDescription - 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). + 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. PayloadOrganization Nucleic PayloadScope @@ -73,6 +71,23 @@ Nucleic in-guest computer-use agent + SystemPolicyNetworkVolumes + + + Identifier + xyz.blakeslee.nucleic.vmagent + IdentifierType + bundleID + CodeRequirement + 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 + Authorization + Allow + StaticCode + + Comment + Nucleic in-guest agent access to VirtioFS workspace mounts + + diff --git a/scripts/package-app.sh b/scripts/package-app.sh index 94651987..f1066621 100755 --- a/scripts/package-app.sh +++ b/scripts/package-app.sh @@ -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 diff --git a/scripts/provision-macos-guest.sh b/scripts/provision-macos-guest.sh index eb89a189..254d3ee6 100755 --- a/scripts/provision-macos-guest.sh +++ b/scripts/provision-macos-guest.sh @@ -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.