From 6b1896660f75b31cf34c9f684610e8e572300435 Mon Sep 17 00:00:00 2001 From: dh Date: Sat, 3 Oct 2026 15:01:00 +0200 Subject: [PATCH 1/6] ci: derive the macOS probe from the pinned QEMU filesystem image --- docs/macos-native-diagnostic.md | 2 ++ tools/ci/MacOsNativeDiagnostic.cs | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/macos-native-diagnostic.md b/docs/macos-native-diagnostic.md index 9eaac92..63416b0 100644 --- a/docs/macos-native-diagnostic.md +++ b/docs/macos-native-diagnostic.md @@ -29,3 +29,5 @@ Evidence is written under the requested output directory: run identity and candi Every container/image has a random run token in its ownership label. `finally` cleanup and the workflow's `always()` step inspect that exact label before removing the matching container and its anonymous storage volume, then the matching image. They never remove an unrelated name or volume, prune Docker, modify host settings or restart Meeting Assistant. Temporary source files are deleted only when their local marker matches the same token. Evidence remains available after cleanup. The earlier background-only local bootstrap never obtained DiskManagement readiness. This separate LaunchDaemon probe is still an experiment until the actual remote run produces the required native evidence. Full macOS CI support remains unverified until an installed guest subsequently compiles/signs the native helpers and passes all application tests, including all five native tests without skips. + +Remote run 4152 passed Docker access and resource checks but failed before VM startup: the runner's BuildKit could not checksum a dangling `/etc/alternatives/awk.1.gz` link while copying the entire QEMU filesystem. The candidate now derives directly from the same pinned QEMU filesystem image and overwrites its QEMU executable as before. Inspection of that exact digest reports an empty image `Config`, so it adds no inherited environment, user, command or healthcheck. The next actual remote build must verify this compatibility change; it does not claim native readiness. diff --git a/tools/ci/MacOsNativeDiagnostic.cs b/tools/ci/MacOsNativeDiagnostic.cs index 4ed5dc9..75e064f 100644 --- a/tools/ci/MacOsNativeDiagnostic.cs +++ b/tools/ci/MacOsNativeDiagnostic.cs @@ -184,7 +184,9 @@ static class NativeDiagnostic var constants = "RECOVERY_ORIGINAL = b'''" + daemon + "'''\nRECOVERY_REPLACEMENT = b'''" + DiagnosticDaemon + "'''.ljust(len(RECOVERY_ORIGINAL), b\" \")"; patch = ReplaceOnce(patch, oldConstants, constants); var dockerPath = Path.Combine(source, "Dockerfile"); - var dockerfile = ReplaceOnce(File.ReadAllText(dockerPath), "--from=qemux/qemu:7.50 ", "--from=qemux/qemu:7.50@sha256:e7f6fda52503a546fd649670ba46e4bc23dc6dcef275bc3fac48877fbbc430df "); + // The existing runner's BuildKit cannot checksum dangling manpage links during COPY /. + // This pinned filesystem image has an empty Config; FROM preserves the same runtime defaults. + var dockerfile = ReplaceOnce(File.ReadAllText(dockerPath), "FROM scratch AS base\nCOPY --from=qemux/qemu:7.50 --exclude=usr/bin/qemu-system-x86_64 / /\n", "FROM qemux/qemu:7.50@sha256:e7f6fda52503a546fd649670ba46e4bc23dc6dcef275bc3fac48877fbbc430df AS base\n"); dockerfile = ReplaceAllExact(dockerfile, "--from=qemux/qemu-macos:latest ", "--from=qemux/qemu-macos:latest@sha256:af64297171228f27d5f616249e18f6ad5e2fbc79c1cc517252521e8bcd8eadaa ", 2); var entryPath = Path.Combine(source, "src/entry.sh"); var entry = ReplaceOnce(File.ReadAllText(entryPath), "set -Eeuo pipefail\n", "set -Eeuo pipefail\n\n# Diagnostic budget: inspect existing Docker storage before Recovery download/boot.\ndf -Pk /storage\nfree_kib=$(df -Pk /storage | awk 'NR==2 {print $4}')\n[[ \"$free_kib\" =~ ^[0-9]+$ ]] && (( free_kib >= 8 * 1024 * 1024 )) || { echo 'Existing Docker storage has less than the 8-GiB diagnostic budget.' >&2; exit 1; }\n"); From b40234d3b5f6b11438718c46fb8e4e570fb24eed Mon Sep 17 00:00:00 2001 From: dh Date: Sat, 3 Oct 2026 16:38:34 +0200 Subject: [PATCH 2/6] ci: capture native recovery startup context before platform probe --- docs/macos-native-diagnostic.md | 8 +++-- tools/ci/MacOsNativeDiagnostic.cs | 47 ++++++++++++++++++++++-- tools/ci/macos-native-readiness.sh | 57 +++++++++++++++++++++++++----- 3 files changed, 99 insertions(+), 13 deletions(-) diff --git a/docs/macos-native-diagnostic.md b/docs/macos-native-diagnostic.md index 63416b0..621ae7e 100644 --- a/docs/macos-native-diagnostic.md +++ b/docs/macos-native-diagnostic.md @@ -20,14 +20,18 @@ Dependencies are the existing Linux/x64 runner, .NET 10 SDK, Git, Bash and Docke The helper clones Dockur commit `16a5b470cdd601bae8b05b02d748d7edfb36c12e`, verifies its exact Recovery patcher hash, and makes three narrowly verified source edits. The early `rc.cdrom.sh` hook only mounts the existing state share and returns. A same-length XML replacement makes the existing `com.apple.recoveryosd` LaunchDaemon execute `/bin/bash /Volumes/installstate/launch.sh` after boot tasks. The staged `launch.sh` is replaced entirely by the checked-in read-only readiness probe. All replacement counts are exact; an upstream mismatch fails. The two imported QEMU image digests are pinned and the final image/source/Recovery hashes are retained. Other upstream Dockerfile downloads are observed through the resulting image identity rather than asserted to be immutable. -The VM uses TCG (`KVM=N`), slirp networking, a 4-GiB guest, two virtual CPUs and a sparse 64-GiB data disk. Its container has a 6-GiB memory/swap ceiling and a two-CPU limit. The existing Docker daemon must report at least two CPUs and 6 GiB total memory, the runner must have at least 5 GiB available memory, and the Docker filesystem must have at least 8 GiB free before Recovery downloads or boot. Its own native commands have per-command watchdogs and a ten-minute readiness phase; the host orchestrator has a 40-minute deadline and the workflow a 45-minute limit. +The VM uses TCG (`KVM=N`), slirp networking, a 4-GiB guest, two virtual CPUs and a sparse 64-GiB data disk. Its container has a 6-GiB memory/swap ceiling and a two-CPU limit. The existing Docker daemon must report at least two CPUs and 6 GiB total memory, the runner must have at least 5 GiB available memory, and the Docker filesystem must have at least 8 GiB free before Recovery downloads or boot. Its own native commands retain 45-second watchdogs and a ten-minute readiness phase; the host orchestrator has a 40-minute deadline and the workflow a 45-minute limit. + +Actual remote run 4155 stopped at the first `sw_vers` with exit 143 before kernel, process or service probes ran. The updated hook collects native `uname`, root identity, bootargs, guest CPU features and process context first. It logs each child PID and builtin elapsed time, explicitly tags watchdog TERM, and takes two independently five-second-bounded CPU/state/command snapshots during each `sw_vers` attempt. After an initial platform failure it still collects native launchd context and repeats the identical `sw_vers` command once, with the same 45-second limit. A successful native `sw_vers`, native product version and all original identity/service/disk gates remain required. Process state or a retry alone does not establish whether initialization was slow or a service blocked. The upstream AVX2 warning reads host flags; the pinned TCG CPU path configures an Intel guest with AVX/AVX2, so the hook observes actual guest CPU flags without changing host or guest CPU settings. ## Evidence and cleanup Evidence is written under the requested output directory: run identity and candidate commit, Docker/runner resources, exact source patch artifacts and hashes, image/container inspection, Recovery hash, native platform/process/launchctl/diskutil logs, machine-readable guest result, outcome and cleanup receipt. The workflow retains these as a seven-day artifact. Phase names and up to 512 KiB of the final native proof also appear in CI stdout, on success or failure, with the run token replaced; no environment or credential dump is printed. A Docker start/build exit zero is not a successful native result. A missing, stale, unsupported-platform, read-only or wrong-size guest receipt fails. +While Recovery readiness is pending, a minute heartbeat reports elapsed guest time and the container's running state. Before final cleanup, an optional ten-second capture rechecks the saved container ID/ownership label and uses the pinned image's existing Unix HMP socket, `nc.openbsd` and a five-second `timeout` to collect only [`info status` and `screendump`](https://www.qemu.org/docs/master/system/monitor.html), retaining the command transcript, exit codes and fresh bounded PPM screenshot. Capture failure is visible and never changes native readiness success. + Every container/image has a random run token in its ownership label. `finally` cleanup and the workflow's `always()` step inspect that exact label before removing the matching container and its anonymous storage volume, then the matching image. They never remove an unrelated name or volume, prune Docker, modify host settings or restart Meeting Assistant. Temporary source files are deleted only when their local marker matches the same token. Evidence remains available after cleanup. The earlier background-only local bootstrap never obtained DiskManagement readiness. This separate LaunchDaemon probe is still an experiment until the actual remote run produces the required native evidence. Full macOS CI support remains unverified until an installed guest subsequently compiles/signs the native helpers and passes all application tests, including all five native tests without skips. -Remote run 4152 passed Docker access and resource checks but failed before VM startup: the runner's BuildKit could not checksum a dangling `/etc/alternatives/awk.1.gz` link while copying the entire QEMU filesystem. The candidate now derives directly from the same pinned QEMU filesystem image and overwrites its QEMU executable as before. Inspection of that exact digest reports an empty image `Config`, so it adds no inherited environment, user, command or healthcheck. The next actual remote build must verify this compatibility change; it does not claim native readiness. +Remote run 4152 passed Docker access and resource checks but failed before VM startup: the runner's BuildKit could not checksum a dangling `/etc/alternatives/awk.1.gz` link while copying the entire QEMU filesystem. The candidate now derives directly from the same pinned QEMU filesystem image and overwrites its QEMU executable as before. Inspection of that exact digest reports an empty image `Config`, so it adds no inherited environment, user, command or healthcheck. Actual run 4155 built that image and started QEMU/XNU successfully, then failed the first native `sw_vers` after its 45-second watchdog. It did not prove native readiness. diff --git a/tools/ci/MacOsNativeDiagnostic.cs b/tools/ci/MacOsNativeDiagnostic.cs index 75e064f..6987335 100644 --- a/tools/ci/MacOsNativeDiagnostic.cs +++ b/tools/ci/MacOsNativeDiagnostic.cs @@ -117,6 +117,8 @@ static class NativeDiagnostic AssertContainer(File.ReadAllText(Path.Combine(output, "container-created.stdout.log")), token); await Command("docker", ["start", id], output, "docker-start", deadline.Token); Console.WriteLine("The owned unprivileged TCG guest is starting. Success requires native macOS 14+/x86_64 and a writable 64-GiB disk; no installer will run."); + var recoveryStarted = Stopwatch.StartNew(); + var heartbeat = Stopwatch.StartNew(); while (true) { deadline.Token.ThrowIfCancellationRequested(); @@ -132,6 +134,11 @@ static class NativeDiagnostic } var running = await Command("docker", ["inspect", "--format", "{{.State.Running}}", id], output, "container-running", deadline.Token); if (running.Output.Trim() != "true") throw new InvalidOperationException("Guest container exited before a native readiness result."); + if (heartbeat.Elapsed >= TimeSpan.FromSeconds(60)) + { + Console.WriteLine($"[native-diagnostic] phase=recovery; elapsed={recoveryStarted.Elapsed.TotalMinutes:F1} minutes; container=running; readiness=pending"); + heartbeat.Restart(); + } await Task.Delay(TimeSpan.FromSeconds(20), deadline.Token); } } @@ -144,7 +151,7 @@ static class NativeDiagnostic { Console.CancelKeyPress -= cancelHandler; using var captureDeadline = new CancellationTokenSource(TimeSpan.FromSeconds(45)); - try { await CaptureGuest(state.ContainerName, output, captureDeadline.Token); } catch (Exception exception) { Console.Error.WriteLine("Final evidence capture: " + exception.Message); } + try { await CaptureGuest(state.ContainerId ?? state.ContainerName, output, captureDeadline.Token, true, state.Token); } catch (Exception exception) { Console.Error.WriteLine("Final evidence capture: " + exception.Message); } try { PrintGuestProof(output, state.Token); } catch (Exception exception) { Console.Error.WriteLine("Native proof output: " + exception.Message); } var clean = await Cleanup(output); if (!clean) { outcome = "failed"; error = (error ?? "") + " Owned-resource cleanup failed; inspect cleanup evidence."; } @@ -229,8 +236,9 @@ static class NativeDiagnostic throw new InvalidOperationException("Created container exceeds the owned/unprivileged diagnostic boundary."); } - static async Task CaptureGuest(string id, string output, CancellationToken cancellation) + static async Task CaptureGuest(string id, string output, CancellationToken cancellation, bool final = false, string? token = null) { + if (final && token is not null) await CaptureMonitor(id, output, token, cancellation); var logs = await Command("docker", ["logs", "--tail", "3000", id], output, "container", cancellation, requireSuccess: false); foreach (var file in new[] { ("proof.log", "guest-proof.log"), ("result.json", "guest-result.json") }) { @@ -240,6 +248,41 @@ static class NativeDiagnostic await Command("docker", ["exec", id, "sh", "-c", "printf '[qemu]\n'; qemu-system-x86_64 --version | head -n 1; printf '[Recovery hash]\n'; test ! -f /storage/14/setup.dmg || sha256sum /storage/14/setup.dmg; printf '[resources]\n'; df -Pk /storage; cat /sys/fs/cgroup/memory.max /sys/fs/cgroup/cpu.max 2>/dev/null || true"], output, "guest-container-resources", cancellation, requireSuccess: false); } + static async Task CaptureMonitor(string id, string output, string token, CancellationToken cancellation) + { + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellation); + deadline.CancelAfter(TimeSpan.FromSeconds(10)); + int? monitorExit = null, copyExit = null; + string? error = null; + try + { + if (!System.Text.RegularExpressions.Regex.IsMatch(id, "^[0-9a-f]{64}$") || !System.Text.RegularExpressions.Regex.IsMatch(token, "^[0-9a-f]{32}$")) throw new InvalidOperationException("No saved owned container identity for the optional monitor capture."); + var inspection = await Command("docker", ["inspect", id], output, "capture-monitor-container", deadline.Token); + AssertContainer(inspection.Output, token); + using (var document = JsonDocument.Parse(inspection.Output)) + if (document.RootElement[0].GetProperty("Id").GetString() != id || !document.RootElement[0].GetProperty("State").GetProperty("Running").GetBoolean()) throw new InvalidOperationException("Owned guest container is no longer running for the optional monitor capture."); + var screen = "/dev/shm/native-diagnostic-screen-" + token + ".ppm"; + var monitor = await Command("docker", ["exec", id, "sh", "-c", """ + test -S /run/shm/monitor.sock || exit 1 + rm -f -- "$1" || exit 1 + printf 'info status\nscreendump %s\n' "$1" | /usr/bin/timeout -s KILL 5 /usr/bin/nc.openbsd -q 1 -w 2 -U /run/shm/monitor.sock + monitor_exit=$? + printf '\n[monitor-exit] %s\n' "$monitor_exit" + [ "$monitor_exit" -eq 0 ] || exit "$monitor_exit" + bytes=$(stat -c%s "$1") || exit 1 + [ "$bytes" -gt 0 ] && [ "$bytes" -le 8388608 ] || exit 1 + printf '[screen-bytes] %s\n' "$bytes" + """, "native-monitor", screen], output, "capture-monitor", deadline.Token, requireSuccess: false); + monitorExit = monitor.ExitCode; + if (monitorExit != 0) throw new InvalidOperationException("Optional monitor status/screenshot command exited " + monitorExit + "."); + var copy = await Command("docker", ["cp", id + ":" + screen, Path.Combine(output, "guest-screen-" + token + ".ppm")], output, "capture-monitor-screen", deadline.Token, requireSuccess: false); + copyExit = copy.ExitCode; + if (copyExit != 0) throw new InvalidOperationException("Optional monitor screenshot copy exited " + copyExit + "."); + } + catch (Exception exception) { error = exception.Message; Console.Error.WriteLine("Optional final monitor capture: " + error); } + finally { Save(Path.Combine(output, "monitor-capture.json"), new { token, monitorExit, copyExit, success = error is null, error, capturedUtc = DateTimeOffset.UtcNow }); } + } + static async Task Cleanup(string output) { var path = Path.Combine(output, "owned-resources.json"); diff --git a/tools/ci/macos-native-readiness.sh b/tools/ci/macos-native-readiness.sh index d451f88..8382e7c 100644 --- a/tools/ci/macos-native-readiness.sh +++ b/tools/ci/macos-native-readiness.sh @@ -45,28 +45,59 @@ finish() { run_command() { local name="$1" shift - local process timer sleeper exit_code + local process timer sleeper exit_code started observer="" LAST_OUTPUT="/tmp/native-diagnostic-$name.out" printf '\n[proof-command] %s:' "$name" >> "$PROOF_LOG" printf ' %s' "$@" >> "$PROOF_LOG" printf '\n' >> "$PROOF_LOG" + started=$SECONDS "$@" > "$LAST_OUTPUT" 2>&1 & process=$! + printf '[proof-start] %s child=%s shell=%s parent=%s seconds=%s\n' "$name" "$process" "$$" "$PPID" "$started" >> "$PROOF_LOG" ( trap 'kill "$sleeper" 2>/dev/null || :; exit 0' TERM INT sleep 45 & sleeper=$! wait "$sleeper" + printf '[proof-timeout] %s child=%s elapsed=%ss signal=TERM\n' "$name" "$process" "$((SECONDS - started))" >> "$PROOF_LOG" kill -TERM "$process" 2>/dev/null || : - sleep 2 + sleep 2 & sleeper=$!; wait "$sleeper" kill -KILL "$process" 2>/dev/null || : ) & timer=$! + if [[ "$name" = platform || "$name" = platform-warm ]]; then + # Observers never extend the independent 45-second command deadline. + ( + local sample_pid="" sample_timer="" pause_pid="" pause sample_exit + trap 'kill -KILL "$sample_pid" 2>/dev/null || :; kill -TERM "$sample_timer" "$pause_pid" 2>/dev/null || :; exit 0' TERM INT + for pause in 10 15; do + sleep "$pause" & pause_pid=$!; wait "$pause_pid" + printf '[proof-process] %s child=%s elapsed=%ss fields=pid,ppid,stat,cpu-time,elapsed,cpu-percent,wchan,comm\n' "$name" "$process" "$((SECONDS - started))" >> "$PROOF_LOG" + /bin/ps -p "$process" -o pid=,ppid=,stat=,time=,etime=,pcpu=,wchan=,comm= >> "$PROOF_LOG" 2>&1 & + sample_pid=$! + ( + local sample_sleeper="" + trap 'kill "$sample_sleeper" 2>/dev/null || :; exit 0' TERM INT + sleep 5 & sample_sleeper=$!; wait "$sample_sleeper" + kill -KILL "$sample_pid" 2>/dev/null || : + ) & sample_timer=$! + wait "$sample_pid"; sample_exit=$? + kill -TERM "$sample_timer" 2>/dev/null || :; wait "$sample_timer" 2>/dev/null || : + printf '[proof-process-exit] %s %s\n' "$name" "$sample_exit" >> "$PROOF_LOG" + sample_pid=""; sample_timer=""; pause_pid="" + done + ) & observer=$! + fi wait "$process" exit_code=$? kill -TERM "$timer" 2>/dev/null || : wait "$timer" 2>/dev/null || : + if [ -n "$observer" ]; then + kill -TERM "$observer" 2>/dev/null || : + wait "$observer" 2>/dev/null || : + fi /usr/bin/tail -c 524288 "$LAST_OUTPUT" >> "$PROOF_LOG" + printf '\n[proof-duration] %s child=%s elapsed=%ss\n' "$name" "$process" "$((SECONDS - started))" >> "$PROOF_LOG" printf '\n[proof-exit] %s\n' "$exit_code" >> "$PROOF_LOG" LAST_EXIT="$exit_code" local size @@ -75,13 +106,7 @@ run_command() { return 0 } -run_command platform /usr/bin/sw_vers -(( LAST_EXIT == 0 )) || finish false sw_vers_failed -run_command version /usr/bin/sw_vers -productVersion -(( LAST_EXIT == 0 )) || finish false product_version_failed -os_version=$(cat "$LAST_OUTPUT") -[[ "$os_version" =~ ^[0-9]+\.[0-9]+(\.[0-9]+)?$ ]] || finish false product_version_invalid -(( ${os_version%%.*} >= 14 )) || finish false unsupported_macos_version +# Collect cheap native identity/context before the first framework-dependent probe. run_command kernel /usr/bin/uname -a (( LAST_EXIT == 0 )) || finish false uname_failed run_command architecture /usr/bin/uname -m @@ -94,14 +119,28 @@ run_command uid /usr/bin/id -u uid=$(cat "$LAST_OUTPUT") [ "$uid" = 0 ] || finish false recovery_account_not_root run_command bootargs /usr/sbin/sysctl kern.bootargs +run_command cpu /usr/sbin/sysctl machdep.cpu.brand_string machdep.cpu.features machdep.cpu.leaf7_features run_command parent /bin/ps -p "$$" -p "$PPID" -o pid=,ppid=,comm= run_command processes /bin/ps -axo pid,ppid,comm +run_command platform /usr/bin/sw_vers +platform_exit="$LAST_EXIT" run_command system /bin/launchctl print system system_exit="$LAST_EXIT" run_command arbitration /bin/launchctl print system/com.apple.diskarbitrationd arbitration_exit="$LAST_EXIT" run_command recovery /bin/launchctl print system/com.apple.recoveryosd recovery_exit="$LAST_EXIT" +if (( platform_exit != 0 )); then + printf '[proof-retry] sw_vers once after native service context; same 45-second deadline\n' >> "$PROOF_LOG" + run_command platform-warm /usr/bin/sw_vers + platform_exit="$LAST_EXIT" +fi +(( platform_exit == 0 )) || finish false sw_vers_failed +run_command version /usr/bin/sw_vers -productVersion +(( LAST_EXIT == 0 )) || finish false product_version_failed +os_version=$(cat "$LAST_OUTPUT") +[[ "$os_version" =~ ^[0-9]+\.[0-9]+(\.[0-9]+)?$ ]] || finish false product_version_invalid +(( ${os_version%%.*} >= 14 )) || finish false unsupported_macos_version # Bound readiness independently of the host's 40-minute overall deadline. readiness_start=$SECONDS From c92e62bdf5cf40cec0c449d9573faecc8b8b446d Mon Sep 17 00:00:00 2001 From: dh Date: Sat, 3 Oct 2026 18:05:15 +0200 Subject: [PATCH 3/6] Reduce native readiness probe overhead and preserve screenshot evidence --- docs/macos-native-diagnostic.md | 8 +- tools/ci/MacOsNativeDiagnostic.cs | 2 +- tools/ci/macos-native-readiness.sh | 214 ++++++++++++++++++----------- 3 files changed, 144 insertions(+), 80 deletions(-) diff --git a/docs/macos-native-diagnostic.md b/docs/macos-native-diagnostic.md index 621ae7e..88c72eb 100644 --- a/docs/macos-native-diagnostic.md +++ b/docs/macos-native-diagnostic.md @@ -22,7 +22,11 @@ The helper clones Dockur commit `16a5b470cdd601bae8b05b02d748d7edfb36c12e`, veri The VM uses TCG (`KVM=N`), slirp networking, a 4-GiB guest, two virtual CPUs and a sparse 64-GiB data disk. Its container has a 6-GiB memory/swap ceiling and a two-CPU limit. The existing Docker daemon must report at least two CPUs and 6 GiB total memory, the runner must have at least 5 GiB available memory, and the Docker filesystem must have at least 8 GiB free before Recovery downloads or boot. Its own native commands retain 45-second watchdogs and a ten-minute readiness phase; the host orchestrator has a 40-minute deadline and the workflow a 45-minute limit. -Actual remote run 4155 stopped at the first `sw_vers` with exit 143 before kernel, process or service probes ran. The updated hook collects native `uname`, root identity, bootargs, guest CPU features and process context first. It logs each child PID and builtin elapsed time, explicitly tags watchdog TERM, and takes two independently five-second-bounded CPU/state/command snapshots during each `sw_vers` attempt. After an initial platform failure it still collects native launchd context and repeats the identical `sw_vers` command once, with the same 45-second limit. A successful native `sw_vers`, native product version and all original identity/service/disk gates remain required. Process state or a retry alone does not establish whether initialization was slow or a service blocked. The upstream AVX2 warning reads host flags; the pinned TCG CPU path configures an Intel guest with AVX/AVX2, so the hook observes actual guest CPU flags without changing host or guest CPU settings. +Actual remote run 4155 stopped at the first `sw_vers` with exit 143. Run 4159 then proved native Darwin/x86_64, root identity and guest AVX2, but reached the host deadline before `sw_vers` or the service/disk gates. Its logged command durations included timer cleanup and output copying, so they did not isolate native execution time. + +The next probe runs mandatory architecture, root identity and platform gates before optional process/CPU diagnostics. It keeps the proof log open, uses Bash 3.2's timed FIFO reads instead of starting a separate sleep process for every watchdog, and groups output copying and byte-limit checks. Separate markers record fork/exec/wait, timer cleanup and output flush durations. Raw output still fails above 512 KiB per command, proof above 4 MiB fails, and scalar gates reject hidden suffixes or multiline values. A local harmless-command harness verifies all 16 timeout, cancellation, output and scalar cases; this does not qualify macOS Recovery. + +After an initial platform failure the hook collects native launchd context and repeats the identical `sw_vers` command once, with the same 45-second limit. Native product version and all original identity/service/disk gates remain required. Optional process and CPU diagnostics run only after a gate fails. The upstream AVX2 warning reads host flags; run 4159 observed AVX2 in the actual guest. No host or guest CPU settings change. ## Evidence and cleanup @@ -30,6 +34,8 @@ Evidence is written under the requested output directory: run identity and candi While Recovery readiness is pending, a minute heartbeat reports elapsed guest time and the container's running state. Before final cleanup, an optional ten-second capture rechecks the saved container ID/ownership label and uses the pinned image's existing Unix HMP socket, `nc.openbsd` and a five-second `timeout` to collect only [`info status` and `screendump`](https://www.qemu.org/docs/master/system/monitor.html), retaining the command transcript, exit codes and fresh bounded PPM screenshot. Capture failure is visible and never changes native readiness success. +Run 4159 generated a 6,220,817-byte screenshot file under `/dev/shm`, but `docker cp` could not retrieve it. Screenshots now use the regular container path `/tmp/native-diagnostic-screen-.ppm`, avoiding Docker's documented [`/dev`/tmpfs copy limitation](https://docs.docker.com/reference/cli/docker/container/cp/#corner-cases). + Every container/image has a random run token in its ownership label. `finally` cleanup and the workflow's `always()` step inspect that exact label before removing the matching container and its anonymous storage volume, then the matching image. They never remove an unrelated name or volume, prune Docker, modify host settings or restart Meeting Assistant. Temporary source files are deleted only when their local marker matches the same token. Evidence remains available after cleanup. The earlier background-only local bootstrap never obtained DiskManagement readiness. This separate LaunchDaemon probe is still an experiment until the actual remote run produces the required native evidence. Full macOS CI support remains unverified until an installed guest subsequently compiles/signs the native helpers and passes all application tests, including all five native tests without skips. diff --git a/tools/ci/MacOsNativeDiagnostic.cs b/tools/ci/MacOsNativeDiagnostic.cs index 6987335..bb899d8 100644 --- a/tools/ci/MacOsNativeDiagnostic.cs +++ b/tools/ci/MacOsNativeDiagnostic.cs @@ -261,7 +261,7 @@ static class NativeDiagnostic AssertContainer(inspection.Output, token); using (var document = JsonDocument.Parse(inspection.Output)) if (document.RootElement[0].GetProperty("Id").GetString() != id || !document.RootElement[0].GetProperty("State").GetProperty("Running").GetBoolean()) throw new InvalidOperationException("Owned guest container is no longer running for the optional monitor capture."); - var screen = "/dev/shm/native-diagnostic-screen-" + token + ".ppm"; + var screen = "/tmp/native-diagnostic-screen-" + token + ".ppm"; var monitor = await Command("docker", ["exec", id, "sh", "-c", """ test -S /run/shm/monitor.sock || exit 1 rm -f -- "$1" || exit 1 diff --git a/tools/ci/macos-native-readiness.sh b/tools/ci/macos-native-readiness.sh index 8382e7c..91bf3bb 100644 --- a/tools/ci/macos-native-readiness.sh +++ b/tools/ci/macos-native-readiness.sh @@ -9,6 +9,11 @@ PROOF_LOG="$STATE_DIR/proof.log" RESULT="$STATE_DIR/result.json" EXPECTED_BYTES=68719476736 MAX_LOG_BYTES=4194304 +MAX_OUTPUT_BYTES=524288 +TIMER_FIFO="/tmp/native-diagnostic-$PROOF_TOKEN-$$.fifo" +PENDING_OUTPUTS=() +ACTIVE_COMMAND="" +ACTIVE_TIMER="" os_version="" architecture="" uid=-1 @@ -27,127 +32,179 @@ while [ ! -d "$STATE_DIR" ] && (( count < 120 )); do done [ -d "$STATE_DIR" ] || exit 1 : > "$PROOF_LOG" || exit 1 +exec 3>> "$PROOF_LOG" || exit 1 rm -f "$RESULT" "$RESULT.tmp" -printf '[proof-token] %s\n' "$PROOF_TOKEN" >> "$PROOF_LOG" +printf '[proof-token] %s\n' "$PROOF_TOKEN" >&3 finish() { local success="$1" reason="$2" - printf '[proof-result] %s: %s\n' "$success" "$reason" >> "$PROOF_LOG" + flush_outputs || { success=false; reason=diagnostic_log_budget_exceeded; } + printf '[proof-result] %s: %s\n' "$success" "$reason" >&3 printf '{"token":"%s","success":%s,"reason":"%s","osVersion":"%s","architecture":"%s","uid":%s,"systemExit":%s,"diskArbitrationExit":%s,"recoveryExit":%s,"diskListExit":%s,"disk":"%s","diskBytes":%s,"readOnly":false}\n' \ "$PROOF_TOKEN" "$success" "$reason" "$os_version" "$architecture" "$uid" \ "$system_exit" "$arbitration_exit" "$recovery_exit" "$disk_list_exit" \ "$selected_disk" "$disk_bytes" > "$RESULT.tmp" /bin/mv -f "$RESULT.tmp" "$RESULT" || exit 1 + exec 9>&- + [ ! -p "$TIMER_FIFO" ] || /bin/rm -f "$TIMER_FIFO" # Keep the service alive for the bounded host diagnostic to capture evidence. while :; do sleep 60; done } +init_timer_fifo() { + # Recovery has Bash 3.2 before any SDK is installed. Its read timeout uses + # alarm(), avoiding a separate sleep process for every command and grace period. + [ ! -e "$TIMER_FIFO" ] || exit 1 + /usr/bin/mkfifo -m 600 "$TIMER_FIFO" || exit 1 + exec 9<> "$TIMER_FIFO" || exit 1 +} + +flush_outputs() { + (( ${#PENDING_OUTPUTS[@]} > 0 )) || return 0 + local started=$SECONDS sizes="/tmp/native-diagnostic-$$.sizes" proof_size output_size raw_size + local raw_count=0 raw_valid=1 pending_count=${#PENDING_OUTPUTS[@]} + local bounded="/tmp/native-diagnostic-$$.flush" + # One bounded native copy per group, rather than tail/stat startup per command. + # Keep native byte-oriented copying: Bash 3.2 read -n would read large outputs + # one byte per system call. Small scalar reads below have a separate tight bound. + /usr/bin/tail -c "$MAX_OUTPUT_BYTES" "${PENDING_OUTPUTS[@]}" > "$bounded" || return 1 + /usr/bin/stat -f '%z' "$PROOF_LOG" "$bounded" "${PENDING_OUTPUTS[@]}" > "$sizes" || return 1 + { + IFS= read -r proof_size; IFS= read -r output_size + while IFS= read -r raw_size; do + raw_count=$((raw_count + 1)) + [[ "$raw_size" =~ ^[0-9]+$ ]] && (( raw_size <= MAX_OUTPUT_BYTES )) || raw_valid=0 + done + } < "$sizes" + PENDING_OUTPUTS=() + [[ "$proof_size" =~ ^[0-9]+$ && "$output_size" =~ ^[0-9]+$ ]] || return 1 + (( raw_valid == 1 && raw_count == pending_count )) || return 1 + (( proof_size + output_size + 1024 <= MAX_LOG_BYTES )) || return 1 + /bin/cat "$bounded" >&3 || return 1 + printf '\n[proof-flush] outputs-bytes=%s elapsed=%ss\n' "$output_size" "$((SECONDS - started))" >&3 +} + +read_scalar() { + local value status + # All three values are short native machine/uid/version scalars. Reject excess + # content instead of accepting a truncated first line as a successful gate. + IFS= read -r -n 65 -d '' value < "$LAST_OUTPUT"; status=$? + # EOF is mandatory: the byte bound or a NUL delimiter must never hide a suffix. + (( status == 1 && ${#value} < 65 )) || return 1 + value=${value%$'\n'} + [[ "$value" != *$'\n'* ]] || return 1 + SCALAR="$value" +} + +cancel_probe() { + trap '' TERM INT + if [ -n "$ACTIVE_COMMAND" ]; then + kill -TERM "$ACTIVE_COMMAND" 2>/dev/null || : + IFS= read -r -t 2 -u 9 unused || : + kill -KILL "$ACTIVE_COMMAND" 2>/dev/null || : + wait "$ACTIVE_COMMAND" 2>/dev/null || : + fi + [ -z "$ACTIVE_TIMER" ] || { kill -TERM "$ACTIVE_TIMER" 2>/dev/null || :; wait "$ACTIVE_TIMER" 2>/dev/null || :; } + ACTIVE_COMMAND=""; ACTIVE_TIMER="" + finish false probe_cancelled +} + run_command() { local name="$1" shift - local process timer sleeper exit_code started observer="" + local process timer exit_code started waited LAST_OUTPUT="/tmp/native-diagnostic-$name.out" - printf '\n[proof-command] %s:' "$name" >> "$PROOF_LOG" - printf ' %s' "$@" >> "$PROOF_LOG" - printf '\n' >> "$PROOF_LOG" + printf '\n[proof-command] %s:' "$name" >&3 + printf ' %s' "$@" >&3 + printf '\n' >&3 started=$SECONDS "$@" > "$LAST_OUTPUT" 2>&1 & process=$! - printf '[proof-start] %s child=%s shell=%s parent=%s seconds=%s\n' "$name" "$process" "$$" "$PPID" "$started" >> "$PROOF_LOG" + ACTIVE_COMMAND="$process" + printf '[proof-start] %s child=%s shell=%s parent=%s seconds=%s\n' "$name" "$process" "$$" "$PPID" "$started" >&3 ( - trap 'kill "$sleeper" 2>/dev/null || :; exit 0' TERM INT - sleep 45 & - sleeper=$! - wait "$sleeper" - printf '[proof-timeout] %s child=%s elapsed=%ss signal=TERM\n' "$name" "$process" "$((SECONDS - started))" >> "$PROOF_LOG" + trap 'exit 0' TERM INT + IFS= read -r -t 45 -u 9 unused || : + printf '[proof-timeout] %s child=%s elapsed=%ss signal=TERM\n' "$name" "$process" "$((SECONDS - started))" >&3 kill -TERM "$process" 2>/dev/null || : - sleep 2 & sleeper=$!; wait "$sleeper" + IFS= read -r -t 2 -u 9 unused || : kill -KILL "$process" 2>/dev/null || : ) & timer=$! - if [[ "$name" = platform || "$name" = platform-warm ]]; then - # Observers never extend the independent 45-second command deadline. - ( - local sample_pid="" sample_timer="" pause_pid="" pause sample_exit - trap 'kill -KILL "$sample_pid" 2>/dev/null || :; kill -TERM "$sample_timer" "$pause_pid" 2>/dev/null || :; exit 0' TERM INT - for pause in 10 15; do - sleep "$pause" & pause_pid=$!; wait "$pause_pid" - printf '[proof-process] %s child=%s elapsed=%ss fields=pid,ppid,stat,cpu-time,elapsed,cpu-percent,wchan,comm\n' "$name" "$process" "$((SECONDS - started))" >> "$PROOF_LOG" - /bin/ps -p "$process" -o pid=,ppid=,stat=,time=,etime=,pcpu=,wchan=,comm= >> "$PROOF_LOG" 2>&1 & - sample_pid=$! - ( - local sample_sleeper="" - trap 'kill "$sample_sleeper" 2>/dev/null || :; exit 0' TERM INT - sleep 5 & sample_sleeper=$!; wait "$sample_sleeper" - kill -KILL "$sample_pid" 2>/dev/null || : - ) & sample_timer=$! - wait "$sample_pid"; sample_exit=$? - kill -TERM "$sample_timer" 2>/dev/null || :; wait "$sample_timer" 2>/dev/null || : - printf '[proof-process-exit] %s %s\n' "$name" "$sample_exit" >> "$PROOF_LOG" - sample_pid=""; sample_timer=""; pause_pid="" - done - ) & observer=$! - fi + ACTIVE_TIMER="$timer" wait "$process" exit_code=$? + waited=$SECONDS + # Includes fork/exec/wait, but excludes timer cleanup and evidence copying. + printf '[proof-native-wait] %s child=%s elapsed=%ss exit=%s\n' "$name" "$process" "$((waited - started))" "$exit_code" >&3 kill -TERM "$timer" 2>/dev/null || : wait "$timer" 2>/dev/null || : - if [ -n "$observer" ]; then - kill -TERM "$observer" 2>/dev/null || : - wait "$observer" 2>/dev/null || : - fi - /usr/bin/tail -c 524288 "$LAST_OUTPUT" >> "$PROOF_LOG" - printf '\n[proof-duration] %s child=%s elapsed=%ss\n' "$name" "$process" "$((SECONDS - started))" >> "$PROOF_LOG" - printf '\n[proof-exit] %s\n' "$exit_code" >> "$PROOF_LOG" + ACTIVE_COMMAND=""; ACTIVE_TIMER="" + printf '[proof-cleanup] %s child=%s elapsed=%ss total=%ss\n' "$name" "$process" "$((SECONDS - waited))" "$((SECONDS - started))" >&3 + printf '[proof-exit] %s\n' "$exit_code" >&3 LAST_EXIT="$exit_code" - local size - size=$(/usr/bin/stat -f '%z' "$PROOF_LOG" 2>/dev/null || printf '0') - (( size <= MAX_LOG_BYTES )) || finish false diagnostic_log_budget_exceeded + PENDING_OUTPUTS+=("$LAST_OUTPUT") return 0 } -# Collect cheap native identity/context before the first framework-dependent probe. -run_command kernel /usr/bin/uname -a -(( LAST_EXIT == 0 )) || finish false uname_failed +diagnose_failure() { + run_command kernel /usr/bin/uname -a + run_command account /usr/bin/id + run_command context /usr/sbin/sysctl kern.bootargs machdep.cpu.brand_string machdep.cpu.features machdep.cpu.leaf7_features + run_command parent /bin/ps -p "$$" -p "$PPID" -o pid=,ppid=,comm= + run_command processes /bin/ps -axo pid,ppid,comm +} + +fail_probe() { + local reason="$1" + flush_outputs || finish false diagnostic_log_budget_exceeded + diagnose_failure + finish false "$reason" +} + +init_timer_fifo +trap cancel_probe TERM INT + +# Test the required native gates before optional process/CPU diagnostics. run_command architecture /usr/bin/uname -m -(( LAST_EXIT == 0 )) || finish false architecture_probe_failed -architecture=$(cat "$LAST_OUTPUT") -[ "$architecture" = x86_64 ] || finish false unexpected_guest_architecture -run_command account /usr/bin/id +(( LAST_EXIT == 0 )) || fail_probe architecture_probe_failed +read_scalar || fail_probe architecture_output_invalid +architecture="$SCALAR" +[ "$architecture" = x86_64 ] || fail_probe unexpected_guest_architecture run_command uid /usr/bin/id -u -(( LAST_EXIT == 0 )) || finish false uid_probe_failed -uid=$(cat "$LAST_OUTPUT") -[ "$uid" = 0 ] || finish false recovery_account_not_root -run_command bootargs /usr/sbin/sysctl kern.bootargs -run_command cpu /usr/sbin/sysctl machdep.cpu.brand_string machdep.cpu.features machdep.cpu.leaf7_features -run_command parent /bin/ps -p "$$" -p "$PPID" -o pid=,ppid=,comm= -run_command processes /bin/ps -axo pid,ppid,comm +(( LAST_EXIT == 0 )) || fail_probe uid_probe_failed +read_scalar || fail_probe uid_output_invalid +uid="$SCALAR" +[ "$uid" = 0 ] || fail_probe recovery_account_not_root run_command platform /usr/bin/sw_vers platform_exit="$LAST_EXIT" -run_command system /bin/launchctl print system -system_exit="$LAST_EXIT" -run_command arbitration /bin/launchctl print system/com.apple.diskarbitrationd -arbitration_exit="$LAST_EXIT" -run_command recovery /bin/launchctl print system/com.apple.recoveryosd -recovery_exit="$LAST_EXIT" +flush_outputs || finish false diagnostic_log_budget_exceeded if (( platform_exit != 0 )); then - printf '[proof-retry] sw_vers once after native service context; same 45-second deadline\n' >> "$PROOF_LOG" + run_command system /bin/launchctl print system + system_exit="$LAST_EXIT" + run_command arbitration /bin/launchctl print system/com.apple.diskarbitrationd + arbitration_exit="$LAST_EXIT" + run_command recovery /bin/launchctl print system/com.apple.recoveryosd + recovery_exit="$LAST_EXIT" + printf '[proof-retry] sw_vers once after native service context; same 45-second deadline\n' >&3 run_command platform-warm /usr/bin/sw_vers platform_exit="$LAST_EXIT" fi -(( platform_exit == 0 )) || finish false sw_vers_failed +(( platform_exit == 0 )) || fail_probe sw_vers_failed run_command version /usr/bin/sw_vers -productVersion -(( LAST_EXIT == 0 )) || finish false product_version_failed -os_version=$(cat "$LAST_OUTPUT") -[[ "$os_version" =~ ^[0-9]+\.[0-9]+(\.[0-9]+)?$ ]] || finish false product_version_invalid -(( ${os_version%%.*} >= 14 )) || finish false unsupported_macos_version +(( LAST_EXIT == 0 )) || fail_probe product_version_failed +read_scalar || fail_probe product_version_invalid +os_version="$SCALAR" +[[ "$os_version" =~ ^[0-9]+\.[0-9]+(\.[0-9]+)?$ ]] || fail_probe product_version_invalid +(( ${os_version%%.*} >= 14 )) || fail_probe unsupported_macos_version +flush_outputs || finish false diagnostic_log_budget_exceeded # Bound readiness independently of the host's 40-minute overall deadline. readiness_start=$SECONDS attempt=0 while (( SECONDS - readiness_start < 600 )); do attempt=$((attempt + 1)) - printf '\n[readiness-attempt] %s\n' "$attempt" >> "$PROOF_LOG" + printf '\n[readiness-attempt] %s\n' "$attempt" >&3 run_command disks /usr/sbin/diskutil list physical disk_list_exit="$LAST_EXIT" if (( disk_list_exit == 0 )); then @@ -168,9 +225,9 @@ while (( SECONDS - readiness_start < 600 )); do candidates=$((candidates + 1)) selected_disk="/dev/$disk" disk_bytes="$size" - printf '[writable-target] %s %s bytes\n' "$selected_disk" "$disk_bytes" >> "$PROOF_LOG" + printf '[writable-target] %s %s bytes\n' "$selected_disk" "$disk_bytes" >&3 done < <(printf '%s\n' "$disk_list" | sed -nE 's#^/dev/(disk[0-9]+).*#\1#p') - (( candidates <= 1 )) || finish false ambiguous_writable_64g_disks + (( candidates <= 1 )) || fail_probe ambiguous_writable_64g_disks if (( candidates == 1 )); then # Re-probe live launchd domains after disk readiness, preserving native exits. run_command system_ready /bin/launchctl print system @@ -179,10 +236,11 @@ while (( SECONDS - readiness_start < 600 )); do arbitration_exit="$LAST_EXIT" run_command recovery_ready /bin/launchctl print system/com.apple.recoveryosd recovery_exit="$LAST_EXIT" - (( system_exit == 0 && arbitration_exit == 0 && recovery_exit == 0 )) || finish false service_domain_not_ready + (( system_exit == 0 && arbitration_exit == 0 && recovery_exit == 0 )) || fail_probe service_domain_not_ready finish true native_recovery_and_writable_64g_disk_ready fi fi - sleep 5 + flush_outputs || finish false diagnostic_log_budget_exceeded + IFS= read -r -t 5 -u 9 unused || : done -finish false disk_management_or_writable_target_not_ready +fail_probe disk_management_or_writable_target_not_ready From 40281b57a5e80bbe699ae50d8927e629d09d05ad Mon Sep 17 00:00:00 2001 From: dh Date: Sat, 3 Oct 2026 18:58:10 +0200 Subject: [PATCH 4/6] Isolate measured UID watchdog failure in the native TCG diagnostic --- docs/macos-native-diagnostic.md | 4 +++- tools/ci/macos-native-readiness.sh | 8 ++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/macos-native-diagnostic.md b/docs/macos-native-diagnostic.md index 88c72eb..de03757 100644 --- a/docs/macos-native-diagnostic.md +++ b/docs/macos-native-diagnostic.md @@ -20,7 +20,7 @@ Dependencies are the existing Linux/x64 runner, .NET 10 SDK, Git, Bash and Docke The helper clones Dockur commit `16a5b470cdd601bae8b05b02d748d7edfb36c12e`, verifies its exact Recovery patcher hash, and makes three narrowly verified source edits. The early `rc.cdrom.sh` hook only mounts the existing state share and returns. A same-length XML replacement makes the existing `com.apple.recoveryosd` LaunchDaemon execute `/bin/bash /Volumes/installstate/launch.sh` after boot tasks. The staged `launch.sh` is replaced entirely by the checked-in read-only readiness probe. All replacement counts are exact; an upstream mismatch fails. The two imported QEMU image digests are pinned and the final image/source/Recovery hashes are retained. Other upstream Dockerfile downloads are observed through the resulting image identity rather than asserted to be immutable. -The VM uses TCG (`KVM=N`), slirp networking, a 4-GiB guest, two virtual CPUs and a sparse 64-GiB data disk. Its container has a 6-GiB memory/swap ceiling and a two-CPU limit. The existing Docker daemon must report at least two CPUs and 6 GiB total memory, the runner must have at least 5 GiB available memory, and the Docker filesystem must have at least 8 GiB free before Recovery downloads or boot. Its own native commands retain 45-second watchdogs and a ten-minute readiness phase; the host orchestrator has a 40-minute deadline and the workflow a 45-minute limit. +The VM uses TCG (`KVM=N`), slirp networking, a 4-GiB guest, two virtual CPUs and a sparse 64-GiB data disk. Its container has a 6-GiB memory/swap ceiling and a two-CPU limit. The existing Docker daemon must report at least two CPUs and 6 GiB total memory, the runner must have at least 5 GiB available memory, and the Docker filesystem must have at least 8 GiB free before Recovery downloads or boot. Native commands have 45-second watchdogs, except the single UID gate's targeted 180-second timing experiment. The ten-minute disk-readiness phase, 40-minute host deadline and 45-minute workflow limit remain unchanged. Actual remote run 4155 stopped at the first `sw_vers` with exit 143. Run 4159 then proved native Darwin/x86_64, root identity and guest AVX2, but reached the host deadline before `sw_vers` or the service/disk gates. Its logged command durations included timer cleanup and output copying, so they did not isolate native execution time. @@ -28,6 +28,8 @@ The next probe runs mandatory architecture, root identity and platform gates bef After an initial platform failure the hook collects native launchd context and repeats the identical `sw_vers` command once, with the same 45-second limit. Native product version and all original identity/service/disk gates remain required. Optional process and CPU diagnostics run only after a gate fails. The upstream AVX2 warning reads host flags; run 4159 observed AVX2 in the actual guest. No host or guest CPU settings change. +Actual run 4161 separated native wait from timer cleanup: architecture passed after 39 seconds, but the UID gate was terminated by its 45-second watchdog (51-second fork/exec/wait duration). Native ps commands passed after 34-42 seconds; output flushes took 289 and 76 seconds. The next diagnostic changes only the UID gate's watchdog to 180 seconds while retaining exit-zero/exact-root checks. This tests whether the measured short limit caused that failure; it does not establish a guest startup or service cause, and it does not qualify native CI. The earlier local 16-case harness qualified the previous 45-second timer/cancellation/output behavior, not this new timing experiment or the actual emulated guest. + ## Evidence and cleanup Evidence is written under the requested output directory: run identity and candidate commit, Docker/runner resources, exact source patch artifacts and hashes, image/container inspection, Recovery hash, native platform/process/launchctl/diskutil logs, machine-readable guest result, outcome and cleanup receipt. The workflow retains these as a seven-day artifact. Phase names and up to 512 KiB of the final native proof also appear in CI stdout, on success or failure, with the run token replaced; no environment or credential dump is printed. A Docker start/build exit zero is not a successful native result. A missing, stale, unsupported-platform, read-only or wrong-size guest receipt fails. diff --git a/tools/ci/macos-native-readiness.sh b/tools/ci/macos-native-readiness.sh index 91bf3bb..db8ff48 100644 --- a/tools/ci/macos-native-readiness.sh +++ b/tools/ci/macos-native-readiness.sh @@ -112,7 +112,10 @@ cancel_probe() { run_command() { local name="$1" shift - local process timer exit_code started waited + local process timer exit_code started waited command_limit=45 + # Run 4161: even native uname/ps startup took 34-42s under TCG. + # Isolate only the failed UID gate; every other watchdog remains unchanged. + [[ "$name" != uid ]] || command_limit=180 LAST_OUTPUT="/tmp/native-diagnostic-$name.out" printf '\n[proof-command] %s:' "$name" >&3 printf ' %s' "$@" >&3 @@ -122,9 +125,10 @@ run_command() { process=$! ACTIVE_COMMAND="$process" printf '[proof-start] %s child=%s shell=%s parent=%s seconds=%s\n' "$name" "$process" "$$" "$PPID" "$started" >&3 + printf '[proof-limit] %s %ss\n' "$name" "$command_limit" >&3 ( trap 'exit 0' TERM INT - IFS= read -r -t 45 -u 9 unused || : + IFS= read -r -t "$command_limit" -u 9 unused || : printf '[proof-timeout] %s child=%s elapsed=%ss signal=TERM\n' "$name" "$process" "$((SECONDS - started))" >&3 kill -TERM "$process" 2>/dev/null || : IFS= read -r -t 2 -u 9 unused || : From 4606de069678e8f95dfe3c7dad1bf5ce5384d30c Mon Sep 17 00:00:00 2001 From: dh Date: Sat, 3 Oct 2026 20:17:56 +0200 Subject: [PATCH 5/6] ci: preserve Apple Recovery daemon during read-only readiness probe --- docs/macos-native-diagnostic.md | 8 +++++--- tools/ci/MacOsNativeDiagnostic.cs | 18 +++++++++++++++--- tools/ci/macos-native-bootstrap.sh | 5 +++++ 3 files changed, 25 insertions(+), 6 deletions(-) create mode 100644 tools/ci/macos-native-bootstrap.sh diff --git a/docs/macos-native-diagnostic.md b/docs/macos-native-diagnostic.md index de03757..13d9816 100644 --- a/docs/macos-native-diagnostic.md +++ b/docs/macos-native-diagnostic.md @@ -18,7 +18,9 @@ dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --cleanup --output artifa Dependencies are the existing Linux/x64 runner, .NET 10 SDK, Git, Bash and Docker CLI/socket. The actual execution downloads public Dockur source, upstream build assets, Docker images and Apple Recovery; it does not use workstation credentials. The existing upstream Python UDIF patcher and the Bash hook are retained because they run inside the pinned Linux/macOS boot integration. Independent orchestration and validation remain C#. -The helper clones Dockur commit `16a5b470cdd601bae8b05b02d748d7edfb36c12e`, verifies its exact Recovery patcher hash, and makes three narrowly verified source edits. The early `rc.cdrom.sh` hook only mounts the existing state share and returns. A same-length XML replacement makes the existing `com.apple.recoveryosd` LaunchDaemon execute `/bin/bash /Volumes/installstate/launch.sh` after boot tasks. The staged `launch.sh` is replaced entirely by the checked-in read-only readiness probe. All replacement counts are exact; an upstream mismatch fails. The two imported QEMU image digests are pinned and the final image/source/Recovery hashes are retained. Other upstream Dockerfile downloads are observed through the resulting image identity rather than asserted to be immutable. +The helper clones Dockur commit `16a5b470cdd601bae8b05b02d748d7edfb36c12e`, verifies its exact Recovery patcher and staging-script hashes, and makes narrowly verified source edits. The early `rc.cdrom.sh` hook only mounts the existing state share and returns. A same-length XML replacement makes the existing `com.apple.recoveryosd` LaunchDaemon execute `/bin/bash /Volumes/installstate/launch.sh` after boot tasks. In this separate bootstrap A/B candidate, that file is the small `tools/ci/macos-native-bootstrap.sh` wrapper: it starts `/bin/bash /Volumes/installstate/readiness.sh` in the background, then `exec /usr/libexec/recoveryosd`. The original Apple executable therefore replaces the wrapper under the same launchd job/PID if exec succeeds. The staging copy and comparison transport the second script through the existing 9p share. All replacement counts are exact; an upstream mismatch fails. The two imported QEMU image digests are pinned and the final image/source/Recovery hashes are retained. Other upstream Dockerfile downloads are observed through the resulting image identity rather than asserted to be immutable. + +The experiment starts from commit `40281b57a5e80bbe699ae50d8927e629d09d05ad`. The readiness script is byte-identical to that baseline; validation enforces SHA-256 `4d428f594dac14eff64ed87b172c81ecf85ac91da8c5460cd6ec4b1d310800c3`. This changes only whether Apple's original Recovery daemon runs alongside the same probe. The probe now has that daemon as its parent and competes with its work for guest CPU/I/O. If the job restarts, the wrapper could start another read-only probe. Those lifecycle and scheduling effects are part of the experiment, not proof of a CoreFoundation or DiskArbitration dependency. No original Apple daemon implementation or such dependency is established by the retained plist. The VM uses TCG (`KVM=N`), slirp networking, a 4-GiB guest, two virtual CPUs and a sparse 64-GiB data disk. Its container has a 6-GiB memory/swap ceiling and a two-CPU limit. The existing Docker daemon must report at least two CPUs and 6 GiB total memory, the runner must have at least 5 GiB available memory, and the Docker filesystem must have at least 8 GiB free before Recovery downloads or boot. Native commands have 45-second watchdogs, except the single UID gate's targeted 180-second timing experiment. The ten-minute disk-readiness phase, 40-minute host deadline and 45-minute workflow limit remain unchanged. @@ -28,11 +30,11 @@ The next probe runs mandatory architecture, root identity and platform gates bef After an initial platform failure the hook collects native launchd context and repeats the identical `sw_vers` command once, with the same 45-second limit. Native product version and all original identity/service/disk gates remain required. Optional process and CPU diagnostics run only after a gate fails. The upstream AVX2 warning reads host flags; run 4159 observed AVX2 in the actual guest. No host or guest CPU settings change. -Actual run 4161 separated native wait from timer cleanup: architecture passed after 39 seconds, but the UID gate was terminated by its 45-second watchdog (51-second fork/exec/wait duration). Native ps commands passed after 34-42 seconds; output flushes took 289 and 76 seconds. The next diagnostic changes only the UID gate's watchdog to 180 seconds while retaining exit-zero/exact-root checks. This tests whether the measured short limit caused that failure; it does not establish a guest startup or service cause, and it does not qualify native CI. The earlier local 16-case harness qualified the previous 45-second timer/cancellation/output behavior, not this new timing experiment or the actual emulated guest. +Actual run 4161 separated native wait from timer cleanup: architecture passed after 39 seconds, but the UID gate was terminated by its 45-second watchdog (51-second fork/exec/wait duration). Native ps commands passed after 34-42 seconds; output flushes took 289 and 76 seconds. Run 4163 used the targeted UID watchdog of 180 seconds but returned UID 0 with exit zero after 38 seconds, so it did not establish a need for that longer limit. Architecture passed after 46 seconds; the initial `sw_vers`, system-domain and DiskArbitration queries were terminated. The recovery-label query passed after 31 seconds but described the replacement Bash job, not Apple's executable. The identical warm `sw_vers` retry had no final exit before the 40-minute host deadline. This bootstrap A/B preserves all those limits and native pass conditions; no cause or native readiness is claimed. The earlier local 16-case harness qualified the previous 45-second timer/cancellation/output behavior, not this experiment or the actual emulated guest. ## Evidence and cleanup -Evidence is written under the requested output directory: run identity and candidate commit, Docker/runner resources, exact source patch artifacts and hashes, image/container inspection, Recovery hash, native platform/process/launchctl/diskutil logs, machine-readable guest result, outcome and cleanup receipt. The workflow retains these as a seven-day artifact. Phase names and up to 512 KiB of the final native proof also appear in CI stdout, on success or failure, with the run token replaced; no environment or credential dump is printed. A Docker start/build exit zero is not a successful native result. A missing, stale, unsupported-platform, read-only or wrong-size guest receipt fails. +Evidence is written under the requested output directory: run identity and candidate commit, Docker/runner resources, exact source patch artifacts and hashes, image/container inspection, Recovery hash, native platform/process/launchctl/diskutil logs, machine-readable guest result, outcome and cleanup receipt. `guest-launch.sh` records the wrapper and its original `recoveryosd` exec path; `guest-readiness.sh` records the separate token-bound probe; `image.sh.patched` records the copy/comparison seam. `source-hashes.json` distinguishes all three. These source artifacts alone do not prove that Apple's executable actually ran. The workflow retains these as a seven-day artifact. Phase names and up to 512 KiB of the final native proof also appear in CI stdout, on success or failure, with the run token replaced; no environment or credential dump is printed. A Docker start/build exit zero is not a successful native result. A missing, stale, unsupported-platform, read-only or wrong-size guest receipt fails. While Recovery readiness is pending, a minute heartbeat reports elapsed guest time and the container's running state. Before final cleanup, an optional ten-second capture rechecks the saved container ID/ownership label and uses the pinned image's existing Unix HMP socket, `nc.openbsd` and a five-second `timeout` to collect only [`info status` and `screendump`](https://www.qemu.org/docs/master/system/monitor.html), retaining the command transcript, exit codes and fresh bounded PPM screenshot. Capture failure is visible and never changes native readiness success. diff --git a/tools/ci/MacOsNativeDiagnostic.cs b/tools/ci/MacOsNativeDiagnostic.cs index bb899d8..08d968d 100644 --- a/tools/ci/MacOsNativeDiagnostic.cs +++ b/tools/ci/MacOsNativeDiagnostic.cs @@ -168,6 +168,8 @@ static class NativeDiagnostic static void ValidateContracts() { + if (Hash(File.ReadAllBytes(Path.Combine("tools", "ci", "macos-native-readiness.sh"))) != "4d428f594dac14eff64ed87b172c81ecf85ac91da8c5460cd6ec4b1d310800c3") + throw new InvalidOperationException("Bootstrap A/B requires the unchanged 40281b readiness probe and limits."); XDocument.Parse(DiagnosticDaemon); if (Encoding.UTF8.GetByteCount(DiagnosticDaemon) > Encoding.UTF8.GetByteCount(OriginalDaemon + "\n")) throw new InvalidOperationException("Daemon replacement exceeds original file."); var good = JsonSerializer.Serialize(new { token = "validation", success = true, osVersion = "14.6.1", architecture = "x86_64", uid = 0, disk = "/dev/disk1", diskBytes = GuestDiskBytes, readOnly = false, systemExit = 0, diskArbitrationExit = 0, recoveryExit = 0, diskListExit = 0 }); @@ -199,16 +201,26 @@ static class NativeDiagnostic var entry = ReplaceOnce(File.ReadAllText(entryPath), "set -Eeuo pipefail\n", "set -Eeuo pipefail\n\n# Diagnostic budget: inspect existing Docker storage before Recovery download/boot.\ndf -Pk /storage\nfree_kib=$(df -Pk /storage | awk 'NR==2 {print $4}')\n[[ \"$free_kib\" =~ ^[0-9]+$ ]] && (( free_kib >= 8 * 1024 * 1024 )) || { echo 'Existing Docker storage has less than the 8-GiB diagnostic budget.' >&2; exit 1; }\n"); var hookPath = Path.Combine("tools", "ci", "macos-native-readiness.sh"); var hook = ReplaceOnce(File.ReadAllText(hookPath), "@@PROOF_TOKEN@@", token); - foreach (var pair in new[] { ("recovery-patch.py", patch), ("Dockerfile.patched", dockerfile), ("container-entry.sh", entry), ("guest-launch.sh", hook), ("recoveryosd-original.plist", daemon), ("recoveryosd-diagnostic.plist", DiagnosticDaemon), ("early-bootstrap.sh", MountOnlyBootstrap) }) + var wrapper = File.ReadAllText(Path.Combine("tools", "ci", "macos-native-bootstrap.sh")); + var imagePath = Path.Combine(source, "src", "image.sh"); + var originalImage = File.ReadAllText(imagePath); + if (Hash(Encoding.UTF8.GetBytes(originalImage)) != "c08bf9436fb8b72ea82fdf0e677641ab2fc42a0a59e2cf0309c00df519884c5c") throw new InvalidOperationException("Pinned Recovery staging script hash mismatch."); + var image = ReplaceOnce(originalImage, " if ! cp -f \"$IMAGE_TOOLS/recovery/launch.sh\" \"$script\"; then\n", " if ! cp -f \"$IMAGE_TOOLS/recovery/launch.sh\" \"$script\" ||\n ! cp -f \"$IMAGE_TOOLS/recovery/readiness.sh\" \"${script%/*}/readiness.sh\"; then\n"); + image = ReplaceOnce(image, " if ! cmp -s \"$IMAGE_TOOLS/recovery/launch.sh\" \"$script\" ||\n", " if ! cmp -s \"$IMAGE_TOOLS/recovery/launch.sh\" \"$script\" ||\n ! cmp -s \"$IMAGE_TOOLS/recovery/readiness.sh\" \"$state/readiness.sh\" ||\n"); + foreach (var pair in new[] { ("recovery-patch.py", patch), ("Dockerfile.patched", dockerfile), ("container-entry.sh", entry), ("guest-launch.sh", wrapper), ("guest-readiness.sh", hook), ("image.sh.patched", image), ("recoveryosd-original.plist", daemon), ("recoveryosd-diagnostic.plist", DiagnosticDaemon), ("early-bootstrap.sh", MountOnlyBootstrap) }) File.WriteAllText(Path.Combine(output, pair.Item1), pair.Item2, new UTF8Encoding(false)); - Save(Path.Combine(output, "source-hashes.json"), Directory.GetFiles(output).Where(path => Path.GetFileName(path) is "recovery-patch.py" or "Dockerfile.patched" or "container-entry.sh" or "guest-launch.sh" or "recoveryosd-original.plist" or "recoveryosd-diagnostic.plist" or "early-bootstrap.sh").ToDictionary(path => Path.GetFileName(path)!, path => Hash(File.ReadAllBytes(path)))); + Save(Path.Combine(output, "source-hashes.json"), Directory.GetFiles(output).Where(path => Path.GetFileName(path) is "recovery-patch.py" or "Dockerfile.patched" or "container-entry.sh" or "guest-launch.sh" or "guest-readiness.sh" or "image.sh.patched" or "recoveryosd-original.plist" or "recoveryosd-diagnostic.plist" or "early-bootstrap.sh").ToDictionary(path => Path.GetFileName(path)!, path => Hash(File.ReadAllBytes(path)))); await Command("bash", ["-n", Path.Combine(output, "guest-launch.sh")], output, "guest-hook-syntax", cancellation); + await Command("bash", ["-n", Path.Combine(output, "guest-readiness.sh")], output, "guest-readiness-syntax", cancellation); + await Command("bash", ["-n", Path.Combine(output, "image.sh.patched")], output, "guest-staging-syntax", cancellation); await Command("bash", ["-n", Path.Combine(output, "container-entry.sh")], output, "entry-syntax", cancellation); if (!writeSource) return; File.WriteAllText(patchPath, patch, new UTF8Encoding(false)); File.WriteAllText(dockerPath, dockerfile, new UTF8Encoding(false)); File.WriteAllText(entryPath, entry, new UTF8Encoding(false)); - File.WriteAllText(Path.Combine(source, "src/install/recovery/launch.sh"), hook, new UTF8Encoding(false)); + File.WriteAllText(imagePath, image, new UTF8Encoding(false)); + File.WriteAllText(Path.Combine(source, "src/install/recovery/launch.sh"), wrapper, new UTF8Encoding(false)); + File.WriteAllText(Path.Combine(source, "src/install/recovery/readiness.sh"), hook, new UTF8Encoding(false)); } static string ReplaceOnce(string text, string oldValue, string newValue) => ReplaceAllExact(text, oldValue, newValue, 1); diff --git a/tools/ci/macos-native-bootstrap.sh b/tools/ci/macos-native-bootstrap.sh new file mode 100644 index 0000000..fb005bb --- /dev/null +++ b/tools/ci/macos-native-bootstrap.sh @@ -0,0 +1,5 @@ +#!/bin/bash +# Apple Recovery has Bash before any SDK is installed. Preserve the original +# daemon under its launchd label/PID while the unchanged read-only probe runs. +/bin/bash /Volumes/installstate/readiness.sh & +exec /usr/libexec/recoveryosd From 45d7bde71f9f5a1f7121585fe3ee9fc81f7c585f Mon Sep 17 00:00:00 2001 From: dh Date: Sat, 3 Oct 2026 20:58:12 +0200 Subject: [PATCH 6/6] ci: probe macOS Ventura on existing KVM hardware --- .gitea/workflows/macos-native-diagnostic.yaml | 4 +- docs/macos-native-diagnostic.md | 65 ++++--- tools/ci/MacOsNativeDiagnostic.cs | 180 ++++++++++++++++-- tools/ci/macos-native-readiness.sh | 2 +- 4 files changed, 204 insertions(+), 47 deletions(-) diff --git a/.gitea/workflows/macos-native-diagnostic.yaml b/.gitea/workflows/macos-native-diagnostic.yaml index d1bb8f2..710122f 100644 --- a/.gitea/workflows/macos-native-diagnostic.yaml +++ b/.gitea/workflows/macos-native-diagnostic.yaml @@ -1,4 +1,4 @@ -name: Native macOS Recovery diagnostic on Ubuntu +name: macOS 13 KVM Cryptex Recovery compatibility diagnostic on: workflow_dispatch: @@ -19,7 +19,7 @@ jobs: with: dotnet-version: "10.0.x" - - name: Probe native macOS Recovery with existing Docker resources + - name: Probe macOS 13 Recovery with existing KVM and host CPU run: dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --run --output artifacts/native-macos - name: Always clean up only this diagnostic's owned resources diff --git a/docs/macos-native-diagnostic.md b/docs/macos-native-diagnostic.md index 13d9816..189476e 100644 --- a/docs/macos-native-diagnostic.md +++ b/docs/macos-native-diagnostic.md @@ -1,47 +1,62 @@ -# Native macOS Recovery diagnostic on the existing Ubuntu runner +# macOS 13 KVM/Cryptex compatibility diagnostic -This manual diagnostic tests the unresolved Recovery startup boundary before adding a native macOS application test job. It does not install macOS, erase a guest disk, install .NET or Apple CLT, or run Meeting Assistant tests. A green diagnostic means only that a real macOS 14+ x86_64 Recovery guest has a working launchd system domain, DiskArbitration and exactly one writable 64-GiB guest disk. +This separate manual candidate probes Recovery readiness on the existing Ubuntu Docker daemon with KVM, the real Intel host CPU and macOS 13. It does not install macOS, erase a disk, install .NET or Apple CLT, or run Meeting Assistant. Passing proves only a fresh macOS 13+ x86_64 Recovery guest with root identity, a working launchd system domain, DiskArbitration and exactly one writable 64-GiB guest disk. -The workflow `.gitea/workflows/macos-native-diagnostic.yaml` has only `workflow_dispatch`; it does not run on ordinary pushes or pull requests. It uses the same `ubuntu-latest` label and existing Docker daemon as the current builds. There are no runner changes, extra host devices, privileged containers, added capabilities, published ports, host networking or new secrets. It fails clearly if the existing Docker daemon cannot fit its bounded resource budget. +Baseline: bootstrap commit `4606de069678e8f95dfe3c7dad1bf5ce5384d30c`; separate branch `codex/macos-ci-kvm-compatibility`. KVM, CPU passthrough, Recovery major version and guest Cryptex staging change together. This is a compatibility experiment, not a causal single-variable A/B test. The TCG/bootstrap experiment remains separate. -## Helper entry point and invocation +## Reasons and remaining gaps -The orchestration is a .NET 10 file-based C# app at `tools/ci/MacOsNativeDiagnostic.cs`: +The existing daemon's Intel Celeron 1037U lacks AVX/AVX2; a separate diagnostic proved KVM enabled/paused state and clean exit. `CPU_MODEL=host` preserves actual instruction availability rather than advertising AVX2 through emulated Skylake. This candidate refuses a TCG or CPU-model fallback. -```sh +All four Swift helpers target `x86_64-apple-macos13.0`; the macOS 14 EventKit call has an existing macOS 13 fallback. Inspected native Mach-O files in pinned .NET SDK 10.0.401 x64 declare `minos 12.0`. These source/binary minima are not runtime qualification or vendor support: macOS 13 is outside [Microsoft's current .NET 10 supported-OS policy](https://github.com/dotnet/core/blob/main/release-notes/10.0/supported-os.md). This probe does not install that SDK, compile helpers or test calendar/audio permissions. + +[Official CryptexFixup 1.0.5](https://github.com/acidanthera/CryptexFixup/blob/1.0.5/CryptexFixup/kern_start.cpp) activates without AVX2 and registers for normal, installer/Recovery and safe-mode boots. It redirects installer/updater ramrod to Apple Silicon's Rosetta Cryptex and bypasses APFS root-hash authentication on Ventura and newer. It does not emulate missing instructions. This kernel patch affects only the owned guest, never a host module. + +**Recovery cache gap:** CryptexFixup does not replace an already running Recovery BaseSystem shared cache. Its installer/update selector targets the installed Cryptex, but this readiness-only run invokes no installer. Staging or loading it therefore proves no Recovery userland compatibility. Actual CPU/kernel behavior, guest injection, all native gates and any later installed-Cryptex/build/test behavior remain unqualified until observed. + +Apple Recovery uses the pinned public InternetRecovery protocol with board ID and session/asset tokens, without Apple ID or workstation credentials. The macOS 13 selection, downloaded hash and actual guest version are retained; the hook downloads no full installer or SDK. + +## Entry point and dependencies + +Orchestration/validation remain the .NET 10 file-based app `tools/ci/MacOsNativeDiagnostic.cs`. Bash/Python stay only in the existing pinned Linux/macOS boot integration. + +~~~sh dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --help dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --validate -dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --validate --source /path/to/pinned/dockur-clone --output artifacts/native-validation +dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --validate --source /path/to/clean/pinned/dockur-clone --cryptex-archive /path/to/CryptexFixup-1.0.5-RELEASE.zip --output /path/to/fresh/validation +~~~ + +`--validate` checks result/container contracts without Docker. With `--source` it verifies the actual Cryptex ZIP/bundle, source seams, generated OpenCore configuration and staging/checksum contracts, then checks Bash syntax, leaving the supplied source untouched. It does not download/extract the LongQT ISO or execute the active-Lilu runtime checks. The ISO checksum is enforced during the later Docker build; active Lilu and EFI-copy checks execute only during container boot. The optional local Cryptex ZIP must match the release size/hash; omitting it downloads only the public 69,703-byte release. Use a fresh output directory. Dependencies are .NET 10, Git and Bash; manual execution also requires the existing Linux/x64 Docker daemon and its existing KVM device. + +The manual-only workflow keeps these owned run/cleanup entry points; validation invokes neither: + +~~~sh dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --run --output artifacts/native-macos dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --cleanup --output artifacts/native-macos -``` +~~~ -Dependencies are the existing Linux/x64 runner, .NET 10 SDK, Git, Bash and Docker CLI/socket. The actual execution downloads public Dockur source, upstream build assets, Docker images and Apple Recovery; it does not use workstation credentials. The existing upstream Python UDIF patcher and the Bash hook are retained because they run inside the pinned Linux/macOS boot integration. Independent orchestration and validation remain C#. +## Exact bootasset contract -The helper clones Dockur commit `16a5b470cdd601bae8b05b02d748d7edfb36c12e`, verifies its exact Recovery patcher and staging-script hashes, and makes narrowly verified source edits. The early `rc.cdrom.sh` hook only mounts the existing state share and returns. A same-length XML replacement makes the existing `com.apple.recoveryosd` LaunchDaemon execute `/bin/bash /Volumes/installstate/launch.sh` after boot tasks. In this separate bootstrap A/B candidate, that file is the small `tools/ci/macos-native-bootstrap.sh` wrapper: it starts `/bin/bash /Volumes/installstate/readiness.sh` in the background, then `exec /usr/libexec/recoveryosd`. The original Apple executable therefore replaces the wrapper under the same launchd job/PID if exec succeeds. The staging copy and comparison transport the second script through the existing 9p share. All replacement counts are exact; an upstream mismatch fails. The two imported QEMU image digests are pinned and the final image/source/Recovery hashes are retained. Other upstream Dockerfile downloads are observed through the resulting image identity rather than asserted to be immutable. +Dockur stays pinned to `16a5b470cdd601bae8b05b02d748d7edfb36c12e`. Original Recovery patcher/staging, Dockerfile, OpenCore script and active config hashes are verified before edits. Both existing QEMU image digests remain pinned; other existing upstream downloads are observed through image identity. -The experiment starts from commit `40281b57a5e80bbe699ae50d8927e629d09d05ad`. The readiness script is byte-identical to that baseline; validation enforces SHA-256 `4d428f594dac14eff64ed87b172c81ecf85ac91da8c5460cd6ec4b1d310800c3`. This changes only whether Apple's original Recovery daemon runs alongside the same probe. The probe now has that daemon as its parent and competes with its work for guest CPU/I/O. If the job restarts, the wrapper could start another read-only probe. Those lifecycle and scheduling effects are part of the experiment, not proof of a CoreFoundation or DiskArbitration dependency. No original Apple daemon implementation or such dependency is established by the retained plist. +The [original LongQT v0.7 template](https://github.com/LongQT-sea/OpenCore-ISO/releases/download/v0.7/LongQT-OpenCore-v0.7.iso), 15,884,288 bytes, is now Docker-ADD-checksummed to SHA256 `287328995d4198f1b05166f087d85bf7ef66bedafe150d17ad112ac8de60051d`. Runtime copies actual `EFI_RELEASE/EFI/OC/Kexts`, including Lilu 1.7.1, even with official OpenCore DEBUG executables. Active Lilu: executable 526,984 bytes, SHA256 `0c016d93cfe40c7fa3965813175c1b991a76f3d295efd5be66ae712b4a3ffb52`; Info.plist SHA256 `fc885f3319f326e3af60e7965a5216b671772d39d40993ec695758bb43d6ea3a`. Staging checks both hashes and bundle version. Cryptex declares Lilu 1.4.7; [Lilu history](https://github.com/acidanthera/Lilu/blob/master/Changelog.md) includes Ventura/Sonoma installer/Recovery support before 1.7.1. Existing Lilu is kept. -The VM uses TCG (`KVM=N`), slirp networking, a 4-GiB guest, two virtual CPUs and a sparse 64-GiB data disk. Its container has a 6-GiB memory/swap ceiling and a two-CPU limit. The existing Docker daemon must report at least two CPUs and 6 GiB total memory, the runner must have at least 5 GiB available memory, and the Docker filesystem must have at least 8 GiB free before Recovery downloads or boot. Native commands have 45-second watchdogs, except the single UID gate's targeted 180-second timing experiment. The ten-minute disk-readiness phase, 40-minute host deadline and 45-minute workflow limit remain unchanged. +[CryptexFixup-1.0.5-RELEASE.zip](https://github.com/acidanthera/CryptexFixup/releases/download/1.0.5/CryptexFixup-1.0.5-RELEASE.zip), 69,703 bytes, SHA256 `25041d94a0fe9a0261caf0ba89b36dfcb21682bf3c697a34bcaddc839576ab30`, is checked in C#. Only expected Info.plist/executable files are accepted; identity/version/dependency and individual hashes are recorded. Runtime checks files before/after copying into fresh guest EFI. -Actual remote run 4155 stopped at the first `sw_vers` with exit 143. Run 4159 then proved native Darwin/x86_64, root identity and guest AVX2, but reached the host deadline before `sw_vers` or the service/disk gates. Its logged command durations included timer cleanup and output copying, so they did not isolate native execution time. +Active `/assets/config.plist` receives exactly one enabled Cryptex immediately after enabled Lilu, preserving every other kext's order. Entry: `Arch=x86_64`, `BundlePath=CryptexFixup.kext`, `ExecutablePath=Contents/MacOS/CryptexFixup`, `PlistPath=Contents/Info.plist`, `MinKernel=22.0.0`, empty `MaxKernel`. [OpenCore Kernel.Add](https://github.com/acidanthera/OpenCorePkg/blob/1.0.7/Docs/Configuration.tex) requires dependencies first; bounds are Darwin versions. Runtime rechecks order/enabled/paths/architecture/bounds and rejects unverified `/custom.plist`. -The next probe runs mandatory architecture, root identity and platform gates before optional process/CPU diagnostics. It keeps the proof log open, uses Bash 3.2's timed FIFO reads instead of starting a separate sleep process for every watchdog, and groups output copying and byte-limit checks. Separate markers record fork/exec/wait, timer cleanup and output flush durations. Raw output still fails above 512 KiB per command, proof above 4 MiB fails, and scalar gates reject hidden suffixes or multiline values. A local harmless-command harness verifies all 16 timeout, cancellation, output and scalar cases; this does not qualify macOS Recovery. +No new force/beta argument is needed for actual no-AVX2 CPUs. Baseline arguments remain. Validation rejects disabling arguments, `-crypt_allow_hash_validation` (disables the APFS patch) and unexpected Cryptex force/beta overrides. Manifest/profile enter the boot signature; this candidate always rebuilds `boot.img` and accepts no old cache as evidence. -After an initial platform failure the hook collects native launchd context and repeats the identical `sw_vers` command once, with the same 45-second limit. Native product version and all original identity/service/disk gates remain required. Optional process and CPU diagnostics run only after a gate fails. The upstream AVX2 warning reads host flags; run 4159 observed AVX2 in the actual guest. No host or guest CPU settings change. +## Gates, privileges and cleanup -Actual run 4161 separated native wait from timer cleanup: architecture passed after 39 seconds, but the UID gate was terminated by its 45-second watchdog (51-second fork/exec/wait duration). Native ps commands passed after 34-42 seconds; output flushes took 289 and 76 seconds. Run 4163 used the targeted UID watchdog of 180 seconds but returned UID 0 with exit zero after 38 seconds, so it did not establish a need for that longer limit. Architecture passed after 46 seconds; the initial `sw_vers`, system-domain and DiskArbitration queries were terminated. The recovery-label query passed after 31 seconds but described the replacement Bash job, not Apple's executable. The identical warm `sw_vers` retry had no final exit before the 40-minute host deadline. This bootstrap A/B preserves all those limits and native pass conditions; no cause or native readiness is claimed. The earlier local 16-case harness qualified the previous 45-second timer/cancellation/output behavior, not this experiment or the actual emulated guest. +The Apple wrapper is byte-identical to baseline: background `/Volumes/installstate/readiness.sh` then `exec /usr/libexec/recoveryosd` under the same launchd job/PID. Source evidence does not prove Apple's executable ran. -## Evidence and cleanup +Readiness changes only minimum macOS 14 to 13. Validation normalizes that gate to 14 and requires baseline SHA256 `4d428f594dac14eff64ed87b172c81ecf85ac91da8c5460cd6ec4b1d310800c3`. Architecture, UID, services, disk size/writability/uniqueness, retries, proof bounds, timers and native-wait/cleanup/flush metrics remain identical. Limits stay 45 seconds per native command, 180 seconds for UID, ten minutes disk readiness, 40 minutes host and 45 minutes workflow. -Evidence is written under the requested output directory: run identity and candidate commit, Docker/runner resources, exact source patch artifacts and hashes, image/container inspection, Recovery hash, native platform/process/launchctl/diskutil logs, machine-readable guest result, outcome and cleanup receipt. `guest-launch.sh` records the wrapper and its original `recoveryosd` exec path; `guest-readiness.sh` records the separate token-bound probe; `image.sh.patched` records the copy/comparison seam. `source-hashes.json` distinguishes all three. These source artifacts alone do not prove that Apple's executable actually ran. The workflow retains these as a seven-day artifact. Phase names and up to 512 KiB of the final native proof also appear in CI stdout, on success or failure, with the run token replaced; no environment or credential dump is printed. A Docker start/build exit zero is not a successful native result. A missing, stale, unsupported-platform, read-only or wrong-size guest receipt fails. +Container profile: `KVM=Y`, `CPU_MODEL=host`, `VERSION=13`, 4-GiB guest, two guest/host CPUs, 6-GiB memory/swap and 512-MiB shared memory. Fresh anonymous `/storage` holds the 64-GiB disk; evidence reads `/storage/13/setup.dmg`. Existing resource budget checks remain. -While Recovery readiness is pending, a minute heartbeat reports elapsed guest time and the container's running state. Before final cleanup, an optional ten-second capture rechecks the saved container ID/ownership label and uses the pinned image's existing Unix HMP socket, `nc.openbsd` and a five-second `timeout` to collect only [`info status` and `screendump`](https://www.qemu.org/docs/master/system/monitor.html), retaining the command transcript, exit codes and fresh bounded PPM screenshot. Capture failure is visible and never changes native readiness success. +Only device mapping: exactly `/dev/kvm:/dev/kvm:rw`. Inspection rejects other devices/permissions, added capabilities, device requests/rules, binds, tmpfs overrides, published ports, host networking, privileged mode, wrong limits, unexpected persistent mounts or changed CPU/OS profile. No host modules, infrastructure, secrets, SSH or app lifecycle actions are involved. Guest slirp networking remains. -Run 4159 generated a 6,220,817-byte screenshot file under `/dev/shm`, but `docker cp` could not retrieve it. Screenshots now use the regular container path `/tmp/native-diagnostic-screen-.ppm`, avoiding Docker's documented [`/dev`/tmpfs copy limitation](https://docs.docker.com/reference/cli/docker/container/cp/#corner-cases). +Evidence retains run/profile identity, source/assets, EFI staging, container/resources, macOS 13 Recovery hash, native proof/result/outcome and cleanup. Optional final Unix HMP capture includes `info kvm`, `info status` and a bounded PPM exported from `/tmp`; capture success passes no native gate. -Every container/image has a random run token in its ownership label. `finally` cleanup and the workflow's `always()` step inspect that exact label before removing the matching container and its anonymous storage volume, then the matching image. They never remove an unrelated name or volume, prune Docker, modify host settings or restart Meeting Assistant. Temporary source files are deleted only when their local marker matches the same token. Evidence remains available after cleanup. - -The earlier background-only local bootstrap never obtained DiskManagement readiness. This separate LaunchDaemon probe is still an experiment until the actual remote run produces the required native evidence. Full macOS CI support remains unverified until an installed guest subsequently compiles/signs the native helpers and passes all application tests, including all five native tests without skips. - -Remote run 4152 passed Docker access and resource checks but failed before VM startup: the runner's BuildKit could not checksum a dangling `/etc/alternatives/awk.1.gz` link while copying the entire QEMU filesystem. The candidate now derives directly from the same pinned QEMU filesystem image and overwrites its QEMU executable as before. Inspection of that exact digest reports an empty image `Config`, so it adds no inherited environment, user, command or healthcheck. Actual run 4155 built that image and started QEMU/XNU successfully, then failed the first native `sw_vers` after its 45-second watchdog. It did not prove native readiness. +Both cleanup paths keep exact token/label/ID checks. `docker rm --force --volumes` removes only the owned container and anonymous volume, then its exact image; no unrelated objects or pruning. Evidence stays seven days. Full native CI still needs a subsequent actual installed remote guest to build/sign helpers and pass the full suite, including five native tests without skips. diff --git a/tools/ci/MacOsNativeDiagnostic.cs b/tools/ci/MacOsNativeDiagnostic.cs index 08d968d..6e3bbd5 100644 --- a/tools/ci/MacOsNativeDiagnostic.cs +++ b/tools/ci/MacOsNativeDiagnostic.cs @@ -1,5 +1,6 @@ #:property PublishAot=false using System.Diagnostics; +using System.IO.Compression; using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Text; @@ -12,6 +13,9 @@ return await NativeDiagnostic.Execute(args); static class NativeDiagnostic { const string DockurCommit = "16a5b470cdd601bae8b05b02d748d7edfb36c12e"; + const string CryptexUrl = "https://github.com/acidanthera/CryptexFixup/releases/download/1.0.5/CryptexFixup-1.0.5-RELEASE.zip"; + const string CryptexHash = "25041d94a0fe9a0261caf0ba89b36dfcb21682bf3c697a34bcaddc839576ab30"; + const string OpenCoreTemplateHash = "287328995d4198f1b05166f087d85bf7ef66bedafe150d17ad112ac8de60051d"; const string OwnerLabel = "org.meeting-assistant.native-diagnostic"; const long GuestDiskBytes = 64L * 1024 * 1024 * 1024; const long ContainerMemoryBytes = 6L * 1024 * 1024 * 1024; @@ -47,7 +51,7 @@ static class NativeDiagnostic { if (args.Length == 0 || args.Contains("--help")) { - Console.WriteLine("dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --run|--cleanup|--validate [--output artifacts/native-macos] [--source existing-dockur-clone]"); + Console.WriteLine("dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --run|--cleanup|--validate [--output artifacts/native-macos] [--source existing-dockur-clone] [--cryptex-archive verified-release.zip]"); return 0; } var output = Path.GetFullPath(Option(args, "--output") ?? "artifacts/native-macos"); @@ -55,7 +59,10 @@ static class NativeDiagnostic { ValidateContracts(); if (Option(args, "--source") is { } source) - await PrepareSource(Path.GetFullPath(source), output, "validation", false, CancellationToken.None); + { + await PrepareSource(Path.GetFullPath(source), output, "validation", false, Option(args, "--cryptex-archive"), CancellationToken.None); + Save(Path.Combine(output, "validation.json"), new { success = true, profile = "kvm-host-ventura-cryptex", helperSha256 = Hash(File.ReadAllBytes(Path.Combine("tools", "ci", "MacOsNativeDiagnostic.cs"))), baselineReadinessNormalized = true, resultNegativeCases = 6, containerNegativeCases = 11, cryptexArchiveVerified = true, configurationAndStagingContractsVerified = true, templateIsoDownloaded = false, activeLiluRuntimeChecked = false, sourceModified = false, dockerExecuted = false, guestExecuted = false, completedUtc = DateTimeOffset.UtcNow }); + } Console.WriteLine("Source patch contracts and diagnostic result validation passed; no Docker or guest execution occurred."); return 0; } @@ -83,7 +90,7 @@ static class NativeDiagnostic throw new InvalidOperationException("This diagnostic runs on the existing Linux/x64 runner only."); ValidateContracts(); var sourceCommit = (await Command("git", ["rev-parse", "HEAD"], output, "candidate-commit", deadline.Token)).Output.Trim(); - Save(Path.Combine(output, "run-metadata.json"), new { token, startedUtc = DateTimeOffset.UtcNow, sourceCommit, dockurCommit = DockurCommit, runId = Environment.GetEnvironmentVariable("GITHUB_RUN_ID"), server = Environment.GetEnvironmentVariable("GITHUB_SERVER_URL"), architecture = RuntimeInformation.ProcessArchitecture.ToString(), deadlineMinutes = 40 }); + Save(Path.Combine(output, "run-metadata.json"), new { token, startedUtc = DateTimeOffset.UtcNow, sourceCommit, dockurCommit = DockurCommit, profile = "kvm-host-ventura-cryptex", causalSingleVariableTest = false, kvm = true, cpuModel = "host", recoveryMajor = 13, cryptexVersion = "1.0.5", liluVersion = "1.7.1", runId = Environment.GetEnvironmentVariable("GITHUB_RUN_ID"), server = Environment.GetEnvironmentVariable("GITHUB_SERVER_URL"), architecture = RuntimeInformation.ProcessArchitecture.ToString(), deadlineMinutes = 40 }); var info = await Command("docker", ["info", "--format", "{{json .}}"], output, "docker-info", deadline.Token); using (var document = JsonDocument.Parse(info.Output)) { @@ -102,13 +109,13 @@ static class NativeDiagnostic await Command("git", ["-C", source, "checkout", "--detach", DockurCommit], output, "dockur-checkout", deadline.Token); var actualCommit = (await Command("git", ["-C", source, "rev-parse", "HEAD"], output, "dockur-commit", deadline.Token)).Output.Trim(); if (actualCommit != DockurCommit) throw new InvalidOperationException("Dockur source pin mismatch."); - await PrepareSource(source, output, token, true, deadline.Token); + await PrepareSource(source, output, token, true, Option(args, "--cryptex-archive"), deadline.Token); await Command("docker", ["build", "--platform", "linux/amd64", "--label", OwnerLabel + "=" + token, "--tag", state.ImageTag, source], output, "docker-build", deadline.Token, echo: true); var imageInspect = await Command("docker", ["image", "inspect", state.ImageTag], output, "image-inspect", deadline.Token); using (var image = JsonDocument.Parse(imageInspect.Output)) state = state with { ImageId = image.RootElement[0].GetProperty("Id").GetString() }; Save(statePath, state); - var create = await Command("docker", ["create", "--name", state.ContainerName, "--label", OwnerLabel + "=" + token, "--memory", "6g", "--memory-swap", "6g", "--cpus", "2", "--shm-size", "512m", "--log-opt", "max-size=8m", "--log-opt", "max-file=1", "--env", "KVM=N", "--env", "NETWORK=slirp", "--env", "DISPLAY=web", "--env", "MANUAL=N", "--env", "VERSION=14", "--env", "RAM_SIZE=4G", "--env", "CPU_CORES=2", "--env", "DISK_SIZE=64G", "--env", "DISK_TYPE=sata", "--env", "ARGUMENTS=-object iothread,id=io2", state.ImageTag], output, "docker-create", deadline.Token); + var create = await Command("docker", ["create", "--name", state.ContainerName, "--label", OwnerLabel + "=" + token, "--memory", "6g", "--memory-swap", "6g", "--cpus", "2", "--shm-size", "512m", "--log-opt", "max-size=8m", "--log-opt", "max-file=1", "--device", "/dev/kvm:/dev/kvm:rw", "--env", "KVM=Y", "--env", "CPU_MODEL=host", "--env", "NETWORK=slirp", "--env", "DISPLAY=web", "--env", "MANUAL=N", "--env", "VERSION=13", "--env", "RAM_SIZE=4G", "--env", "CPU_CORES=2", "--env", "DISK_SIZE=64G", "--env", "DISK_TYPE=sata", "--env", "ARGUMENTS=-object iothread,id=io2", state.ImageTag], output, "docker-create", deadline.Token); var id = create.Output.Trim(); if (!System.Text.RegularExpressions.Regex.IsMatch(id, "^[0-9a-f]{64}$")) throw new InvalidOperationException("Docker did not return a container identity."); state = state with { ContainerId = id }; @@ -116,7 +123,7 @@ static class NativeDiagnostic await Command("docker", ["inspect", id], output, "container-created", deadline.Token); AssertContainer(File.ReadAllText(Path.Combine(output, "container-created.stdout.log")), token); await Command("docker", ["start", id], output, "docker-start", deadline.Token); - Console.WriteLine("The owned unprivileged TCG guest is starting. Success requires native macOS 14+/x86_64 and a writable 64-GiB disk; no installer will run."); + Console.WriteLine("The owned restricted KVM/host-CPU macOS 13 compatibility guest is starting. Success requires native macOS 13+/x86_64 and a writable 64-GiB disk; no installer will run. This is not a single-variable causal test."); var recoveryStarted = Stopwatch.StartNew(); var heartbeat = Stopwatch.StartNew(); while (true) @@ -168,20 +175,33 @@ static class NativeDiagnostic static void ValidateContracts() { - if (Hash(File.ReadAllBytes(Path.Combine("tools", "ci", "macos-native-readiness.sh"))) != "4d428f594dac14eff64ed87b172c81ecf85ac91da8c5460cd6ec4b1d310800c3") - throw new InvalidOperationException("Bootstrap A/B requires the unchanged 40281b readiness probe and limits."); + var readiness = File.ReadAllText(Path.Combine("tools", "ci", "macos-native-readiness.sh")); + var baseline = ReplaceOnce(readiness, "(( ${os_version%%.*} >= 13 ))", "(( ${os_version%%.*} >= 14 ))"); + if (Hash(Encoding.UTF8.GetBytes(baseline)) != "4d428f594dac14eff64ed87b172c81ecf85ac91da8c5460cd6ec4b1d310800c3") + throw new InvalidOperationException("Compatibility readiness may change only the baseline's macOS minimum to 13; identity, services, disk and limits must remain identical."); + if (Hash(File.ReadAllBytes(Path.Combine("tools", "ci", "macos-native-bootstrap.sh"))) != "94f069e116fdc7685a4d233cab6fa50df9f39274386bb82157674061e74fadb5") + throw new InvalidOperationException("Compatibility profile must preserve the baseline Apple recoveryosd wrapper."); XDocument.Parse(DiagnosticDaemon); if (Encoding.UTF8.GetByteCount(DiagnosticDaemon) > Encoding.UTF8.GetByteCount(OriginalDaemon + "\n")) throw new InvalidOperationException("Daemon replacement exceeds original file."); - var good = JsonSerializer.Serialize(new { token = "validation", success = true, osVersion = "14.6.1", architecture = "x86_64", uid = 0, disk = "/dev/disk1", diskBytes = GuestDiskBytes, readOnly = false, systemExit = 0, diskArbitrationExit = 0, recoveryExit = 0, diskListExit = 0 }); + var good = JsonSerializer.Serialize(new { token = "validation", success = true, osVersion = "13.6.1", architecture = "x86_64", uid = 0, disk = "/dev/disk1", diskBytes = GuestDiskBytes, readOnly = false, systemExit = 0, diskArbitrationExit = 0, recoveryExit = 0, diskListExit = 0 }); ValidateResult(good, "validation"); - foreach (var invalid in new[] { good.Replace("14.6.1", "13.6.1"), good.Replace("x86_64", "arm64"), good.Replace("\"readOnly\":false", "\"readOnly\":true"), good.Replace("\"success\":true", "\"success\":false"), good.Replace("68719476736", "17179869184"), good.Replace("validation", "stale") }) + foreach (var invalid in new[] { good.Replace("13.6.1", "12.6.1"), good.Replace("x86_64", "arm64"), good.Replace("\"readOnly\":false", "\"readOnly\":true"), good.Replace("\"success\":true", "\"success\":false"), good.Replace("68719476736", "17179869184"), good.Replace("validation", "stale") }) { try { ValidateResult(invalid, "validation"); } catch (InvalidOperationException) { continue; } throw new InvalidOperationException("Diagnostic validator accepted an invalid/stale result."); } + var boundary = """ + [{"Config":{"Labels":{"org.meeting-assistant.native-diagnostic":"validation"},"Env":["KVM=Y","CPU_MODEL=host","VERSION=13"]},"HostConfig":{"Privileged":false,"NetworkMode":"default","Memory":6442450944,"MemorySwap":6442450944,"NanoCpus":2000000000,"ShmSize":536870912,"CapAdd":null,"DeviceRequests":null,"Binds":null,"PortBindings":{},"DeviceCgroupRules":null,"Tmpfs":null,"Devices":[{"PathOnHost":"/dev/kvm","PathInContainer":"/dev/kvm","CgroupPermissions":"rw"}]},"Mounts":[{"Type":"volume","Destination":"/storage","RW":true}]}] + """; + AssertContainer(boundary, "validation"); + foreach (var invalid in new[] { boundary.Replace("\"Privileged\":false", "\"Privileged\":true"), boundary.Replace("\"CgroupPermissions\":\"rw\"", "\"CgroupPermissions\":\"rwm\""), boundary.Replace("/dev/kvm", "/dev/other"), boundary.Replace("KVM=Y", "KVM=N"), boundary.Replace("CPU_MODEL=host", "CPU_MODEL=Skylake-Client-v4"), boundary.Replace("VERSION=13", "VERSION=14"), boundary.Replace("6442450944", "8589934592"), boundary.Replace("\"NetworkMode\":\"default\"", "\"NetworkMode\":\"host\""), boundary.Replace("\"CapAdd\":null", "\"CapAdd\":[\"NET_ADMIN\"]"), boundary.Replace("\"Type\":\"volume\"", "\"Type\":\"bind\""), boundary.Replace("/storage", "/host") }) + { + try { AssertContainer(invalid, "validation"); } catch (InvalidOperationException) { continue; } + throw new InvalidOperationException("Diagnostic validator accepted an excessive/wrong-profile container boundary."); + } } - static async Task PrepareSource(string source, string output, string token, bool writeSource, CancellationToken cancellation) + static async Task PrepareSource(string source, string output, string token, bool writeSource, string? cryptexArchive, CancellationToken cancellation) { Directory.CreateDirectory(output); var patchPath = Path.Combine(source, "src/install/recovery/patch.py"); @@ -193,12 +213,17 @@ static class NativeDiagnostic var constants = "RECOVERY_ORIGINAL = b'''" + daemon + "'''\nRECOVERY_REPLACEMENT = b'''" + DiagnosticDaemon + "'''.ljust(len(RECOVERY_ORIGINAL), b\" \")"; patch = ReplaceOnce(patch, oldConstants, constants); var dockerPath = Path.Combine(source, "Dockerfile"); + if (Hash(File.ReadAllBytes(dockerPath)) != "a0e804235967400eb70e755d63eff8a33a7761922ddd6e9723faa8e828fd8aa3") throw new InvalidOperationException("Pinned Dockerfile hash mismatch."); // The existing runner's BuildKit cannot checksum dangling manpage links during COPY /. // This pinned filesystem image has an empty Config; FROM preserves the same runtime defaults. var dockerfile = ReplaceOnce(File.ReadAllText(dockerPath), "FROM scratch AS base\nCOPY --from=qemux/qemu:7.50 --exclude=usr/bin/qemu-system-x86_64 / /\n", "FROM qemux/qemu:7.50@sha256:e7f6fda52503a546fd649670ba46e4bc23dc6dcef275bc3fac48877fbbc430df AS base\n"); dockerfile = ReplaceAllExact(dockerfile, "--from=qemux/qemu-macos:latest ", "--from=qemux/qemu-macos:latest@sha256:af64297171228f27d5f616249e18f6ad5e2fbc79c1cc517252521e8bcd8eadaa ", 2); + dockerfile = ReplaceOnce(dockerfile, "ADD $REPO_KVM_OPENCORE/releases/download/v$VERSION_KVM_OPENCORE/LongQT-OpenCore-v$VERSION_KVM_OPENCORE.iso /opencore.iso", "ADD --checksum=sha256:" + OpenCoreTemplateHash + " $REPO_KVM_OPENCORE/releases/download/v$VERSION_KVM_OPENCORE/LongQT-OpenCore-v$VERSION_KVM_OPENCORE.iso /opencore.iso"); + var compatibility = await PrepareCompatibility(source, output, cryptexArchive, cancellation); var entryPath = Path.Combine(source, "src/entry.sh"); var entry = ReplaceOnce(File.ReadAllText(entryPath), "set -Eeuo pipefail\n", "set -Eeuo pipefail\n\n# Diagnostic budget: inspect existing Docker storage before Recovery download/boot.\ndf -Pk /storage\nfree_kib=$(df -Pk /storage | awk 'NR==2 {print $4}')\n[[ \"$free_kib\" =~ ^[0-9]+$ ]] && (( free_kib >= 8 * 1024 * 1024 )) || { echo 'Existing Docker storage has less than the 8-GiB diagnostic budget.' >&2; exit 1; }\n"); + entry = ReplaceOnce(entry, ". init.sh # Initialize system\n", ". init.sh # Initialize system\n# Fail before Apple downloads if the existing daemon cannot retain this profile.\nenabled \"$KVM\" && [[ \"$CPU_MODEL\" == host && \"$VERSION\" == 13 ]] && grep -Eq '^vendor_id[[:space:]]*:[[:space:]]*GenuineIntel$' /proc/cpuinfo || { error 'Compatibility probe requires existing Intel KVM and the exact host/13 profile.'; exit 1; }\n"); + entry = ReplaceOnce(entry, "trap - ERR\n", "[[ \"$KVM_OPTS\" == *'accel=kvm'* || \"$KVM_OPTS\" == *'-accel kvm'* ]] && [[ \"$KVM_OPTS\" != *tcg* && \"$CPU_MODEL\" == host ]] || { error 'Compatibility profile refuses a TCG/CPU fallback.'; exit 1; }\ninfo '[compatibility-profile] accelerator=kvm cpu=host recovery=13; actual guest gates still pending'\n\ntrap - ERR\n"); var hookPath = Path.Combine("tools", "ci", "macos-native-readiness.sh"); var hook = ReplaceOnce(File.ReadAllText(hookPath), "@@PROOF_TOKEN@@", token); var wrapper = File.ReadAllText(Path.Combine("tools", "ci", "macos-native-bootstrap.sh")); @@ -207,13 +232,14 @@ static class NativeDiagnostic if (Hash(Encoding.UTF8.GetBytes(originalImage)) != "c08bf9436fb8b72ea82fdf0e677641ab2fc42a0a59e2cf0309c00df519884c5c") throw new InvalidOperationException("Pinned Recovery staging script hash mismatch."); var image = ReplaceOnce(originalImage, " if ! cp -f \"$IMAGE_TOOLS/recovery/launch.sh\" \"$script\"; then\n", " if ! cp -f \"$IMAGE_TOOLS/recovery/launch.sh\" \"$script\" ||\n ! cp -f \"$IMAGE_TOOLS/recovery/readiness.sh\" \"${script%/*}/readiness.sh\"; then\n"); image = ReplaceOnce(image, " if ! cmp -s \"$IMAGE_TOOLS/recovery/launch.sh\" \"$script\" ||\n", " if ! cmp -s \"$IMAGE_TOOLS/recovery/launch.sh\" \"$script\" ||\n ! cmp -s \"$IMAGE_TOOLS/recovery/readiness.sh\" \"$state/readiness.sh\" ||\n"); - foreach (var pair in new[] { ("recovery-patch.py", patch), ("Dockerfile.patched", dockerfile), ("container-entry.sh", entry), ("guest-launch.sh", wrapper), ("guest-readiness.sh", hook), ("image.sh.patched", image), ("recoveryosd-original.plist", daemon), ("recoveryosd-diagnostic.plist", DiagnosticDaemon), ("early-bootstrap.sh", MountOnlyBootstrap) }) + foreach (var pair in new[] { ("recovery-patch.py", patch), ("Dockerfile.patched", dockerfile), ("container-entry.sh", entry), ("guest-launch.sh", wrapper), ("guest-readiness.sh", hook), ("image.sh.patched", image), ("recoveryosd-original.plist", daemon), ("recoveryosd-diagnostic.plist", DiagnosticDaemon), ("early-bootstrap.sh", MountOnlyBootstrap), ("boot.sh.patched", compatibility.Boot), ("opencore-config.plist", compatibility.Config) }) File.WriteAllText(Path.Combine(output, pair.Item1), pair.Item2, new UTF8Encoding(false)); - Save(Path.Combine(output, "source-hashes.json"), Directory.GetFiles(output).Where(path => Path.GetFileName(path) is "recovery-patch.py" or "Dockerfile.patched" or "container-entry.sh" or "guest-launch.sh" or "guest-readiness.sh" or "image.sh.patched" or "recoveryosd-original.plist" or "recoveryosd-diagnostic.plist" or "early-bootstrap.sh").ToDictionary(path => Path.GetFileName(path)!, path => Hash(File.ReadAllBytes(path)))); + Save(Path.Combine(output, "source-hashes.json"), Directory.GetFiles(output).Where(path => Path.GetFileName(path) is "recovery-patch.py" or "Dockerfile.patched" or "container-entry.sh" or "guest-launch.sh" or "guest-readiness.sh" or "image.sh.patched" or "recoveryosd-original.plist" or "recoveryosd-diagnostic.plist" or "early-bootstrap.sh" or "boot.sh.patched" or "opencore-config.plist" or "compatibility-boot-assets.json").ToDictionary(path => Path.GetFileName(path)!, path => Hash(File.ReadAllBytes(path)))); await Command("bash", ["-n", Path.Combine(output, "guest-launch.sh")], output, "guest-hook-syntax", cancellation); await Command("bash", ["-n", Path.Combine(output, "guest-readiness.sh")], output, "guest-readiness-syntax", cancellation); await Command("bash", ["-n", Path.Combine(output, "image.sh.patched")], output, "guest-staging-syntax", cancellation); await Command("bash", ["-n", Path.Combine(output, "container-entry.sh")], output, "entry-syntax", cancellation); + await Command("bash", ["-n", Path.Combine(output, "boot.sh.patched")], output, "boot-staging-syntax", cancellation); if (!writeSource) return; File.WriteAllText(patchPath, patch, new UTF8Encoding(false)); File.WriteAllText(dockerPath, dockerfile, new UTF8Encoding(false)); @@ -221,8 +247,101 @@ static class NativeDiagnostic File.WriteAllText(imagePath, image, new UTF8Encoding(false)); File.WriteAllText(Path.Combine(source, "src/install/recovery/launch.sh"), wrapper, new UTF8Encoding(false)); File.WriteAllText(Path.Combine(source, "src/install/recovery/readiness.sh"), hook, new UTF8Encoding(false)); + File.WriteAllText(Path.Combine(source, "src/boot.sh"), compatibility.Boot, new UTF8Encoding(false)); + File.WriteAllText(Path.Combine(source, "assets/config.plist"), compatibility.Config, new UTF8Encoding(false)); + var target = Path.Combine(source, "assets", "native-compatibility"); + if (Directory.Exists(target)) throw new InvalidOperationException("Refusing an existing compatibility asset overlay."); + foreach (var file in Directory.GetFiles(compatibility.Assets, "*", SearchOption.AllDirectories)) + { + var destination = Path.Combine(target, Path.GetRelativePath(compatibility.Assets, file)); + Directory.CreateDirectory(Path.GetDirectoryName(destination)!); + File.Copy(file, destination, false); + } } + static async Task<(string Boot, string Config, string Assets)> PrepareCompatibility(string source, string output, string? archivePath, CancellationToken cancellation) + { + var boot = File.ReadAllText(Path.Combine(source, "src", "boot.sh")); + var config = File.ReadAllText(Path.Combine(source, "assets", "config.plist")); + if (Hash(Encoding.UTF8.GetBytes(boot)) != "82b56525707a8f586e040f56108b5034c02e7fecfea071f1857e596cba10cbed" || Hash(Encoding.UTF8.GetBytes(config)) != "3b0ec58b693cfa0fadf3e3f952486e87af8c27d504f9545d1e90ae2dc3777096") throw new InvalidOperationException("Pinned OpenCore staging/config hashes mismatch."); + byte[] bytes; + if (archivePath is not null) bytes = await File.ReadAllBytesAsync(archivePath, cancellation); + else + { + using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(30), MaxResponseContentBufferSize = 2 * 1024 * 1024 }; + bytes = await client.GetByteArrayAsync(CryptexUrl, cancellation); + } + if (bytes.Length != 69703 || Hash(bytes) != CryptexHash) throw new InvalidOperationException("Official CryptexFixup release size/hash mismatch."); + File.WriteAllBytes(Path.Combine(output, "CryptexFixup-1.0.5-RELEASE.zip"), bytes); + var assets = Path.Combine(output, "compatibility-assets"); + if (Directory.Exists(assets)) throw new InvalidOperationException("Compatibility validation requires a fresh output directory."); + Directory.CreateDirectory(assets); + using var archive = new ZipArchive(new MemoryStream(bytes), ZipArchiveMode.Read); + var required = new[] { "CryptexFixup.kext/Contents/Info.plist", "CryptexFixup.kext/Contents/MacOS/CryptexFixup" }; + var entries = archive.Entries.Where(entry => entry.FullName.StartsWith("CryptexFixup.kext/", StringComparison.Ordinal) && !entry.FullName.EndsWith('/')).ToArray(); + if (entries.Length != 2 || required.Any(name => entries.Count(entry => entry.FullName == name) != 1)) throw new InvalidOperationException("Cryptex bundle has an unexpected file layout."); + foreach (var entry in entries) + { + if (entry.Length <= 0 || entry.Length > 1024 * 1024) throw new InvalidOperationException("Cryptex bundle file exceeded the staging bound."); + var destination = Path.Combine(assets, entry.FullName); + Directory.CreateDirectory(Path.GetDirectoryName(destination)!); + entry.ExtractToFile(destination, false); + } + var info = XDocument.Load(Path.Combine(assets, required[0])).Root!.Element("dict")!; + if (PlistValue(info, "CFBundleIdentifier").Value != "com.khronokernel.CryptexFixup" || PlistValue(info, "CFBundleVersion").Value != "1.0.5" || PlistValue(info, "CFBundleExecutable").Value != "CryptexFixup" || PlistValue(PlistValue(info, "OSBundleLibraries"), "as.vit9696.Lilu").Value != "1.4.7") throw new InvalidOperationException("Cryptex bundle identity/version/Lilu dependency mismatch."); + var fileHashes = required.ToDictionary(name => name, name => Hash(File.ReadAllBytes(Path.Combine(assets, name)))); + File.WriteAllText(Path.Combine(assets, "SHA256SUMS"), string.Concat(fileHashes.Select(pair => pair.Value + " " + pair.Key + "\n")), new UTF8Encoding(false)); + Save(Path.Combine(output, "compatibility-boot-assets.json"), new { cryptexUrl = CryptexUrl, cryptexSha256 = CryptexHash, cryptexBytes = bytes.Length, cryptexFiles = fileHashes, templateUrl = "https://github.com/LongQT-sea/OpenCore-ISO/releases/download/v0.7/LongQT-OpenCore-v0.7.iso", templateSha256 = OpenCoreTemplateHash, templateBytes = 15884288, liluVersion = "1.7.1", liluBinarySha256 = "0c016d93cfe40c7fa3965813175c1b991a76f3d295efd5be66ae712b4a3ffb52", liluBinaryBytes = 526984, liluInfoSha256 = "fc885f3319f326e3af60e7965a5216b671772d39d40993ec695758bb43d6ea3a", causalSingleVariableTest = false }); + var document = XDocument.Parse(config, LoadOptions.PreserveWhitespace); + var add = PlistValue(PlistValue(document.Root!.Element("dict")!, "Kernel"), "Add"); + var expected = new[] { "Lilu.kext", "VMHide.kext", "VirtualSMC.kext", "WhateverGreen.kext", "VoodooPS2Controller.kext", "VoodooPS2Controller.kext/Contents/PlugIns/VoodooPS2Keyboard.kext", "AppleMCEReporterDisabler.kext" }; + if (!add.Elements("dict").Select(dict => PlistValue(dict, "BundlePath").Value).SequenceEqual(expected) || add.Elements("dict").Any(dict => PlistValue(dict, "Enabled").Name != "true")) throw new InvalidOperationException("Pinned Kernel.Add order/enabled contract mismatch."); + var cryptex = XElement.Parse("Archx86_64BundlePathCryptexFixup.kextCommentOfficial CryptexFixup 1.0.5; owned compatibility guest onlyEnabledExecutablePathContents/MacOS/CryptexFixupMaxKernelMinKernel22.0.0PlistPathContents/Info.plist"); + add.Elements("dict").First().AddAfterSelf(cryptex); + var bootArguments = PlistValue(PlistValue(PlistValue(PlistValue(document.Root.Element("dict")!, "NVRAM"), "Add"), "7C436110-AB2A-4BBB-A880-FE41995C9F82"), "boot-args").Value.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (bootArguments.Intersect(new[] { "-cryptoff", "-liluoff", "-crypt_allow_hash_validation", "-crypt_force_avx", "-cryptbeta", "-lilubetaall" }).Any()) throw new InvalidOperationException("Unexpected Cryptex/Lilu disabling or forcing boot argument."); + boot = ReplaceOnce(boot, " cp -a \"$template/OC/Resources\" \"$EFI_DIR/OC/\"\n", " cp -a \"$template/OC/Resources\" \"$EFI_DIR/OC/\"\n" + CompatibilityStaging + "\n"); + boot = ReplaceOnce(boot, " PLIST=\"/assets/config.plist\"\n", " [ ! -e /custom.plist ] || { error 'Compatibility profile refuses an unverified custom OpenCore config!'; exit 12; }\n PLIST=\"/assets/config.plist\"\n"); + boot = ReplaceOnce(boot, " checkOpenCoreConfig\n addVmHideKext\n", " checkOpenCoreConfig\n" + CompatibilityConfigCheck + "\n addVmHideKext\n"); + boot = ReplaceOnce(boot, " if [ -s \"$target\" ] && [ \"$previous\" = \"$current\" ]; then\n IMG=\"$target\"\n return 0\n fi\n", " # This owned compatibility probe always rebuilds; never trust a cached boot.img.\n"); + boot = ReplaceOnce(boot, " echo \"VMHIDE=$vmhide\"\n", " echo \"VMHIDE=$vmhide\"\n echo \"COMPATIBILITY=kvm-host-ventura-cryptex\"\n sha256sum /assets/native-compatibility/SHA256SUMS\n"); + return (boot, document.ToString(), assets); + } + + static XElement PlistValue(XElement dictionary, string key) + { + var keys = dictionary.Elements("key").Where(element => element.Value == key).ToArray(); + if (keys.Length != 1 || keys[0].ElementsAfterSelf().FirstOrDefault() is not { } value) throw new InvalidOperationException("Missing/duplicate plist key: " + key); + return value; + } + + const string CompatibilityStaging = """ + # Only the freshly extracted, owned guest EFI is changed; never the host. + local lilu="$EFI_DIR/OC/Kexts/Lilu.kext/Contents" + printf '%s %s\n' \ + fc885f3319f326e3af60e7965a5216b671772d39d40993ec695758bb43d6ea3a "$lilu/Info.plist" \ + 0c016d93cfe40c7fa3965813175c1b991a76f3d295efd5be66ae712b4a3ffb52 "$lilu/MacOS/Lilu" | sha256sum -c - || { error "Pinned active Lilu files mismatch!"; exit 12; } + [ "$(xmlstarlet sel -T -t -v '/plist/dict/key[.="CFBundleVersion"]/following-sibling::string[1]' "$lilu/Info.plist")" = 1.7.1 ] || { error "Active Lilu version mismatch!"; exit 12; } + [ ! -e "$EFI_DIR/OC/Kexts/CryptexFixup.kext" ] || { error "Unexpected pre-existing Cryptex kext!"; exit 12; } + (cd /assets/native-compatibility && sha256sum -c SHA256SUMS) || { error "Pinned Cryptex staging files mismatch!"; exit 12; } + cp -a /assets/native-compatibility/CryptexFixup.kext "$EFI_DIR/OC/Kexts/" + (cd "$EFI_DIR/OC/Kexts" && sha256sum -c /assets/native-compatibility/SHA256SUMS) || { error "Active Cryptex copy mismatch!"; exit 12; } + info "[compatibility-boot] Lilu=1.7.1 CryptexFixup=1.0.5 files=verified; guest injection and Recovery readiness remain unproved" + """; + + const string CompatibilityConfigCheck = """ + local kernel='/plist/dict/key[.="Kernel"]/following-sibling::dict[1]/key[.="Add"]/following-sibling::array[1]' + local actual expected + actual=$(xmlstarlet sel -T -t -m "$kernel/dict" -v 'key[.="BundlePath"]/following-sibling::string[1]' -n "$CFG") || exit 12 + expected=$(printf '%s\n' Lilu.kext CryptexFixup.kext VMHide.kext VirtualSMC.kext WhateverGreen.kext VoodooPS2Controller.kext VoodooPS2Controller.kext/Contents/PlugIns/VoodooPS2Keyboard.kext AppleMCEReporterDisabler.kext) + [ "$actual" = "$expected" ] || { error "Active Kernel.Add order mismatch!"; exit 12; } + [ "$(xmlstarlet sel -T -t -v "name($kernel/dict[1]/key[.='Enabled']/following-sibling::*[1])" -v "name($kernel/dict[2]/key[.='Enabled']/following-sibling::*[1])" "$CFG")" = truetrue ] || { error "Active Lilu/Cryptex must both be enabled!"; exit 12; } + actual=$(xmlstarlet sel -T -t -m "$kernel/dict[2]" -v 'key[.="Arch"]/following-sibling::string[1]' -n -v 'key[.="ExecutablePath"]/following-sibling::string[1]' -n -v 'key[.="PlistPath"]/following-sibling::string[1]' -n -v 'key[.="MinKernel"]/following-sibling::string[1]' -n -v 'key[.="MaxKernel"]/following-sibling::string[1]' "$CFG") || exit 12 + expected=$(printf '%s\n' x86_64 Contents/MacOS/CryptexFixup Contents/Info.plist 22.0.0 '') + [ "$actual" = "$expected" ] || { error "Active Cryptex Kernel.Add paths/architecture/Darwin bounds mismatch!"; exit 12; } + info "[compatibility-config] Kernel.Add=Lilu,CryptexFixup before remaining baseline kexts; MinKernel=22.0.0 MaxKernel=empty" + """; + static string ReplaceOnce(string text, string oldValue, string newValue) => ReplaceAllExact(text, oldValue, newValue, 1); static string ReplaceAllExact(string text, string oldValue, string newValue, int expected) { @@ -235,8 +354,8 @@ static class NativeDiagnostic { using var document = JsonDocument.Parse(json); var result = document.RootElement; - if (result.GetProperty("token").GetString() != token || !result.GetProperty("success").GetBoolean() || !Version.TryParse(result.GetProperty("osVersion").GetString(), out var version) || version.Major < 14 || result.GetProperty("architecture").GetString() != "x86_64" || result.GetProperty("uid").GetInt32() != 0 || !System.Text.RegularExpressions.Regex.IsMatch(result.GetProperty("disk").GetString() ?? "", "^/dev/disk[0-9]+$") || result.GetProperty("diskBytes").GetInt64() != GuestDiskBytes || result.GetProperty("readOnly").GetBoolean() || new[] { "systemExit", "diskArbitrationExit", "recoveryExit", "diskListExit" }.Any(key => result.GetProperty(key).GetInt32() != 0)) - throw new InvalidOperationException("The fresh guest receipt did not prove native macOS 14+/x86_64, service readiness and the writable 64-GiB disk."); + if (result.GetProperty("token").GetString() != token || !result.GetProperty("success").GetBoolean() || !Version.TryParse(result.GetProperty("osVersion").GetString(), out var version) || version.Major < 13 || result.GetProperty("architecture").GetString() != "x86_64" || result.GetProperty("uid").GetInt32() != 0 || !System.Text.RegularExpressions.Regex.IsMatch(result.GetProperty("disk").GetString() ?? "", "^/dev/disk[0-9]+$") || result.GetProperty("diskBytes").GetInt64() != GuestDiskBytes || result.GetProperty("readOnly").GetBoolean() || new[] { "systemExit", "diskArbitrationExit", "recoveryExit", "diskListExit" }.Any(key => result.GetProperty(key).GetInt32() != 0)) + throw new InvalidOperationException("The fresh guest receipt did not prove native macOS 13+/x86_64, service readiness and the writable 64-GiB disk."); } static void AssertContainer(string json, string token) @@ -244,8 +363,31 @@ static class NativeDiagnostic using var document = JsonDocument.Parse(json); var container = document.RootElement[0]; var config = container.GetProperty("HostConfig"); - if (container.GetProperty("Config").GetProperty("Labels").GetProperty(OwnerLabel).GetString() != token || config.GetProperty("Privileged").GetBoolean() || config.GetProperty("NetworkMode").GetString() != "default" && config.GetProperty("NetworkMode").GetString() != "bridge" || config.GetProperty("Memory").GetInt64() != ContainerMemoryBytes || new[] { "CapAdd", "Devices", "DeviceRequests", "Binds", "PortBindings" }.Any(key => config.TryGetProperty(key, out var value) && value.ValueKind != JsonValueKind.Null && (value.ValueKind == JsonValueKind.Array ? value.GetArrayLength() != 0 : value.EnumerateObject().Any()))) - throw new InvalidOperationException("Created container exceeds the owned/unprivileged diagnostic boundary."); + var devices = config.GetProperty("Devices"); + var mounts = container.GetProperty("Mounts"); + var environment = container.GetProperty("Config").GetProperty("Env").EnumerateArray().Select(value => value.GetString()).ToArray(); + if (container.GetProperty("Config").GetProperty("Labels").GetProperty(OwnerLabel).GetString() != token + || config.GetProperty("Privileged").GetBoolean() + || config.GetProperty("NetworkMode").GetString() is not ("default" or "bridge") + || config.GetProperty("Memory").GetInt64() != ContainerMemoryBytes + || config.GetProperty("MemorySwap").GetInt64() != ContainerMemoryBytes + || config.GetProperty("NanoCpus").GetInt64() != 2000000000 + || config.GetProperty("ShmSize").GetInt64() != 536870912 + || new[] { "CapAdd", "DeviceRequests", "Binds", "PortBindings", "DeviceCgroupRules", "Tmpfs" }.Any(key => + config.TryGetProperty(key, out var value) && value.ValueKind != JsonValueKind.Null + && (value.ValueKind == JsonValueKind.Array ? value.GetArrayLength() != 0 : value.EnumerateObject().Any())) + || devices.GetArrayLength() != 1 + || devices[0].GetProperty("PathOnHost").GetString() != "/dev/kvm" + || devices[0].GetProperty("PathInContainer").GetString() != "/dev/kvm" + || devices[0].GetProperty("CgroupPermissions").GetString() != "rw" + || mounts.GetArrayLength() != 1 + || mounts[0].GetProperty("Type").GetString() != "volume" + || mounts[0].GetProperty("Destination").GetString() != "/storage" + || !mounts[0].GetProperty("RW").GetBoolean() + || new[] { "KVM=Y", "CPU_MODEL=host", "VERSION=13" }.Any(expected => + environment.Count(value => value is not null && value.StartsWith(expected.Split('=')[0] + "=", StringComparison.Ordinal)) != 1 + || !environment.Contains(expected))) + throw new InvalidOperationException("Created container exceeds the owned restricted KVM/host-CPU compatibility boundary."); } static async Task CaptureGuest(string id, string output, CancellationToken cancellation, bool final = false, string? token = null) @@ -257,7 +399,7 @@ static class NativeDiagnostic var result = await Command("docker", ["exec", id, "cat", "/dev/shm/installstate/" + file.Item1], output, "capture-" + file.Item1, cancellation, requireSuccess: false); if (result.ExitCode == 0 && !string.IsNullOrWhiteSpace(result.Output)) File.WriteAllText(Path.Combine(output, file.Item2), result.Output); } - await Command("docker", ["exec", id, "sh", "-c", "printf '[qemu]\n'; qemu-system-x86_64 --version | head -n 1; printf '[Recovery hash]\n'; test ! -f /storage/14/setup.dmg || sha256sum /storage/14/setup.dmg; printf '[resources]\n'; df -Pk /storage; cat /sys/fs/cgroup/memory.max /sys/fs/cgroup/cpu.max 2>/dev/null || true"], output, "guest-container-resources", cancellation, requireSuccess: false); + await Command("docker", ["exec", id, "sh", "-c", "printf '[qemu]\n'; qemu-system-x86_64 --version | head -n 1; printf '[Recovery hash]\n'; test ! -f /storage/13/setup.dmg || sha256sum /storage/13/setup.dmg; printf '[resources]\n'; df -Pk /storage; cat /sys/fs/cgroup/memory.max /sys/fs/cgroup/cpu.max 2>/dev/null || true"], output, "guest-container-resources", cancellation, requireSuccess: false); } static async Task CaptureMonitor(string id, string output, string token, CancellationToken cancellation) @@ -277,7 +419,7 @@ static class NativeDiagnostic var monitor = await Command("docker", ["exec", id, "sh", "-c", """ test -S /run/shm/monitor.sock || exit 1 rm -f -- "$1" || exit 1 - printf 'info status\nscreendump %s\n' "$1" | /usr/bin/timeout -s KILL 5 /usr/bin/nc.openbsd -q 1 -w 2 -U /run/shm/monitor.sock + printf 'info kvm\ninfo status\nscreendump %s\n' "$1" | /usr/bin/timeout -s KILL 5 /usr/bin/nc.openbsd -q 1 -w 2 -U /run/shm/monitor.sock monitor_exit=$? printf '\n[monitor-exit] %s\n' "$monitor_exit" [ "$monitor_exit" -eq 0 ] || exit "$monitor_exit" diff --git a/tools/ci/macos-native-readiness.sh b/tools/ci/macos-native-readiness.sh index db8ff48..5c68ae2 100644 --- a/tools/ci/macos-native-readiness.sh +++ b/tools/ci/macos-native-readiness.sh @@ -200,7 +200,7 @@ run_command version /usr/bin/sw_vers -productVersion read_scalar || fail_probe product_version_invalid os_version="$SCALAR" [[ "$os_version" =~ ^[0-9]+\.[0-9]+(\.[0-9]+)?$ ]] || fail_probe product_version_invalid -(( ${os_version%%.*} >= 14 )) || fail_probe unsupported_macos_version +(( ${os_version%%.*} >= 13 )) || fail_probe unsupported_macos_version flush_outputs || finish false diagnostic_log_budget_exceeded # Bound readiness independently of the host's 40-minute overall deadline.