Merge nucleic/mellow-drift-lemur-o6gt into dev
This commit is contained in:
@@ -80,7 +80,27 @@ extension MacVMEngine {
|
||||
"the configured prebuilt base at \(prebuilt) is missing its VM artifacts")
|
||||
}
|
||||
let built = MacVMBundle(root: builtBaseDir)
|
||||
if built.isComplete { return built }
|
||||
if built.isComplete {
|
||||
// Refuse to clone a base whose provisioning never landed the in-guest agent: the control
|
||||
// plane is vsock-only, so such a clone can NEVER become reachable — it would sit at
|
||||
// "Booting…" for the whole agent deadline and then fail opaquely. Fail fast and say why.
|
||||
// Only bundles that recorded a provisioning status are judged; a pre-metadata bundle
|
||||
// passes through as before. The launch staleness check auto-repairs an unusable base;
|
||||
// this error covers the window until that (or a manual rebuild) completes.
|
||||
let status = readBaseStatus(built)
|
||||
if status.installed, !status.agentInstalled {
|
||||
if baseIsBusy {
|
||||
throw MacVMError.baseBusy(
|
||||
"the base image is being (re)provisioned right now — retry once it finishes")
|
||||
}
|
||||
throw MacVMError.baseImageMissing(
|
||||
"the base macOS image exists, but its provisioning pass didn't complete (the "
|
||||
+ "in-guest agent is not installed), so VMs cloned from it can never be "
|
||||
+ "reached. It will be re-provisioned automatically at the next app launch, "
|
||||
+ "or run Settings ▸ Virtual Machines ▸ Build base image now.")
|
||||
}
|
||||
return built
|
||||
}
|
||||
throw MacVMError.baseImageMissing(
|
||||
"no base macOS image has been built yet. Build it once via Settings ▸ Virtual Machines ▸ "
|
||||
+ "Build base image, or `scripts/build-macos-base.sh`.")
|
||||
|
||||
@@ -141,33 +141,49 @@ extension MacVMEngine {
|
||||
) async throws {
|
||||
baseProgress = MacVMBaseProgress(
|
||||
phase: declarativeFirstBoot ? .firstBootSetup : .provisioning, fraction: nil)
|
||||
let result: ProvisionResult
|
||||
do {
|
||||
// The native agent is the sole host↔guest control channel now, so it is ALWAYS installed
|
||||
// (exec + computer-use ride it). Its AX *TCC grants* remain SIP-gated and are the only part
|
||||
// `MacVMSettings.axAgentEnabled` affects downstream (see `axAgentReady`).
|
||||
let result = try await provisionBase(
|
||||
result = try await provisionBase(
|
||||
bundle: bundle, installAgent: true, declarativeFirstBoot: declarativeFirstBoot,
|
||||
carryAppPaths: carryAppPaths, carryPackageIDs: carryPackageIDs)
|
||||
var status = base
|
||||
// Reaching a shell + running the bootstrap means the account exists and auto-login works.
|
||||
status.accountProvisioned = status.accountProvisioned || declarativeFirstBoot
|
||||
status.provisioned = result.provisioned
|
||||
status.agentInstalled = result.agentInstalled
|
||||
status.sipDisabled = result.sipDisabled
|
||||
status.axAgentReady = result.axAgentReady
|
||||
// Stamp the recipe this pass provisioned under, so a later app update that bumps the recipe
|
||||
// can detect this base as stale and force a re-provision (MacVMEngine+Reprovision).
|
||||
status.provisioningRecipe = Self.macOSProvisioningRecipe
|
||||
// Record what was actually staged (Settings ∪ carried-forward), so the NEXT rebuild that
|
||||
// replaces this base can carry these apps/packages forward in turn.
|
||||
status.installedAppPaths = result.installedAppPaths
|
||||
status.installedPackageIDs = result.installedPackageIDs
|
||||
writeBaseStatus(bundle, status)
|
||||
baseProgress = MacVMBaseProgress(phase: .ready, fraction: 1)
|
||||
} catch {
|
||||
baseProgress = nil
|
||||
throw MacVMError.provisionFailed(String(describing: error))
|
||||
}
|
||||
var status = base
|
||||
// Reaching a shell + running the bootstrap means the account exists and auto-login works.
|
||||
status.accountProvisioned = status.accountProvisioned || declarativeFirstBoot
|
||||
status.provisioned = result.provisioned
|
||||
status.agentInstalled = result.agentInstalled
|
||||
status.sipDisabled = result.sipDisabled
|
||||
status.axAgentReady = result.axAgentReady
|
||||
// Stamp the recipe this pass provisioned under — ONLY when the pass actually landed the
|
||||
// in-guest agent. The agent is the sole host↔guest control plane, so a pass without it left
|
||||
// the base unusable for exec/computer-use; stamping it anyway is what once wedged bases at
|
||||
// "recipe up to date" + `provisioned: false` — the launch staleness check saw nothing to
|
||||
// redo, and every clone booted agentless, pinned at "Booting…" until the vsock deadline.
|
||||
// Leaving the old stamp keeps the base detectably stale so the next launch retries.
|
||||
if result.agentInstalled {
|
||||
status.provisioningRecipe = Self.macOSProvisioningRecipe
|
||||
}
|
||||
// Record what was actually staged (Settings ∪ carried-forward), so the NEXT rebuild that
|
||||
// replaces this base can carry these apps/packages forward in turn. Written even for a failed
|
||||
// pass — the settings panel shows the partial flags rather than pretending nothing ran.
|
||||
status.installedAppPaths = result.installedAppPaths
|
||||
status.installedPackageIDs = result.installedPackageIDs
|
||||
writeBaseStatus(bundle, status)
|
||||
guard result.agentInstalled else {
|
||||
baseProgress = nil
|
||||
throw MacVMError.provisionFailed(
|
||||
"the pass finished without installing the in-guest agent"
|
||||
+ (result.provisioned ? "" : " (the provision script exited nonzero)")
|
||||
+ " — clones of this base would be unreachable. Check the build monitor / "
|
||||
+ "provision log and run Settings ▸ Virtual Machines ▸ Build base image again.")
|
||||
}
|
||||
baseProgress = MacVMBaseProgress(phase: .ready, fraction: 1)
|
||||
}
|
||||
|
||||
/// Provision the base **entirely host-side + VirtioFS, no network** (docs/MACOS_VM.md §4.4):
|
||||
|
||||
@@ -44,6 +44,18 @@ extension MacVMEngine {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a base's recorded status marks its last provisioning pass as having landed everything
|
||||
/// clones depend on. For macOS that is the toolchain pass **and** the in-guest agent (the vsock
|
||||
/// control plane rides it — without the agent no clone is ever reachable); a Linux base bakes its
|
||||
/// agent into the rootfs during the provision build, so `provisioned` alone is the signal. Pure,
|
||||
/// unit-tested; drives the launch staleness check's retry-on-failed-pass behavior.
|
||||
static func baseIsUsable(_ status: MacVMBaseStatus, os: GuestOS) -> Bool {
|
||||
switch os {
|
||||
case .macOS: return status.provisioned && status.agentInstalled
|
||||
case .linux: return status.provisioned
|
||||
}
|
||||
}
|
||||
|
||||
/// The outcome of a staleness check, for logging/telemetry.
|
||||
public enum BaseRecipeCheck: Sendable, Equatable {
|
||||
case noBase // nothing built to check
|
||||
@@ -76,15 +88,35 @@ extension MacVMEngine {
|
||||
var status = readBaseStatus(bundle)
|
||||
status.os = os
|
||||
|
||||
// Grandfather a pre-tracking base: record the current recipe, don't reprovision.
|
||||
// A recipe stamp — matching or absent — proves nothing for an unusable base: a failed pass
|
||||
// must be retried regardless, or every clone boots unreachable and sits at "Booting…" until
|
||||
// the vsock deadline.
|
||||
let usable = Self.baseIsUsable(status, os: os)
|
||||
|
||||
// Grandfather a pre-tracking base: record the current recipe, don't reprovision. Only for a
|
||||
// usable base — grandfathering a half-provisioned one (e.g. a fresh install whose provisioning
|
||||
// pass failed before ever stamping a recipe) would permanently wedge it as "up to date".
|
||||
guard let recorded = status.provisioningRecipe else {
|
||||
guard usable else {
|
||||
return await reprovision(os: os, bundle: bundle, status: status)
|
||||
}
|
||||
status.provisioningRecipe = current
|
||||
writeBaseStatus(bundle, status)
|
||||
return .grandfathered
|
||||
}
|
||||
if recorded == current { return .upToDate }
|
||||
if recorded == current, usable { return .upToDate }
|
||||
|
||||
// Stale. Don't stomp an in-flight build/Recovery; the next launch will catch it.
|
||||
return await reprovision(os: os, bundle: bundle, status: status)
|
||||
}
|
||||
|
||||
/// Run the actual re-provision for a base found stale (recipe behind) or unusable (last pass never
|
||||
/// landed the control plane). Split from ``reprovisionStaleBaseIfNeeded(for:)`` so both triggers
|
||||
/// share the busy guard and the per-OS build path.
|
||||
private func reprovision(
|
||||
os: GuestOS, bundle: MacVMBundle, status: MacVMBaseStatus
|
||||
) async -> BaseRecipeCheck {
|
||||
var status = status
|
||||
// Don't stomp an in-flight build/Recovery; the next launch will catch it.
|
||||
guard !baseIsBusy else { return .busy }
|
||||
|
||||
#if arch(arm64)
|
||||
@@ -93,11 +125,10 @@ extension MacVMEngine {
|
||||
case .macOS:
|
||||
// The reentrant `buildBaseImage` path skips the multi-GB reinstall on a complete base
|
||||
// and re-runs only the provisioning pass, which re-stages the *current* bundled script
|
||||
// + agent and re-stamps the recipe (see `provisionAndFinalize`). It needs the account
|
||||
// already provisioned; a base that never got that far can't be reprovisioned unattended.
|
||||
guard status.accountProvisioned else {
|
||||
return .reprovisionFailed("the base has no provisioned agent account yet")
|
||||
}
|
||||
// + agent and re-stamps the recipe (see `provisionAndFinalize`). A base with no
|
||||
// provisioned agent account falls through to `buildBaseImage`'s clean-reinstall path
|
||||
// (the macOS-27 declarative first-boot slot was consumed; reinstall is the only
|
||||
// unattended fix, and the cached IPSW makes it a reinstall, not a re-download).
|
||||
try await buildBaseImage()
|
||||
case .linux:
|
||||
// A Linux base bakes the agent + toolchain into the rootfs at build time, so a genuine
|
||||
|
||||
@@ -306,11 +306,14 @@ public actor MacVMEngine {
|
||||
@discardableResult
|
||||
public func ensureRunning(_ spec: MacVMSpec) async throws -> (name: String, ipAddress: String) {
|
||||
lastSpec[spec.name] = spec
|
||||
if let existing = live[spec.name], let ip = existing.ipAddress {
|
||||
// Reuse keys on `ready` — NOT on `ipAddress`. The control plane is vsock-only, so a fully
|
||||
// usable guest routinely has no NAT lease for its whole life; gating reuse on the IP would
|
||||
// send every follow-up call for a live VM into `performBoot`, double-booting its own disk.
|
||||
if let existing = live[spec.name], existing.ready {
|
||||
// Transparently thaw a VM the agent suspended (`*_vm_control suspend`) before handing it
|
||||
// back, so exec/computer-use never has to know it was frozen.
|
||||
if existing.paused { _ = await resume(name: spec.name) }
|
||||
return (spec.name, ip)
|
||||
return (spec.name, existing.ipAddress ?? "")
|
||||
}
|
||||
// Join an in-flight boot for the same name rather than starting a second one.
|
||||
if let inFlight = boots[spec.name] {
|
||||
@@ -338,9 +341,10 @@ public actor MacVMEngine {
|
||||
while live.count + boots.count >= limit {
|
||||
// Another call may have booted or joined this exact VM while we were parked — re-honor the
|
||||
// dedup/reuse fast paths before consuming a slot for a duplicate of a VM that now exists.
|
||||
if let existing = live[spec.name], let ip = existing.ipAddress {
|
||||
// Same rule as the fast path above: reuse keys on `ready`, never on the NAT IP.
|
||||
if let existing = live[spec.name], existing.ready {
|
||||
if existing.paused { _ = await resume(name: spec.name) }
|
||||
return (spec.name, ip)
|
||||
return (spec.name, existing.ipAddress ?? "")
|
||||
}
|
||||
if let inFlight = boots[spec.name] { return try await inFlight.value }
|
||||
// Reclaim the stalest done chat's VM if the policy offers one. Prefer **suspend-to-disk**:
|
||||
|
||||
@@ -1134,4 +1134,85 @@ import Testing
|
||||
#expect(MacVMSettings.selectedPackageIDs == ["chrome"])
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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.
|
||||
#expect(script.contains("sort -V | tail -1 || true"))
|
||||
// codesign → designated-requirement extraction: an unsigned bundle degrades to csreq NULL.
|
||||
#expect(script.contains("sed -n 's/^designated => //p' || true"))
|
||||
}
|
||||
|
||||
/// The launch staleness check must treat a base whose last pass failed as needing re-provisioning
|
||||
/// even when its recipe stamp matches — a stamp proves nothing about a pass that didn't land the
|
||||
/// control plane. macOS needs the toolchain AND the in-guest agent (exec/computer-use are
|
||||
/// vsock-only); Linux bakes its agent during the provision build, so `provisioned` is the signal.
|
||||
@Test func baseUsabilityRequiresTheControlPlane() {
|
||||
func status(provisioned: Bool, agent: Bool) -> MacVMBaseStatus {
|
||||
var s = MacVMBaseStatus()
|
||||
s.installed = true
|
||||
s.accountProvisioned = true
|
||||
s.provisioned = provisioned
|
||||
s.agentInstalled = agent
|
||||
return s
|
||||
}
|
||||
#expect(MacVMEngine.baseIsUsable(status(provisioned: true, agent: true), os: .macOS))
|
||||
#expect(!MacVMEngine.baseIsUsable(status(provisioned: false, agent: false), os: .macOS))
|
||||
#expect(!MacVMEngine.baseIsUsable(status(provisioned: true, agent: false), os: .macOS))
|
||||
#expect(!MacVMEngine.baseIsUsable(status(provisioned: false, agent: true), os: .macOS))
|
||||
#expect(MacVMEngine.baseIsUsable(status(provisioned: true, agent: false), os: .linux))
|
||||
#expect(!MacVMEngine.baseIsUsable(status(provisioned: false, agent: false), os: .linux))
|
||||
}
|
||||
|
||||
/// A complete-on-disk base whose provisioning never installed the in-guest agent must be refused
|
||||
/// at clone time with an actionable error — a clone of it can never answer the vsock readiness
|
||||
/// probe, so booting it would burn the whole agent deadline stuck at "Booting…". Once the status
|
||||
/// records the agent, the same bundle resolves normally.
|
||||
@Test func ensureBaseBundleRefusesAgentlessBase() async throws {
|
||||
// A fresh defaults suite so a developer machine's `basePrebuiltPath` can't reroute the lookup.
|
||||
let suite = UserDefaults(suiteName: "macvm-base-\(UUID().uuidString)")!
|
||||
try await ContainerServiceSettings.withDefaults(suite) {
|
||||
let root = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("macvm-base-\(UUID().uuidString)", isDirectory: true)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let engine = MacVMEngine(storageRoot: root)
|
||||
let baseDir = root.appendingPathComponent("base", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: baseDir, withIntermediateDirectories: true)
|
||||
let bundle = MacVMBundle(root: baseDir)
|
||||
for artifact in [
|
||||
bundle.hardwareModelURL, bundle.machineIdentifierURL,
|
||||
bundle.auxiliaryStorageURL, bundle.diskImageURL,
|
||||
] {
|
||||
try Data().write(to: artifact)
|
||||
}
|
||||
|
||||
func writeStatus(agentInstalled: Bool) throws {
|
||||
let json: [String: Any] = [
|
||||
"installed": true, "accountProvisioned": true, "provisioned": false,
|
||||
"agentInstalled": agentInstalled, "provisioningRecipe": 4,
|
||||
]
|
||||
try JSONSerialization.data(withJSONObject: json).write(to: bundle.metadataURL)
|
||||
}
|
||||
|
||||
try writeStatus(agentInstalled: false)
|
||||
await #expect(throws: MacVMError.self) {
|
||||
_ = try await engine.ensureBaseBundle(for: .macOS)
|
||||
}
|
||||
|
||||
try writeStatus(agentInstalled: true)
|
||||
let resolved = try await engine.ensureBaseBundle(for: .macOS)
|
||||
#expect(resolved.root == baseDir)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ 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="$(/usr/bin/dscl . -read "/Users/$AGENT_USER" NFSHomeDirectory 2>/dev/null | awk '{print $2}' || true)"
|
||||
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
|
||||
@@ -143,11 +143,14 @@ else
|
||||
|
||||
# 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.
|
||||
# The trailing `|| true` is load-bearing under `set -euo pipefail`: on a beta/VM guest whose SU
|
||||
# catalog lists no CLT at all, `grep` exits 1 and would otherwise abort the WHOLE provision run
|
||||
# right here — before the on-demand-installer fallback below ever gets a chance.
|
||||
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)"
|
||||
| sed -E 's/^.*Label: *//' | sort -V | tail -1 || true)"
|
||||
[ -n "$CLT_LABEL" ] && break
|
||||
echo " (softwareupdate hasn't listed the CLT package yet — attempt $attempt) …"
|
||||
done
|
||||
@@ -497,7 +500,7 @@ else
|
||||
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')
|
||||
req=$(codesign -d -r- "$2" 2>&1 | sed -n 's/^designated => //p' || true)
|
||||
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)"
|
||||
@@ -609,7 +612,9 @@ PLIST
|
||||
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')
|
||||
# `|| true`: an unsigned/odd bundle makes codesign exit nonzero, which under pipefail would
|
||||
# abort the whole run; an empty AGENT_REQ already degrades gracefully to csreq NULL below.
|
||||
AGENT_REQ=$(codesign -d -r- "$AGENT_APP_DEST" 2>&1 | sed -n 's/^designated => //p' || true)
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user