Merge nucleic/ivory-slate-dingo-9tiv into dev

This commit is contained in:
2026-08-03 14:00:27 -07:00
parent 392e437d61
commit 1ab77632ab
6 changed files with 334 additions and 40 deletions
@@ -50,6 +50,67 @@ extension MacVMEngine {
return out
}
/// The file name a `.app` bundle is staged under in the provisioning share's `user-apps/`
/// `Xcode-beta.app` `Xcode-beta.app.tar`. The provisioner keys on the `.app.tar` suffix and
/// strips it back off to get the install name (`provision-macos-guest.sh` §7½).
static func stagedArchiveName(forBundleAt source: URL) -> String {
"\(source.lastPathComponent).tar"
}
/// **`tar` the `.app` bundle at `source` into `<directory>/<Name>.app.tar`** how every bundle is
/// handed to the base provisioner.
///
/// The share is virtiofs, and a bundle copied out of it **file by file** does not survive: a
/// symlink's own metadata can only be read by opening the *link* (`copyfile(3)` with
/// `COPYFILE_NOFOLLOW`), a virtiofs vnode can't be opened that way, and the kernel answers `ELOOP`
/// *"Too many levels of symbolic links"*. `ditto` fails outright; `cp -Rp` fails on every one of
/// Xcode's 11160 symlinks; `tar` reading the share warns once per symlink, exits 0 anyway, and
/// lands a **bit-damaged** Xcode whose `xcodebuild` is SIGKILLed by the kernel's code-signing check
/// (and which Finder calls *"damaged and can't be opened"*).
///
/// Archiving here inverts the problem. Every per-file read happens on the host's APFS, where
/// symlinks, xattrs and Xcode's ~32000 hardlinks are all faithful; the guest then makes ONE
/// sequential read of ONE file off the share and re-creates the tree locally on APFS. Same disk
/// cost as the tree copy this replaces, and the whole ELOOP class is gone.
///
/// Throws ``MacVMError/provisionFailed(_:)`` when `tar` can't be started or exits nonzero; a
/// partial archive is removed rather than left to be staged as if it were whole.
@discardableResult
static func stageAppArchive(at source: URL, into directory: URL) throws -> URL {
let destination = directory.appendingPathComponent(stagedArchiveName(forBundleAt: source))
let tar = Process()
tar.executableURL = URL(fileURLWithPath: "/usr/bin/tar")
// `-C <parent> <leaf>` roots the archive at the bundle itself, so the guest unpacks it straight
// into /Applications with no path surgery. Uncompressed: the share is local disk, and gzipping
// several GB of Xcode would cost minutes of CPU to save nothing that matters here.
tar.arguments = [
"-c", "-f", destination.path,
"-C", source.deletingLastPathComponent().path, source.lastPathComponent,
]
tar.standardOutput = FileHandle.nullDevice
let errors = Pipe()
tar.standardError = errors
do {
try tar.run()
} catch {
throw MacVMError.provisionFailed(
"could not archive \(source.lastPathComponent) for the provisioning share: \(error)")
}
// Read stderr to EOF BEFORE waiting: a bundle that warns thousands of times would otherwise
// fill the pipe and park `tar` forever.
let stderrData = errors.fileHandleForReading.readDataToEndOfFile()
tar.waitUntilExit()
guard tar.terminationStatus == 0 else {
try? FileManager.default.removeItem(at: destination)
let detail = String(decoding: stderrData.suffix(2000), as: UTF8.self)
.trimmingCharacters(in: .whitespacesAndNewlines)
throw MacVMError.provisionFailed(
"tar exited \(tar.terminationStatus) archiving \(source.lastPathComponent)"
+ (detail.isEmpty ? "" : ": \(detail)"))
}
return destination
}
/// Where a FULL Xcode can live on the host. Order is only a tiebreak which one gets baked in is
/// decided by VERSION (``preferredHostXcode(_:exists:version:)``), not by this list.
static let hostXcodeSearchPaths = ["/Applications/Xcode.app", "/Applications/Xcode-beta.app"]
@@ -770,8 +770,13 @@ extension MacVMEngine {
try fm.copyItem(at: nash, to: stageDir.appendingPathComponent("nash"))
// Stage any user-picked `.app` bundles (Settings → "Included apps") into a `user-apps/`
// subdir of the share; the provisioner copies each into the guest's `/Applications`. Best
// effort per bundle — a copy that fails (bad/huge bundle) just doesn't make it into the base.
// subdir of the share, each as a single `<Name>.app.tar`; the provisioner unpacks them into the
// guest's `/Applications`. Best effort per bundle — a bundle that can't be archived (bad/huge)
// just doesn't make it into the base.
//
// An ARCHIVE, not a copied tree, because the share is virtiofs: reading a bundle out of it file
// by file cannot preserve it (see ``stageAppArchive(at:into:)`` — `ELOOP` on every symlink, and
// a silently damaged Xcode at the end of it). Archiving keeps all the per-file reads on APFS.
//
// The set is the current Settings selection UNIONED with `carryAppPaths` — the apps recorded on
// the base being replaced/reprovisioned — so an app the user had installed here but has since
@@ -815,13 +820,28 @@ extension MacVMEngine {
if !stagedApps.isEmpty {
let userAppsDir = stageDir.appendingPathComponent("user-apps", isDirectory: true)
try fm.createDirectory(at: userAppsDir, withIntermediateDirectories: true)
// The required Xcode is checked at its SOURCE, before it is archived — the archive itself
// can't be inspected cheaply (finding one member in a multi-GB tar means reading the whole
// file), and a source that is already incomplete is the failure worth naming.
let xcodeSource = URL(fileURLWithPath: requiredXcode)
guard
fm.isExecutableFile(
atPath: xcodeSource.appendingPathComponent(
"Contents/Developer/usr/bin/xcodebuild"
).path)
else {
throw MacVMError.provisionFailed(
"the host's \(xcodeSource.lastPathComponent) is incomplete "
+ "(Contents/Developer/usr/bin/xcodebuild is missing); "
+ "the macOS base was not published.")
}
for path in stagedApps {
let src = URL(fileURLWithPath: path)
let destination = userAppsDir.appendingPathComponent(src.lastPathComponent)
do {
try fm.copyItem(at: src, to: destination)
try Self.stageAppArchive(at: src, into: userAppsDir)
} catch {
try? fm.removeItem(at: destination)
try? fm.removeItem(
at: userAppsDir.appendingPathComponent(Self.stagedArchiveName(forBundleAt: src)))
if path == requiredXcode {
throw MacVMError.provisionFailed(
"could not stage the required \(src.lastPathComponent) into the macOS "
@@ -832,13 +852,13 @@ extension MacVMEngine {
provisionLog.warning("\(message, privacy: .public)")
}
}
let stagedXcodebuild = userAppsDir
.appendingPathComponent(URL(fileURLWithPath: requiredXcode).lastPathComponent)
.appendingPathComponent("Contents/Developer/usr/bin/xcodebuild")
guard fm.isExecutableFile(atPath: stagedXcodebuild.path) else {
let stagedXcodeArchive = userAppsDir.appendingPathComponent(
Self.stagedArchiveName(forBundleAt: xcodeSource))
let stagedBytes =
(try? fm.attributesOfItem(atPath: stagedXcodeArchive.path))?[.size] as? UInt64
guard let stagedBytes, stagedBytes > 0 else {
throw MacVMError.provisionFailed(
"the staged \(URL(fileURLWithPath: requiredXcode).lastPathComponent) is "
+ "incomplete (Contents/Developer/usr/bin/xcodebuild is missing); "
"the staged \(stagedXcodeArchive.lastPathComponent) is missing or empty; "
+ "the macOS base was not published.")
}
}
@@ -88,7 +88,16 @@ extension MacVMEngine {
// Xcode that can't build against a beta macOS's SDK (the very reason the operator installed
// Xcode-beta) and the inverse, a stale leftover beta, was already possible whenever the
// stable Xcode was absent. Re-derive the choice on every base.
public static let macOSProvisioningRecipe = 14
// v15: `.app` bundles are staged into the provisioning share as `<Name>.app.tar` archives, tarred
// host-side off APFS, and unpacked in the guest instead of being copied out of the share
// file by file. The share is virtiofs, where a symlink's own metadata can't be read at all
// (`copyfile(3)` opens the LINK; the vnode answers ELOOP), and every copier failed there:
// `ditto` outright, `cp -Rp` on all 11160 of Xcode's symlinks, and `tar` SILENTLY one
// warning per symlink, exit 0, and a bit-damaged Xcode whose `xcodebuild` the kernel SIGKILLs
// 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
/// 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
+106 -1
View File
@@ -427,6 +427,58 @@ import UniformTypeIdentifiers
== ["/one/Foo.app", "/three/Bar.app"])
}
/// A bundle is handed to the base provisioner as ONE archive, because the provisioning share is
/// virtiofs and a per-file copy out of it cannot preserve a bundle: a symlink's own metadata is
/// only readable by opening the *link*, which a virtiofs vnode refuses with `ELOOP`. `ditto` fails
/// outright, `cp -Rp` fails on all 11160 of Xcode's symlinks, and `tar` reading the share warns
/// once per symlink, exits 0 anyway, and lands an Xcode whose `xcodebuild` is SIGKILLed by the
/// kernel's code-signing check. Archiving host-side keeps every per-file read on APFS.
@Test func stageAppArchiveWritesOneTarThatPreservesTheBundle() throws {
let fm = FileManager.default
let root = fm.temporaryDirectory.appendingPathComponent(
"nucleic-stage-archive-\(UUID().uuidString)", isDirectory: true)
let source = root.appendingPathComponent("Probe.app/Contents/MacOS", isDirectory: true)
let share = root.appendingPathComponent("user-apps", isDirectory: true)
try fm.createDirectory(at: source, withIntermediateDirectories: true)
try fm.createDirectory(at: share, withIntermediateDirectories: true)
defer { try? fm.removeItem(at: root) }
let binary = source.appendingPathComponent("Probe")
try "#!/bin/sh\nexit 0\n".write(to: binary, atomically: true, encoding: .utf8)
try fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: binary.path)
// The link that ELOOPs when it lives on the share the whole reason this path exists.
try fm.createSymbolicLink(
at: source.appendingPathComponent("alias"), withDestinationURL: binary)
let bundle = root.appendingPathComponent("Probe.app")
#expect(MacVMEngine.stagedArchiveName(forBundleAt: bundle) == "Probe.app.tar")
let archive = try MacVMEngine.stageAppArchive(at: bundle, into: share)
#expect(archive.lastPathComponent == "Probe.app.tar")
#expect(((try? fm.attributesOfItem(atPath: archive.path))?[.size] as? UInt64 ?? 0) > 0)
// Unpack it the way the provisioner does, and check the bundle came back whole: the archive is
// rooted at the bundle itself (so it lands straight in /Applications), the executable bit
// survives, and the symlink is still a symlink rather than a flattened copy.
let unpacked = root.appendingPathComponent("unpacked", isDirectory: true)
try fm.createDirectory(at: unpacked, withIntermediateDirectories: true)
let tar = Process()
tar.executableURL = URL(fileURLWithPath: "/usr/bin/tar")
tar.arguments = ["-xpf", archive.path, "-C", unpacked.path]
try tar.run()
tar.waitUntilExit()
#expect(tar.terminationStatus == 0)
let restored = unpacked.appendingPathComponent("Probe.app/Contents/MacOS")
#expect(fm.isExecutableFile(atPath: restored.appendingPathComponent("Probe").path))
let linkType = try fm.attributesOfItem(
atPath: restored.appendingPathComponent("alias").path)[.type] as? FileAttributeType
#expect(linkType == .typeSymbolicLink)
// A source that isn't there fails loudly instead of staging an empty archive.
#expect(throws: MacVMError.self) {
try MacVMEngine.stageAppArchive(at: root.appendingPathComponent("Absent.app"), into: share)
}
#expect(!fm.fileExists(atPath: share.appendingPathComponent("Absent.app.tar").path))
}
@Test func preferredHostXcodePicksTheNewestAndNeedsXcodebuild() {
// A full Xcode is identified by the `xcodebuild` the Command Line Tools don't ship that
// binary is the only supported Metal-toolchain installer, so a candidate without it is useless.
@@ -1617,6 +1669,59 @@ import UniformTypeIdentifiers
#expect(script.contains("could not install $AGENT_APP_DEST"))
}
/// Neither `cp -Rp` nor `tar` actually SOLVED the ELOOP above they only failed differently, and
/// tar failed silently: 11160 "Could not pack extended attributes" warnings, exit 0, and a
/// bit-damaged Xcode whose `xcodebuild` the kernel SIGKILLs on exec. So bundles now arrive as
/// host-made `<Name>.app.tar` archives that the guest unpacks locally, and the tree copiers are
/// only the fallback for a hand-staged bundle directory.
@Test func macOSGuestProvisionerUnpacksStagedAppArchives() throws {
let repoRoot = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent()
let script = try String(
contentsOf: repoRoot.appendingPathComponent("scripts/provision-macos-guest.sh"),
encoding: .utf8)
#expect(script.contains("install_app_archive()"))
#expect(script.contains(#"sudo /usr/bin/tar -xpf "$archive" -C "$(dirname "$dest")""#))
// Phase 7½ takes archives AND (for a hands-on run) bundle directories, archive first so a
// leftover staged tree can't shadow the archive of the same name.
#expect(script.contains(#"for staged in "$USER_APPS_DIR"/*.app.tar "$USER_APPS_DIR"/*.app"#))
#expect(script.contains(#"install_app_archive "$staged" "$dest""#))
#expect(script.contains(#"install_app_bundle "$app" "$dest""#))
// The surviving tree fallback stops reading symlink xattrs off the share, which is what
// produced one warning per symlink (and no failure) on the create side.
#expect(script.contains(#"sudo /usr/bin/tar $TAR_NO_METADATA -cf - "$leaf""#))
#expect(script.contains(#"/usr/bin/tar --no-mac-metadata --version"#))
}
/// "Structurally complete" is not "intact": a bundle damaged in transit keeps every path in place
/// but its binaries are SIGKILLed by the kernel's code-signing check the instant they exec (bash
/// 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.
@Test func macOSGuestProvisionerDiagnosesADamagedXcodeRatherThanBlamingTheLicense() throws {
let repoRoot = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent()
let script = try String(
contentsOf: repoRoot.appendingPathComponent("scripts/provision-macos-guest.sh"),
encoding: .utf8)
// The copy itself is gated on the installed Xcode actually running.
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()"))
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 ]; }"#))
}
/// 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
@@ -2857,7 +2962,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 == 14)
#expect(MacVMEngine.macOSProvisioningRecipe == 15)
}
/// Clone-time admission duplicates the current-recipe publication invariant: neither a stale
+25 -15
View File
@@ -486,8 +486,9 @@ On top of that, two ways to customize what every session clone ships with, both
Settings → "macOS virtual machines":
- **Included apps** (`bundledAppPaths`) — local `.app` bundles the *operator supplies*. The engine
stages each into the provisioning share's `user-apps/` and the provisioner copies them into the
guest's `/Applications` (`provision-macos-guest.sh` §7½).
`tar`s each into the provisioning share's `user-apps/` as `<Name>.app.tar` (`stageAppArchive`) and
the provisioner unpacks them into the guest's `/Applications` (`provision-macos-guest.sh` §7½). An
archive rather than a copied tree because the share is virtiofs — see the note below.
- **Common packages** (`selectedPackages`) — curated, well-known *optional* tools the guest fetches and
installs itself (`MacVMPackage.catalog`). Currently only **Xcode**, and it's grayed out because
Xcode is no longer optional: Apple gates its download behind an Apple ID, so the base build copies
@@ -554,25 +555,34 @@ hits), because `-license accept` can exit 0 having recorded nothing. It also run
`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;
the guest copies the bundle with `install_app_bundle` (preserving symlinks, modes, and signatures — see
the note on `ditto` and the share below), and §7⅞ aborts
the guest unpacks the staged `Xcode*.app.tar` with `install_app_archive` (see the virtiofs note
below), and §7⅞ aborts
the build if Xcode is missing/incomplete, license or first-launch setup fails, Metal cannot compile a
probe, or the selected macOS SDK cannot typecheck a minimal `import SwiftUI` view. The same pass
resolves the core Homebrew tools promised by the base. This keeps failures in the one-time base build
instead of cloning them into every agent session.
Budget roughly **6 GB** of base disk for this (Xcode ≈ 3.6 GB, the Metal toolchain asset ≈ 2.5 GB).
> **`ditto` cannot copy a symlink that lives on the virtiofs share.** `copyfile(3)` carries a link's own
> metadata by opening the *link* (`open(2)` with `O_SYMLINK`); a virtiofs vnode doesn't support being
> opened that way, so the kernel answers `ELOOP` — *"Too many levels of symbolic links"*. It is not a
> link cycle: a staged `Xcode-beta.app` failed on **all 11152** of its symlinks (one error each, every
> regular file copied fine), including same-directory links like `Contents/Developer/usr/bin/g++ →
> clang++` that cannot loop. `ditto` exits 1, and Phase 7½ aborted every base build at *"failed to
> install required Xcode-beta.app"*. `cp -R` is immune — it re-creates a link with `readlink(2)` +
> `symlink(2)` and never opens it. So `install_app_bundle` (scripts/provision-macos-guest.sh) probes the
> source filesystem with a throwaway symlink, keeps `ditto` wherever it still works (local disk), and
> copies with `cp -Rp` (then `tar`) off the share. A required bundle that lands with **no** symlinks is
> fatal, because it would look installed and fail much later.
> **A bundle cannot be copied file-by-file off the virtiofs share — it is staged as an archive.**
> `copyfile(3)` carries a link's own metadata by opening the *link* (`open(2)` with `O_SYMLINK`); a
> virtiofs vnode doesn't support being opened that way, so the kernel answers `ELOOP` — *"Too many
> levels of symbolic links"*. It is not a link cycle: a staged `Xcode-beta.app` failed on **all 11160**
> of its symlinks (one error each, every regular file copied fine), including same-directory links like
> `Contents/Developer/usr/bin/g++ → clang++` that cannot loop.
>
> Every copier hits it, and the dangerous one hits it *quietly*. `ditto` exits 1 outright. `cp -Rp`
> re-creates the link with `readlink(2)` + `symlink(2)` but still reads its xattrs, so it errors per
> symlink. `tar` reading the share warns per symlink (*"Could not pack extended attributes"*), **exits 0
> regardless**, and lands a bit-damaged Xcode: `xcodebuild` is then SIGKILLed by the kernel's
> code-signing check on exec (`Killed: 9`) and Finder calls the bundle *"damaged and can't be opened"*.
> Phase 7⅞ read that SIGKILL as an unaccepted Xcode **license** and failed every base build there.
>
> So the engine `tar`s each bundle **host-side, off APFS** (`stageAppArchive`), where symlinks, xattrs
> and Xcode's ~32000 hardlinks are all faithful, and the guest makes one sequential read of one file to
> unpack it locally (`install_app_archive`). `install_app_bundle` — the ditto/`cp -Rp`/`tar` cascade,
> now with `--no-mac-metadata` on the create side — survives only for a hand-staged bundle directory.
> A required bundle that lands with **no** symlinks, or whose `xcodebuild` dies on a *signal* rather
> than an exit code, is fatal at §7½: both look installed and fail much later.
---
+101 -12
View File
@@ -150,6 +150,25 @@ clt_present() { /usr/bin/xcode-select -p >/dev/null 2>&1 && /usr/bin/xcrun --fin
# call can't stall the whole build. `alarm` survives the exec, so the wrapped process is killed on time.
run_timeout() { perl -e 'alarm shift; exec @ARGV' "$@"; }
# ── Installing .app bundles staged as ARCHIVES (the normal path) ──────────────────────────────────
# Nucleic stages each bundle into the share as a single `<Name>.app.tar`, tarred HOST-side off APFS
# (MacVMEngine+InstallApps.swift → `stageAppArchive`). That is deliberate: a bundle copied out of the
# share file-by-file cannot be preserved (see the ELOOP note below), and the copy that "succeeds"
# anyway lands a bit-damaged Xcode. Unpacking an archive touches the share exactly once, sequentially,
# for one file — every symlink, hardlink and xattr is then re-created locally on APFS.
install_app_archive() { # $1 = staged <Name>.app.tar, $2 = destination bundle path
local archive="$1" dest="$2" leaf
leaf="$(basename "$dest")"
sudo /bin/rm -rf "$dest" || return 1
sudo /bin/mkdir -p "$(dirname "$dest")" || return 1
# `-p` restores modes/owners; extraction is into /Applications, so the archive's top-level name must
# match the destination leaf (it does — the host roots each archive at the bundle itself).
sudo /usr/bin/tar -xpf "$archive" -C "$(dirname "$dest")" || return 1
[ -d "$dest" ] || { echo "$leaf: archive did not contain $leaf" >&2; return 1; }
sudo /usr/bin/xattr -dr com.apple.quarantine "$dest" 2>/dev/null || true
return 0
}
# ── Copying .app bundles OFF the virtiofs provisioning share ──────────────────────────────────────
# `ditto` CANNOT copy a symlink that lives on the share. copyfile(3) carries a link's own metadata by
# opening the LINK (open(2) with O_SYMLINK), and a virtiofs vnode doesn't support being opened as a
@@ -159,6 +178,22 @@ run_timeout() { perl -e 'alarm shift; exec @ARGV' "$@"; }
# cannot loop. `ditto` then exits 1 and Phase 7½ aborts the whole base build on the required Xcode.
# `cp -R` is immune: it re-creates a link with readlink(2)+symlink(2) and never opens it.
#
# NEITHER `cp -Rp` NOR `tar` FIXES THIS — they only fail differently. `cp -Rp` re-creates the link but
# still reads its xattrs through copyfile(3), so it errors on all 11160 of Xcode's symlinks; bsdtar
# reading the share warns once per symlink ("Could not pack extended attributes"), EXITS 0 REGARDLESS,
# and produced an Xcode whose `xcodebuild` was SIGKILLed by the kernel's code-signing check the moment
# it ran — the "Xcode-beta is damaged and can't be opened" copy. That is why the engine now stages an
# ARCHIVE (`install_app_archive` above) and this helper is only the fallback for a HAND-STAGED bundle
# directory: nothing routinely reads a bundle tree off the share any more.
#
# bsdtar packs each file's xattrs through copyfile(3) — the very call that answers ELOOP on a share
# symlink — so the tar fallback below turns that off. Probed rather than assumed: an older bsdtar
# without the flag would abort on an unknown option, which is worse than the warnings it suppresses.
TAR_NO_METADATA=""
if /usr/bin/tar --no-mac-metadata --version >/dev/null 2>&1; then
TAR_NO_METADATA="--no-mac-metadata"
fi
#
# So probe the source filesystem — write a throwaway directory holding one symlink and try to `ditto`
# it — and pick the copier that actually works there. ditto keeps its bundle fidelity everywhere it
# still can (a local source, or a share whose symlinks it can read); the share falls back to cp -Rp,
@@ -195,7 +230,10 @@ install_app_bundle() { # $1 = source bundle, $2 = destination
# installer streams (MacVMEngine+InstallApps.swift), so it is a proven shape for a bundle.
echo " ⚠ cp -Rp failed for $leaf — retrying with tar …" >&2
sudo /bin/rm -rf "$dest"
( cd "$(dirname "$src")" && sudo /usr/bin/tar -cf - "$leaf" ) \
# `$TAR_NO_METADATA` on the CREATE side only: reading a share symlink's xattrs is what fails, and
# without it bsdtar emits one warning per symlink (11160 of them for Xcode) and still exits 0.
# Extraction keeps `-p`, since writing metadata onto local APFS works fine.
( cd "$(dirname "$src")" && sudo /usr/bin/tar $TAR_NO_METADATA -cf - "$leaf" ) \
| ( cd "$(dirname "$dest")" && sudo /usr/bin/tar -xpf - ) || return 1
[ "$leaf" = "$(basename "$dest")" ] \
|| sudo /bin/mv "$(dirname "$dest")/$leaf" "$dest" || return 1
@@ -728,23 +766,37 @@ echo " ────────────────────────
# ── Phase 7½: user-provided apps → /Applications ────────────────────────────────────────────────────
# ══════════════════════════════════════════════════════════════════════════════════════════════════
# Nucleic stages any .app bundles the operator picked in Settings ("Base image" → "Included apps")
# into a `user-apps/` subdir of the provisioning share; bake each into the base's /Applications so
# every session clone has them. Optional apps remain best effort, but Xcode is a required base
# invariant: a failed or incomplete Xcode copy aborts provisioning. `install_app_bundle` picks a
# copier that survives the share's virtiofs symlink limitation (see its definition — a plain `ditto`
# here failed on every one of Xcode's 11152 symlinks) while preserving symlinks, executable bits and
# signatures. Quarantine is stripped so copied apps launch without Gatekeeper prompting inside the
# (disposable, NAT-isolated) guest.
# into a `user-apps/` subdir of the provisioning share — each as a single `<Name>.app.tar` — and bakes
# them into the base's /Applications so every session clone has them. Optional apps remain best
# effort, but Xcode is a required base invariant: a failed or incomplete Xcode install aborts
# provisioning. Archives are unpacked with `install_app_archive`; a bundle DIRECTORY (a hands-on run
# that staged one by hand) still goes through `install_app_bundle`, which picks the least-bad copier
# for the share's virtiofs symlink limitation. Quarantine is stripped so copied apps launch without
# Gatekeeper prompting inside the (disposable, NAT-isolated) guest.
USER_APPS_DIR="$PROVISION_SHARE/user-apps"
if [ -d "$USER_APPS_DIR" ]; then
echo "▸ Installing user-provided apps into /Applications …"
shopt -s nullglob
for app in "$USER_APPS_DIR"/*.app; do
name="$(basename "$app")"
for staged in "$USER_APPS_DIR"/*.app.tar "$USER_APPS_DIR"/*.app; do
app=""
case "$staged" in
*.app.tar) name="$(basename "${staged%.tar}")" ;;
*) name="$(basename "$staged")"; app="$staged" ;;
esac
# An archive wins over a same-named directory, so a leftover hand-staged tree can't shadow it.
if [ -n "$app" ] && [ -f "$USER_APPS_DIR/$name.tar" ]; then
continue
fi
dest="/Applications/$name"
required=0
case "$name" in Xcode*.app) required=1 ;; esac
if install_app_bundle "$app" "$dest"; then
installed=0
if [ -n "$app" ]; then
if install_app_bundle "$app" "$dest"; then installed=1; fi
else
if install_app_archive "$staged" "$dest"; then installed=1; fi
fi
if [ "$installed" -eq 1 ]; then
if [ "$required" -eq 1 ] && [ ! -x "$dest/Contents/Developer/usr/bin/xcodebuild" ]; then
echo " ✗ installed $name is incomplete (xcodebuild is missing) — aborting." >&2
exit 1
@@ -756,6 +808,23 @@ if [ -d "$USER_APPS_DIR" ]; then
echo " ✗ installed $name has no symlinks — the copy did not preserve them; aborting." >&2
exit 1
fi
# STRUCTURALLY complete is not the same as INTACT. A bundle whose bytes were altered in transit
# still has every path in place, but the kernel's code-signing check SIGKILLs its binaries the
# instant they exec (bash reports "Killed: 9"), and Finder calls the app "damaged and can't be
# opened". So make the required Xcode actually RUN here, where the failure is attributable to the
# copy — Phase 7⅞ used to meet the same SIGKILL and blame the Xcode license for it. Only a
# signal death is fatal: a plain nonzero exit is the licence still being outstanding, which is
# exactly what 7⅞ goes on to accept.
if [ "$required" -eq 1 ]; then
xcodebuild_rc=0
"$dest/Contents/Developer/usr/bin/xcodebuild" -version >/dev/null 2>&1 || xcodebuild_rc=$?
if [ "$xcodebuild_rc" -ge 128 ]; then
echo " ✗ installed $name is DAMAGED — xcodebuild was killed by signal $((xcodebuild_rc - 128))" >&2
echo " on launch, i.e. its code signature no longer validates, so the bundle did not" >&2
echo " survive the copy out of the provisioning share. Aborting." >&2
exit 1
fi
fi
echo " ✓ installed $dest"
else
if [ "$required" -eq 1 ]; then
@@ -959,7 +1028,27 @@ else
# 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.
xcode_license_ok() { "$XCODE_DEVDIR/usr/bin/xcodebuild" -version >/dev/null 2>&1; }
#
# 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() {
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)"
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
echo " copied into this guest. (Finder reports the same bundle as \"damaged and can't be" >&2
echo " opened\".) This is NOT a license problem; re-stage Xcode and rebuild the base." >&2
exit 1
fi
if xcode_license_ok; then
echo " ✓ Xcode license already accepted."