From 6d394d8f58a5b5addff2da8f4a9021ba88b81aca Mon Sep 17 00:00:00 2001 From: Nucleic Date: Wed, 5 Aug 2026 19:53:03 -0700 Subject: [PATCH] Merge nucleic/keen-grove-ferret-3rgz into dev --- .../MacVM/MacVMEngine+Reprovision.swift | 8 ++- Tests/NucleicCoreTests/MacVMTests.swift | 24 +++++---- docs/MACOS_VM.md | 16 +++--- scripts/provision-macos-guest.sh | 51 ++++++++++++++----- 4 files changed, 70 insertions(+), 29 deletions(-) diff --git a/Sources/NucleicCore/MacVM/MacVMEngine+Reprovision.swift b/Sources/NucleicCore/MacVM/MacVMEngine+Reprovision.swift index 1a6cafed..2a19781f 100644 --- a/Sources/NucleicCore/MacVM/MacVMEngine+Reprovision.swift +++ b/Sources/NucleicCore/MacVM/MacVMEngine+Reprovision.swift @@ -97,7 +97,13 @@ extension MacVMEngine { // on exec ("damaged and can't be opened"). Phase 7⅞ then blamed the Xcode license for that // SIGKILL and failed the build. §7½ now also proves a required Xcode RUNS before accepting it. // A v14 base was either never published or holds a damaged Xcode, so force them all. - public static let macOSProvisioningRecipe = 15 + // v16: Xcode 27 allows `xcodebuild -version` before its license is accepted, so v15's alleged + // functional license probe returned a false positive, skipped `-license accept`, and then + // failed every base at `-runFirstLaunch`. Phase 7⅞ now compares the bundle's declared + // LicenseInfo.plist ID/type against the matching system-wide IDELastLicenseAgreedTo + // value. Force any v15 base through the exact license gate (failed bases retry regardless, + // while this also corrects any base that happened to get stamped around the bad heuristic). + public static let macOSProvisioningRecipe = 16 /// The current Linux base **provisioning-recipe version**. Bump when the bundled Linux provisioning /// assets change in a way that must re-provision an already-built base — a new step in diff --git a/Tests/NucleicCoreTests/MacVMTests.swift b/Tests/NucleicCoreTests/MacVMTests.swift index a2766821..97ed8366 100644 --- a/Tests/NucleicCoreTests/MacVMTests.swift +++ b/Tests/NucleicCoreTests/MacVMTests.swift @@ -1699,7 +1699,7 @@ import UniformTypeIdentifiers /// prints "Killed: 9"; Finder says "damaged and can't be opened"). Phase 7⅞ met exactly that and /// reported it as an unaccepted Xcode LICENSE — sending the operator to run `-license accept` /// against a bundle that cannot run at all. A signal death must be diagnosed as damage, at the - /// copy, and a plain nonzero exit must still be treated as the license being outstanding. + /// copy, while license state must be checked independently against the bundle's declared ID. @Test func macOSGuestProvisionerDiagnosesADamagedXcodeRatherThanBlamingTheLicense() throws { let repoRoot = URL(fileURLWithPath: #filePath) .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() @@ -1711,21 +1711,22 @@ import UniformTypeIdentifiers let installed = try #require(script.range(of: "installed $name is DAMAGED")) #expect(script.contains(#"|| xcodebuild_rc=$?"#)) #expect(script.contains(#"if [ "$xcodebuild_rc" -ge 128 ]"#)) - // …and the later license pass separates a signal from a nonzero exit before it accuses the - // license, which it must still go on to accept for the ordinary (exit 1) case. - let licenseCheck = try #require(script.range(of: "xcode_run_status()")) + // …and the later license pass keeps the signal-only integrity probe separate from the exact + // preference-based license check, which still goes on to accept an outstanding license. + let licenseCheck = try #require(script.range(of: "xcode_binary_status()")) let damaged = try #require(script.range(of: "is DAMAGED: xcodebuild is killed by signal")) let licenseAccepted = try #require(script.range(of: "-license accept", options: .backwards)) #expect(installed.upperBound < licenseCheck.lowerBound) #expect(damaged.upperBound < licenseAccepted.lowerBound) #expect(script.contains("This is NOT a license problem")) - #expect(script.contains(#"xcode_license_ok() { [ "$(xcode_run_status)" -eq 0 ]; }"#)) + #expect(script.contains("XCODE_LICENSE_INFO")) + #expect(script.contains("IDELast${XCODE_LICENSE_TYPE}LicenseAgreedTo")) } /// A copied-in Xcode is unusable until its license is accepted by root, and an agent exec has no /// TTY to answer the prompt in — so the provisioner must accept it during the base build, verify - /// the acceptance the way a build does, and fall back to driving the interactive prompt with its - /// confirmations pre-fed rather than trusting `-license accept`'s exit code. + /// the exact license ID recorded system-wide, and fall back to driving the interactive prompt + /// with its confirmations pre-fed rather than trusting `-license accept`'s exit code. @Test func macOSGuestProvisionerAcceptsTheXcodeLicenseBeforeBuildingAnything() throws { let repoRoot = URL(fileURLWithPath: #filePath) .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() @@ -1740,8 +1741,13 @@ import UniformTypeIdentifiers script.range(of: "printf 'q\\nagree\\nagree\\ny\\n'", options: .backwards)) #expect(accept.upperBound < interactive.lowerBound) #expect(script.contains("sudo \"$XCODE_DEVDIR/usr/bin/xcodebuild\" -license >/dev/null")) - // Verified functionally (what a build hits), not by exit code… + // Verified against this bundle's exact declared license, not via `-version` (Xcode 27 allows + // that informational command while its license is still outstanding) or the accept exit code. #expect(script.contains("xcode_license_ok()")) + #expect(script.contains("Contents/Resources/LicenseInfo.plist")) + #expect(script.contains(#"defaults read /Library/Preferences/com.apple.dt.Xcode"#)) + #expect(script.contains(#"[ "$agreed" = "$XCODE_LICENSE_ID" ]"#)) + #expect(!script.contains(#"xcode_license_ok() { [ "$(xcode_binary_status)" -eq 0 ]; }"#)) // …and everything happens before the Metal download, which needs a licensed xcodebuild. let metal = try #require( script.range(of: "-downloadComponent MetalToolchain", options: .backwards)) @@ -2962,7 +2968,7 @@ import UniformTypeIdentifiers provisioned: true, agent: false, grants: true), os: .macOS)) #expect(MacVMEngine.baseIsUsable(status(provisioned: true, agent: false), os: .linux)) #expect(!MacVMEngine.baseIsUsable(status(provisioned: false, agent: false), os: .linux)) - #expect(MacVMEngine.macOSProvisioningRecipe == 15) + #expect(MacVMEngine.macOSProvisioningRecipe == 16) } /// Clone-time admission duplicates the current-recipe publication invariant: neither a stale diff --git a/docs/MACOS_VM.md b/docs/MACOS_VM.md index 5a7b31f0..0ce9c008 100644 --- a/docs/MACOS_VM.md +++ b/docs/MACOS_VM.md @@ -545,13 +545,15 @@ This is what gives a guest a `metal` compiler, and there is no alternative: (falling back to `-downloadPlatform macOS`), and finally a **verification** — resolve `metal` through `xcrun` and compile a probe kernel, because `-downloadComponent` can exit 0 with an unusable toolchain. -The license step matters as much as the download: a *copied* Xcode refuses every `xcodebuild`/`xcrun` -call with *"You have not agreed to the Xcode license agreements"* until root records the agreement, and -an exec-only agent has no TTY to answer that prompt in. §7⅞ tries `sudo xcodebuild -license accept`, -then — if the license is still outstanding — drives the interactive `sudo xcodebuild -license` with its -confirmations pre-fed on stdin (`q`, `agree`), bounded by `run_timeout`. Acceptance is checked -*functionally* after each attempt, by running `xcodebuild -version` as the agent (exactly what a build -hits), because `-license accept` can exit 0 having recorded nothing. It also runs +The license step matters as much as the download: a *copied* Xcode refuses build/first-launch commands +with *"You have not agreed to the Xcode license agreements"* until root records the agreement, and an +exec-only agent has no TTY to answer that prompt in. §7⅞ tries `sudo xcodebuild -license accept`, then — +if the license is still outstanding — drives the interactive `sudo xcodebuild -license` with its +confirmations pre-fed on stdin (`q`, `agree`), bounded by `run_timeout`. Acceptance is checked after +each attempt by matching this bundle's `Contents/Resources/LicenseInfo.plist` ID/type to the corresponding +system-wide `IDELastLicenseAgreedTo` preference. `xcodebuild -version` is deliberately not the +probe: Xcode 27 prints its version and exits successfully while its license is still outstanding, and +`-license accept` can exit 0 having recorded nothing. The provisioner also runs `DevToolsSecurity -enable` and adds the agent to the `_developer` group, so the first debug/test run in a clone doesn't pop the "Developer Tools Access needs to take control" authentication panel. Xcode is a base-publication invariant, not a best-effort extra. Host-side staging errors propagate; diff --git a/scripts/provision-macos-guest.sh b/scripts/provision-macos-guest.sh index 5df4df89..ebeaaf4e 100755 --- a/scripts/provision-macos-guest.sh +++ b/scripts/provision-macos-guest.sh @@ -1021,27 +1021,49 @@ else # ── License agreement ─────────────────────────────────────────────────────────────────────────── # A just-copied Xcode.app is NOT usable: until the agreement is recorded (by root, system-wide in - # /Library/Preferences/com.apple.dt.Xcode), EVERY xcodebuild/xcrun call — and the GUI — stops at + # /Library/Preferences/com.apple.dt.Xcode), build/first-launch commands — and the GUI — stop at # "You have not agreed to the Xcode license agreements", and an agent exec has no TTY to answer # that prompt in. So accept it HERE, once per base, and PROVE it took. # - # The proof is functional, not an exit code: `xcodebuild -version` run as the agent (NOT root) is - # exactly what a build hits, and it fails while the license is outstanding. Probe with it after - # every attempt — `-license accept` can exit 0 having recorded nothing. + # Do NOT use `xcodebuild -version` as the proof. Xcode 27 prints its version and exits 0 even when + # that Xcode's license is still outstanding; that false positive skips acceptance, then + # `-runFirstLaunch` stops at the license gate. Instead, read the license ID/type declared by THIS + # bundle and require the corresponding system-wide agreement value to match it exactly. (For + # example, Xcode 27 beta declares Beta/EA2002 and records IDELastBetaLicenseAgreedTo=EA2002.) Probe + # that exact state after every attempt — `-license accept` can exit 0 having recorded nothing. # - # But separate the TWO ways that probe can fail. An outstanding license is a nonzero EXIT; a bundle - # whose bytes didn't survive being copied here is a SIGNAL — the kernel's code-signing check kills - # xcodebuild on exec ("Killed: 9"), and no amount of `-license accept` will ever change that. Reading - # the second as the first is what made a damaged Xcode report itself as an unaccepted license, and - # sent the operator to run `xcodebuild -license accept` against a bundle that cannot run at all. - xcode_run_status() { + # Keep executable integrity as a separate probe. A bundle whose bytes didn't survive being copied + # here is killed by a SIGNAL when xcodebuild starts — the kernel's code-signing check prints + # "Killed: 9" — and no amount of `-license accept` will ever change that. Reading the signal as a + # license failure is what once sent operators to accept a license against a bundle that could not + # run at all. A zero/nonzero ordinary exit is irrelevant to the exact preference check below. + xcode_binary_status() { local rc=0 "$XCODE_DEVDIR/usr/bin/xcodebuild" -version >/dev/null 2>&1 || rc=$? printf '%s' "$rc" } - xcode_license_ok() { [ "$(xcode_run_status)" -eq 0 ]; } - XCODE_RUN_RC="$(xcode_run_status)" + XCODE_LICENSE_INFO="$XCODE_APP/Contents/Resources/LicenseInfo.plist" + XCODE_LICENSE_ID="$(/usr/libexec/PlistBuddy -c 'Print :licenseID' \ + "$XCODE_LICENSE_INFO" 2>/dev/null || true)" + XCODE_LICENSE_TYPE="$(/usr/libexec/PlistBuddy -c 'Print :licenseType' \ + "$XCODE_LICENSE_INFO" 2>/dev/null || true)" + XCODE_LICENSE_PREF="" + # Apple currently ships GM, Beta, and PTR licenses. Derive the preference name instead of fixing + # those three in a case statement, but accept only a plain alphabetic type before interpolating it. + case "$XCODE_LICENSE_TYPE" in + "" | *[!A-Za-z]*) ;; + *) XCODE_LICENSE_PREF="IDELast${XCODE_LICENSE_TYPE}LicenseAgreedTo" ;; + esac + xcode_license_ok() { + local agreed + [ -n "$XCODE_LICENSE_ID" ] && [ -n "$XCODE_LICENSE_PREF" ] || return 1 + agreed="$(/usr/bin/defaults read /Library/Preferences/com.apple.dt.Xcode \ + "$XCODE_LICENSE_PREF" 2>/dev/null || true)" + [ "$agreed" = "$XCODE_LICENSE_ID" ] + } + + XCODE_RUN_RC="$(xcode_binary_status)" if [ "$XCODE_RUN_RC" -ge 128 ]; then echo " ✗ $XCODE_APP is DAMAGED: xcodebuild is killed by signal $((XCODE_RUN_RC - 128)) on" >&2 echo " launch, so its code signature no longer validates — the bundle did not survive being" >&2 @@ -1053,6 +1075,11 @@ else if xcode_license_ok; then echo " ✓ Xcode license already accepted." else + if [ -z "$XCODE_LICENSE_ID" ] || [ -z "$XCODE_LICENSE_PREF" ]; then + echo " ✗ could not read a supported license ID/type from $XCODE_LICENSE_INFO." >&2 + echo " Refusing to guess whether this Xcode's license is accepted." >&2 + exit 1 + fi # 1. The documented non-interactive verb. run_timeout 300 sudo "$XCODE_DEVDIR/usr/bin/xcodebuild" -license accept >/dev/null 2>&1 || true if xcode_license_ok; then