diff --git a/.gitea/workflows/macos-native-diagnostic.yaml b/.gitea/workflows/macos-native-diagnostic.yaml new file mode 100644 index 0000000..d1bb8f2 --- /dev/null +++ b/.gitea/workflows/macos-native-diagnostic.yaml @@ -0,0 +1,36 @@ +name: Native macOS Recovery diagnostic on Ubuntu + +on: + workflow_dispatch: + +jobs: + macos-native-diagnostic: + runs-on: ubuntu-latest + timeout-minutes: 45 + env: + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: "1" + DOTNET_NOLOGO: "1" + steps: + - name: Checkout diagnostic source + uses: actions/checkout@v7 + + - name: Setup .NET for the diagnostic helper + uses: actions/setup-dotnet@v6 + with: + dotnet-version: "10.0.x" + + - name: Probe native macOS Recovery with existing Docker resources + run: dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --run --output artifacts/native-macos + + - name: Always clean up only this diagnostic's owned resources + if: always() + run: dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --cleanup --output artifacts/native-macos + + - name: Preserve native diagnostic evidence + if: always() + uses: actions/upload-artifact@v3 + with: + name: native-macos-recovery-diagnostic + path: artifacts/native-macos/ + if-no-files-found: error + retention-days: 7 diff --git a/README.md b/README.md index 47374a6..6b3e4f3 100644 --- a/README.md +++ b/README.md @@ -175,6 +175,8 @@ Ubuntu does not compile the Swift helpers or execute Apple frameworks. Tests req [Docker-OSX](https://github.com/sickcodes/Docker-OSX) runs a macOS VM rather than providing a Wine-style compatibility layer. Its launcher supports software emulation with `KVM=accel=tcg`, so KVM is not an absolute requirement. A supported .NET 10 guest needs macOS 14 or later plus the Swift build tools. The documented `auto` build downloads a preinstalled guest disk through `IMAGE_URL`; its documented ready-made tags and disk downloads were unavailable when checked on 2026-10-03. No verified native guest bootstrap is owned by this repository. CI validates source; it does not publish or deploy the workstation application. +The separate manual [native Recovery diagnostic](docs/macos-native-diagnostic.md) probes macOS startup and disk readiness through an unprivileged TCG guest on the existing Ubuntu Docker runner. It neither installs macOS nor runs application tests; its result is a prerequisite for a future native test job, not verification of macOS CI support. + ## Operations And Limitations - Treat recording, transcription drain, speaker finalization, OCR, and summarization as live user work. Never restart, kill, or clean runtime files until `/recording/status` is idle unless interruption is explicitly intended. diff --git a/docs/macos-native-diagnostic.md b/docs/macos-native-diagnostic.md new file mode 100644 index 0000000..9eaac92 --- /dev/null +++ b/docs/macos-native-diagnostic.md @@ -0,0 +1,31 @@ +# Native macOS Recovery diagnostic on the existing Ubuntu runner + +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. + +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. + +## Helper entry point and invocation + +The orchestration is a .NET 10 file-based C# app at `tools/ci/MacOsNativeDiagnostic.cs`: + +```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 -- --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#. + +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. + +## 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. + +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 new file mode 100644 index 0000000..4ed5dc9 --- /dev/null +++ b/tools/ci/MacOsNativeDiagnostic.cs @@ -0,0 +1,346 @@ +#:property PublishAot=false +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Xml.Linq; + +// .NET 10 file-based CI diagnostic. See docs/macos-native-diagnostic.md. +return await NativeDiagnostic.Execute(args); + +static class NativeDiagnostic +{ + const string DockurCommit = "16a5b470cdd601bae8b05b02d748d7edfb36c12e"; + const string OwnerLabel = "org.meeting-assistant.native-diagnostic"; + const long GuestDiskBytes = 64L * 1024 * 1024 * 1024; + const long ContainerMemoryBytes = 6L * 1024 * 1024 * 1024; + const int MaximumCapturedCharacters = 8 * 1024 * 1024; + static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, WriteIndented = true }; + const string OriginalBootstrap = "[ ! -e /tmp/m ]&&{ /sbin/mount_9p installstate >/dev/null 2>&1;exec /Volumes/installstate/launch.sh;};: >/tmp/m\n"; + const string MountOnlyBootstrap = "[ ! -e /tmp/m ]&& /sbin/mount_9p installstate >/dev/null 2>&1; : >/tmp/m\n"; + static readonly string OriginalDaemon = """ + + + + + \tLabel + \tcom.apple.recoveryosd + \tOnDemand + \t + \tProcessType + \tApp + \tEnablePressuredExit + \t + \tProgramArguments + \t + \t\t/usr/libexec/recoveryosd + \t + + + """.Replace("\\t", "\t", StringComparison.Ordinal); + static readonly string DiagnosticDaemon = (OriginalDaemon + "\n") + .Replace("\n", "", StringComparison.Ordinal) + .Replace("\t\t/usr/libexec/recoveryosd", "\t\t/bin/bash\n\t\t/Volumes/installstate/launch.sh", StringComparison.Ordinal); + + public static async Task Execute(string[] args) + { + 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]"); + return 0; + } + var output = Path.GetFullPath(Option(args, "--output") ?? "artifacts/native-macos"); + if (args.Contains("--validate")) + { + ValidateContracts(); + if (Option(args, "--source") is { } source) + await PrepareSource(Path.GetFullPath(source), output, "validation", false, CancellationToken.None); + Console.WriteLine("Source patch contracts and diagnostic result validation passed; no Docker or guest execution occurred."); + return 0; + } + if (args.Contains("--cleanup")) + return await Cleanup(output) ? 0 : 1; + if (!args.Contains("--run")) throw new ArgumentException("Choose --run, --cleanup or --validate."); + Directory.CreateDirectory(output); + var statePath = Path.Combine(output, "owned-resources.json"); + if (File.Exists(statePath)) throw new InvalidOperationException("Output already contains a run identity; choose a fresh directory or clean up its run first."); + var token = Guid.NewGuid().ToString("N"); + var work = Path.Combine(Environment.GetEnvironmentVariable("RUNNER_TEMP") ?? Path.GetTempPath(), "meeting-assistant-native-" + token); + var state = new OwnedResources(token, "meeting-assistant-native-" + token, "meeting-assistant-native-diagnostic:" + token, work); + Save(statePath, state); + Directory.CreateDirectory(work); + File.WriteAllText(Path.Combine(work, "run.owner"), token); + using var deadline = new CancellationTokenSource(TimeSpan.FromMinutes(40)); + using var signal = OperatingSystem.IsLinux() ? PosixSignalRegistration.Create(PosixSignal.SIGTERM, context => { context.Cancel = true; deadline.Cancel(); }) : null; + ConsoleCancelEventHandler cancelHandler = (_, context) => { context.Cancel = true; deadline.Cancel(); }; + Console.CancelKeyPress += cancelHandler; + var outcome = "failed"; + string? error = null; + try + { + if (!OperatingSystem.IsLinux() || RuntimeInformation.ProcessArchitecture != Architecture.X64) + 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 }); + var info = await Command("docker", ["info", "--format", "{{json .}}"], output, "docker-info", deadline.Token); + using (var document = JsonDocument.Parse(info.Output)) + { + var data = document.RootElement; + if (data.GetProperty("OSType").GetString() != "linux" || data.GetProperty("Architecture").GetString() is not ("x86_64" or "amd64")) + throw new InvalidOperationException("The existing Docker daemon is not Linux/x64; this diagnostic does not reconfigure it."); + if (data.GetProperty("NCPU").GetInt32() < 2 || data.GetProperty("MemTotal").GetInt64() < ContainerMemoryBytes) + throw new InvalidOperationException("Existing Docker resources cannot fit this bounded 2-CPU/6-GiB diagnostic; no infrastructure change was requested."); + } + await Command("sh", ["-c", "cat /proc/meminfo; printf '\n[cgroup]\n'; cat /sys/fs/cgroup/memory.max /sys/fs/cgroup/cpu.max 2>/dev/null || true; printf '\n[workspace disk]\n'; df -Pk ."], output, "runner-resources", deadline.Token); + var available = System.Text.RegularExpressions.Regex.Match(File.ReadAllText("/proc/meminfo"), @"(?m)^MemAvailable:\s+(\d+) kB$"); + if (!available.Success || long.Parse(available.Groups[1].Value) < 5L * 1024 * 1024) + throw new InvalidOperationException("Existing runner memory has less than the 5-GiB available diagnostic budget; no infrastructure change was requested."); + var source = Path.Combine(work, "dockur"); + await Command("git", ["clone", "--no-checkout", "https://github.com/dockur/macos.git", source], output, "dockur-clone", deadline.Token); + 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 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 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 }; + Save(statePath, state); + 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."); + while (true) + { + deadline.Token.ThrowIfCancellationRequested(); + await CaptureGuest(id, output, deadline.Token); + var resultPath = Path.Combine(output, "guest-result.json"); + if (File.Exists(resultPath)) + { + var result = File.ReadAllText(resultPath); + ValidateResult(result, token); + Console.WriteLine("Native Recovery readiness passed. This run has not installed macOS, .NET, CLT, or run Meeting Assistant tests."); + outcome = "readiness-passed"; + break; + } + 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."); + await Task.Delay(TimeSpan.FromSeconds(20), deadline.Token); + } + } + catch (Exception exception) + { + error = exception is OperationCanceledException ? "The explicit 40-minute diagnostic deadline or cancellation was reached." : exception.Message; + Console.Error.WriteLine(error); + } + finally + { + 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 { 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."; } + Save(Path.Combine(output, "outcome.json"), new { token, outcome, error, completedUtc = DateTimeOffset.UtcNow }); + } + return outcome == "readiness-passed" ? 0 : 1; + } + + static string? Option(string[] args, string name) + { + var index = Array.IndexOf(args, name); + return index < 0 ? null : index + 1 < args.Length ? args[index + 1] : throw new ArgumentException("Missing value for " + name); + } + + static void ValidateContracts() + { + 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 }); + 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") }) + { + try { ValidateResult(invalid, "validation"); } catch (InvalidOperationException) { continue; } + throw new InvalidOperationException("Diagnostic validator accepted an invalid/stale result."); + } + } + + static async Task PrepareSource(string source, string output, string token, bool writeSource, CancellationToken cancellation) + { + Directory.CreateDirectory(output); + var patchPath = Path.Combine(source, "src/install/recovery/patch.py"); + var originalPatch = File.ReadAllText(patchPath); + if (Hash(Encoding.UTF8.GetBytes(originalPatch)) != "84f13db88c02edbf5ce21a39571fe58f12bebf5b0886c2d012f16ddbaed45323") throw new InvalidOperationException("Pinned Recovery patcher hash mismatch."); + var patch = ReplaceOnce(originalPatch, OriginalBootstrap, MountOnlyBootstrap); + var oldConstants = "RECOVERY_ORIGINAL = b\"/usr/libexec/recoveryosd\"\nRECOVERY_REPLACEMENT = b\"/private/etc/rc.cdrom.sh\""; + var daemon = OriginalDaemon + "\n"; + 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 "); + 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"); + 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) }) + 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)))); + await Command("bash", ["-n", Path.Combine(output, "guest-launch.sh")], output, "guest-hook-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)); + } + + 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) + { + var count = text.Split(oldValue, StringSplitOptions.None).Length - 1; + if (count != expected) throw new InvalidOperationException($"Pinned source contract expected {expected} match(es), found {count}: {oldValue.Split('\n')[0]}"); + return text.Replace(oldValue, newValue, StringComparison.Ordinal); + } + + static void ValidateResult(string json, string token) + { + 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."); + } + + static void AssertContainer(string json, string token) + { + 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."); + } + + static async Task CaptureGuest(string id, string output, CancellationToken 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") }) + { + 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); + } + + static async Task Cleanup(string output) + { + var path = Path.Combine(output, "owned-resources.json"); + if (!File.Exists(path)) return true; + var state = JsonSerializer.Deserialize(File.ReadAllText(path), JsonOptions) ?? throw new InvalidOperationException("Invalid owned-resource receipt."); + if (!System.Text.RegularExpressions.Regex.IsMatch(state.Token, "^[0-9a-f]{32}$") || state.ContainerName != "meeting-assistant-native-" + state.Token || state.ImageTag != "meeting-assistant-native-diagnostic:" + state.Token) throw new InvalidOperationException("Invalid cleanup ownership identity."); + using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(90)); + try + { + foreach (var kind in new[] { "container", "image" }) + { + var name = kind == "container" ? state.ContainerName : state.ImageTag; + var inspect = await Command("docker", [kind, "inspect", name], output, "cleanup-" + kind + "-inspect", deadline.Token, requireSuccess: false); + if (inspect.ExitCode != 0) + { + if (inspect.Error.Contains("No such object", StringComparison.Ordinal) || inspect.Error.Contains("No such container", StringComparison.Ordinal) || inspect.Error.Contains("No such image", StringComparison.Ordinal)) continue; + throw new InvalidOperationException("Cannot establish owned " + kind + " absence: " + inspect.Error); + } + using var document = JsonDocument.Parse(inspect.Output); + var resource = document.RootElement[0]; + if (resource.GetProperty("Config").GetProperty("Labels").GetProperty(OwnerLabel).GetString() != state.Token) throw new InvalidOperationException("Cleanup refuses a resource without this run's exact ownership label."); + var id = resource.GetProperty("Id").GetString()!; + var expectedId = kind == "container" ? state.ContainerId : state.ImageId; + if (expectedId is not null && expectedId != id) throw new InvalidOperationException("Cleanup refuses a resource whose ID changed after creation."); + await Command("docker", kind == "container" ? ["rm", "--force", "--volumes", id] : ["image", "rm", id], output, "cleanup-" + kind + "-remove", deadline.Token); + } + if (Path.GetFileName(state.WorkDirectory) == "meeting-assistant-native-" + state.Token && File.Exists(Path.Combine(state.WorkDirectory, "run.owner")) && File.ReadAllText(Path.Combine(state.WorkDirectory, "run.owner")) == state.Token) Directory.Delete(state.WorkDirectory, true); + Save(Path.Combine(output, "cleanup.json"), new { state.Token, success = true, completedUtc = DateTimeOffset.UtcNow }); + return true; + } + catch (Exception exception) + { + Save(Path.Combine(output, "cleanup.json"), new { state.Token, success = false, error = exception.Message, completedUtc = DateTimeOffset.UtcNow }); + Console.Error.WriteLine("Owned diagnostic cleanup failed: " + exception.Message); + return false; + } + } + + static async Task Command(string executable, string[] arguments, string output, string label, CancellationToken cancellation, bool requireSuccess = true, bool echo = false) + { + if (!label.StartsWith("capture-", StringComparison.Ordinal) && label is not "container" and not "container-running" and not "guest-container-resources") + Console.WriteLine("[native-diagnostic] " + label); + using var commandCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellation); + var commandToken = commandCancellation.Token; + var start = new ProcessStartInfo(executable) { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false }; + foreach (var argument in arguments) start.ArgumentList.Add(argument); + start.Environment["GIT_TERMINAL_PROMPT"] = "0"; + using var process = Process.Start(start) ?? throw new InvalidOperationException("Cannot start " + executable); + async Task Read(StreamReader reader, string stream) + { + var captured = new StringBuilder(); + var buffer = new char[8192]; + using var log = new StreamWriter(Path.Combine(output, label + "." + stream + ".log"), false, new UTF8Encoding(false)); + while (true) + { + var count = await reader.ReadAsync(buffer.AsMemory(), commandToken); + if (count == 0) break; + if (captured.Length + count > MaximumCapturedCharacters) + { + commandCancellation.Cancel(); + throw new InvalidOperationException(label + " exceeded its bounded diagnostic log size."); + } + captured.Append(buffer, 0, count); + await log.WriteAsync(buffer.AsMemory(0, count), commandToken); + await log.FlushAsync(commandToken); + if (echo) Console.Write(new string(buffer, 0, count)); + } + return captured.ToString(); + } + var stdout = Read(process.StandardOutput, "stdout"); + var stderr = Read(process.StandardError, "stderr"); + try + { + await Task.WhenAll(stdout, stderr, process.WaitForExitAsync(commandToken)); + var result = new CommandResult(process.ExitCode, await stdout, await stderr); + if (requireSuccess && result.ExitCode != 0) throw new InvalidOperationException($"{label} exited {result.ExitCode}: {result.Error[..Math.Min(result.Error.Length, 1500)]}"); + return result; + } + catch + { + try { if (!process.HasExited) process.Kill(entireProcessTree: true); } catch (InvalidOperationException) { } + throw; + } + } + + static string Hash(byte[] bytes) => Convert.ToHexStringLower(SHA256.HashData(bytes)); + static void PrintGuestProof(string output, string token) + { + var path = Path.Combine(output, "guest-proof.log"); + if (!File.Exists(path)) { Console.WriteLine("[native-diagnostic] No native guest proof was captured."); return; } + var proof = File.ReadAllText(path).Replace(token, "", StringComparison.Ordinal); + const int budget = 512 * 1024; + if (proof.Length > budget) + proof = proof[..(64 * 1024)] + "\n[native-diagnostic] Middle of proof omitted from CI stdout; complete bounded proof is retained in the artifact.\n" + proof[^((budget - 64 * 1024))..]; + Console.WriteLine("[native-diagnostic] Final native guest proof:"); + Console.Write(proof); + } + static void Save(string path, object value) + { + var temporary = path + ".tmp"; + File.WriteAllText(temporary, JsonSerializer.Serialize(value, JsonOptions), new UTF8Encoding(false)); + File.Move(temporary, path, overwrite: true); + } + sealed record OwnedResources(string Token, string ContainerName, string ImageTag, string WorkDirectory, string? ContainerId = null, string? ImageId = null); + sealed record CommandResult(int ExitCode, string Output, string Error); +} diff --git a/tools/ci/macos-native-readiness.sh b/tools/ci/macos-native-readiness.sh new file mode 100644 index 0000000..d451f88 --- /dev/null +++ b/tools/ci/macos-native-readiness.sh @@ -0,0 +1,149 @@ +#!/bin/bash +# Existing macOS Recovery/launchd runtime hook; never installs or erases anything. +set -u +PATH="/usr/bin:/bin:/usr/sbin:/sbin" +export PATH +PROOF_TOKEN="@@PROOF_TOKEN@@" +STATE_DIR="/Volumes/installstate" +PROOF_LOG="$STATE_DIR/proof.log" +RESULT="$STATE_DIR/result.json" +EXPECTED_BYTES=68719476736 +MAX_LOG_BYTES=4194304 +os_version="" +architecture="" +uid=-1 +system_exit=-1 +arbitration_exit=-1 +recovery_exit=-1 +disk_list_exit=-1 +selected_disk="" +disk_bytes=0 + +count=0 +while [ ! -d "$STATE_DIR" ] && (( count < 120 )); do + /sbin/mount_9p installstate >/dev/null 2>&1 || : + count=$((count + 1)) + sleep 1 +done +[ -d "$STATE_DIR" ] || exit 1 +: > "$PROOF_LOG" || exit 1 +rm -f "$RESULT" "$RESULT.tmp" +printf '[proof-token] %s\n' "$PROOF_TOKEN" >> "$PROOF_LOG" + +finish() { + local success="$1" reason="$2" + printf '[proof-result] %s: %s\n' "$success" "$reason" >> "$PROOF_LOG" + 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 + # Keep the service alive for the bounded host diagnostic to capture evidence. + while :; do sleep 60; done +} + +run_command() { + local name="$1" + shift + local process timer sleeper exit_code + LAST_OUTPUT="/tmp/native-diagnostic-$name.out" + printf '\n[proof-command] %s:' "$name" >> "$PROOF_LOG" + printf ' %s' "$@" >> "$PROOF_LOG" + printf '\n' >> "$PROOF_LOG" + "$@" > "$LAST_OUTPUT" 2>&1 & + process=$! + ( + trap 'kill "$sleeper" 2>/dev/null || :; exit 0' TERM INT + sleep 45 & + sleeper=$! + wait "$sleeper" + kill -TERM "$process" 2>/dev/null || : + sleep 2 + kill -KILL "$process" 2>/dev/null || : + ) & + timer=$! + wait "$process" + exit_code=$? + kill -TERM "$timer" 2>/dev/null || : + wait "$timer" 2>/dev/null || : + /usr/bin/tail -c 524288 "$LAST_OUTPUT" >> "$PROOF_LOG" + printf '\n[proof-exit] %s\n' "$exit_code" >> "$PROOF_LOG" + 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 + 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 +run_command kernel /usr/bin/uname -a +(( LAST_EXIT == 0 )) || finish false uname_failed +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 +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 parent /bin/ps -p "$$" -p "$PPID" -o pid=,ppid=,comm= +run_command processes /bin/ps -axo pid,ppid,comm +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" + +# 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" + run_command disks /usr/sbin/diskutil list physical + disk_list_exit="$LAST_EXIT" + if (( disk_list_exit == 0 )); then + disk_list=$(cat "$LAST_OUTPUT") + candidates=0 + while IFS= read -r disk; do + [ -n "$disk" ] || continue + run_command "info-$disk" /usr/sbin/diskutil info "/dev/$disk" + (( LAST_EXIT == 0 )) || continue + info=$(cat "$LAST_OUTPUT") + if printf '%s\n' "$info" | grep -Eq '^[[:space:]]*(Read-Only (Media|Device)|(Media|Device) Read-Only):[[:space:]]*Yes'; then + continue + fi + printf '%s\n' "$info" | grep -Eq '^[[:space:]]*(Read-Only (Media|Device)|(Media|Device) Read-Only):[[:space:]]*No' || continue + size=$(printf '%s\n' "$info" | sed -nE 's/^[[:space:]]*Disk Size:.*\(([0-9]+) Bytes\).*/\1/p' | head -n 1) + [[ "$size" =~ ^[0-9]+$ ]] || continue + (( size == EXPECTED_BYTES )) || continue + candidates=$((candidates + 1)) + selected_disk="/dev/$disk" + disk_bytes="$size" + printf '[writable-target] %s %s bytes\n' "$selected_disk" "$disk_bytes" >> "$PROOF_LOG" + done < <(printf '%s\n' "$disk_list" | sed -nE 's#^/dev/(disk[0-9]+).*#\1#p') + (( candidates <= 1 )) || finish false 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 + system_exit="$LAST_EXIT" + run_command arbitration_ready /bin/launchctl print system/com.apple.diskarbitrationd + 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 + finish true native_recovery_and_writable_64g_disk_ready + fi + fi + sleep 5 +done +finish false disk_management_or_writable_target_not_ready