Nucleic: The MacOS VM Base Image Provisioning
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user