diff --git a/docs/macos-native-diagnostic.md b/docs/macos-native-diagnostic.md index 6aeb810..693e42e 100644 --- a/docs/macos-native-diagnostic.md +++ b/docs/macos-native-diagnostic.md @@ -6,6 +6,8 @@ The pipeline and its application/test prerequisites are consolidated on `codex/m The configuration has not passed remote installation, build or tests. Run [4219](https://gitea.schweigert.cloud/Daniel/meeting-assistant/actions/runs/4219) proved native macOS 13.6/x86_64 but failed eight bounded disk-readiness attempts. Run [4221](https://gitea.schweigert.cloud/Daniel/meeting-assistant/actions/runs/4221) failed both bounded `sw_vers` attempts. Run [4245](https://gitea.schweigert.cloud/Daniel/meeting-assistant/actions/runs/4245) subsequently passed Recovery readiness and entered installation; it was stopped at the user's request before a native build/test result. Local validation checks preparation and ownership contracts, not native CI success. These results do not establish that PR 39 is ready to merge. +Run [4319](https://gitea.schweigert.cloud/Manuel/meeting-assistant/actions/runs/4319) passed the Windows and portable jobs and native Recovery readiness. The host published the permit ten seconds after readiness, but the guest's file-size check did not observe it and timed out after five minutes; installation never started. The unhealthy run was cancelled and normal owned-resource cleanup succeeded. The repaired handoff has offline shell evidence; a fresh Full native CI run remains required. + ## Entrypoints Run from a clean checkout of the commit to test: @@ -17,6 +19,8 @@ dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --cleanup --output artifa The output directory binds the run identity and must be fresh. `git archive HEAD` supplies the exact source to the guest; uncommitted application changes are not included. Cleanup uses the same output directory and removes only the saved container, image and anonymous storage volume with matching ownership labels and IDs. Retained artifacts stay outside that volume. Shared Docker caches are not pruned. +Before publishing successful Recovery readiness, the guest creates a fixed-size pending permit mailbox. After rechecking the owned container, disk and share, the host changes that same file's contents without truncation or rename. The guest reads and validates exactly three terminated lines plus EOF against its run token, selected disk and source commit. It retains `install-permit-received.json`; only a matching receipt starts the host's installation phase. A missing or invalid permit publishes `installation-failed` after five minutes. The installer still independently rechecks the serial, exact writable 64-GiB disk and exclusive erase guard before touching it. Offline validation models stale size metadata and exercises the real generated shell and host publication command; it does not prove macOS share behavior. + For a read-only Recovery check, omit `--full`. That mode does not install an OS or execute application tests. Offline checks use a pristine checkout of the pinned Dockur source and the two pinned compatibility archives: ```sh diff --git a/openspec/changes/complete-macos-feature-parity/specs/platform-parity-verification/spec.md b/openspec/changes/complete-macos-feature-parity/specs/platform-parity-verification/spec.md index 7e35987..d9f9db1 100644 --- a/openspec/changes/complete-macos-feature-parity/specs/platform-parity-verification/spec.md +++ b/openspec/changes/complete-macos-feature-parity/specs/platform-parity-verification/spec.md @@ -96,3 +96,12 @@ Die offenen Tasks 4.2 und 4.4 des bestehenden Changes `add-macos-user-communicat - **THEN** eine bekannte WAV-Datei wird durch den tatsächlichen Backendpfad verarbeitet - **AND** Output, Host, Laufzeit und Voraussetzungen werden getrennt je Backend dokumentiert - **AND** fehlende Zugangsdaten oder Laufzeiten werden nicht stillschweigend angenommen oder durch einen Ersatzprovider verdeckt + +#### Scenario: Native CI wartet auf die tatsächlich empfangene Installationsfreigabe + +- **GIVEN** der frische macOS-Gast hat seine native Bereitschaft und den eigenen beschreibbaren Datenträger nachgewiesen +- **WHEN** der Host die geprüfte, an Run, Source-Commit und Datenträger gebundene Freigabe über die gemeinsame Ablage übermittelt +- **THEN** der Gast prüft den vollständig gelesenen Inhalt auch bei veralteten Dateigrößen-Metadaten +- **AND** der Host meldet den Beginn der Installationsphase erst nach einer passenden Gastquittung +- **AND** eine ausbleibende oder ungültige Freigabe erzeugt innerhalb von fünf Minuten eine terminale Fehlerquittung, ohne einen Datenträger zu verändern +- **AND** die bestehenden erneuten Eigentums- und Datenträgerprüfungen vor dem Löschen bleiben erforderlich diff --git a/openspec/changes/complete-macos-feature-parity/tasks.md b/openspec/changes/complete-macos-feature-parity/tasks.md index bbe38bc..98059d8 100644 --- a/openspec/changes/complete-macos-feature-parity/tasks.md +++ b/openspec/changes/complete-macos-feature-parity/tasks.md @@ -38,4 +38,6 @@ Abschluss Ticket 02 (2026-10-06): WAV-/Verfahrensvorbereitung akzeptiert; lokales Ticket auf Benutzerauftrag geschlossen. Task 3.2 einschließlich aller fehlenden Windows-Backendläufe bleibt offen. [Abschluss und offene Abnahmematrix](windows-platform-acceptance-completion.md). +- [ ] 3.3 Die an Run, Source-Commit und Datenträger gebundene Installationsfreigabe gastseitig quittieren und den reparierten Ablauf durch einen frischen vollständigen nativen CI-Lauf nachweisen. Fehlende oder ungültige Freigaben müssen innerhalb von fünf Minuten terminal fehlschlagen. Die Offline-Fälle für veraltete Metadaten, unveränderten Datei-Inode und die Ablehnung unvollständiger oder fremder Freigaben ersetzen den nativen Lauf nicht. + Alle Einträge beschreiben zukünftige Arbeit. Dieser Planungsschritt startet keine Implementierung, Anwendungstests oder Abnahme. Die fachlichen Ergänzungen werden im jeweiligen bestehenden Change geführt; frühere Nachweise und die ursprünglichen Kommunikationsaufgaben bleiben erhalten. diff --git a/tools/ci/MacOsNativeDiagnostic.cs b/tools/ci/MacOsNativeDiagnostic.cs index 2d108f4..f3bf8e2 100644 --- a/tools/ci/MacOsNativeDiagnostic.cs +++ b/tools/ci/MacOsNativeDiagnostic.cs @@ -31,6 +31,7 @@ static class NativeDiagnostic const string SdkVersion = "10.0.401"; const string SdkSha512 = "33401b4a2da8554e3306db6072ea8569d9fcc608509c271e0aa4b39e7cc432da3631f14e7e1e2445d67d72550d18ce44a8bbd2382a756867ad2edab6b1c963c0"; const string FullState = "/storage/13/ci-state"; + const string PermitPublication = "test -f \"$1\" && test ! -L \"$1\" && dd if=\"$1.tmp\" of=\"$1\" bs=256 count=1 conv=notrunc status=none && cmp -s \"$1.tmp\" \"$1\""; 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"; @@ -75,11 +76,15 @@ static class NativeDiagnostic if (recoveryFormat is not ("dmg" or "raw")) throw new ArgumentException("Recovery format must be dmg or raw."); if (args.Contains("--validate")) { + if (full) await ValidatePermitHandoff(output); await ValidateRuntimeObservation(output); ValidateRunnerMemoryGate(); ValidateGuestProgress(output); ValidateContracts(); - if (full) ValidateFullContracts(); + if (full) + { + ValidateFullContracts(); + } if (Option(args, "--source") is { } source) { await PrepareSource(Path.GetFullPath(source), output, full ? new string('0', 32) : "validation", false, CancellationToken.None, full, Option(args, "--cryptex-archive"), Option(args, "--noavx-archive"), recoveryFormat); @@ -182,8 +187,8 @@ static class NativeDiagnostic { await PermitInstallation(id, output, token, sourceCommit, result, deadline.Token); permitted = true; - phase = "installation"; - phaseBudget = TimeSpan.FromMinutes(80); + phase = "permit-handoff"; + phaseBudget = TimeSpan.FromMinutes(5); phaseStarted.Restart(); } else @@ -195,18 +200,28 @@ static class NativeDiagnostic } if (full) { + var permitReceiptPath = Path.Combine(output, "install-permit-received.json"); + if (permitted && phase == "permit-handoff" && File.Exists(permitReceiptPath)) + { + using var acknowledged = JsonDocument.Parse(File.ReadAllText(permitReceiptPath)); + using var readiness = JsonDocument.Parse(File.ReadAllText(resultPath)); + if (acknowledged.RootElement.GetProperty("token").GetString() != token || acknowledged.RootElement.GetProperty("sourceCommit").GetString() != sourceCommit || acknowledged.RootElement.GetProperty("disk").GetString() != readiness.RootElement.GetProperty("disk").GetString()) + throw new InvalidOperationException("Guest permit receipt does not match the authorized run, source and disk."); + phase = "installation"; phaseBudget = TimeSpan.FromMinutes(80); phaseStarted.Restart(); + Console.WriteLine("[native-diagnostic] phase: installation (guest permit receipt verified)"); + } var phasePath = Path.Combine(output, "guest-phase.json"); if (File.Exists(phasePath)) { using var nativePhase = JsonDocument.Parse(File.ReadAllText(phasePath)); if (nativePhase.RootElement.GetProperty("token").GetString() != token) throw new InvalidOperationException("Stale native phase receipt."); var current = nativePhase.RootElement.GetProperty("phase").GetString(); - var next = !permitted ? phase : current == "toolchain-installing" ? "toolchain" : current is "tests-running" or "tests-passed" ? "tests" : phase; + var next = !permitted || phase == "permit-handoff" ? phase : current == "toolchain-installing" ? "toolchain" : current is "tests-running" or "tests-passed" ? "tests" : phase; if (next != phase) { phase = next; phaseBudget = TimeSpan.FromMinutes(next == "toolchain" ? 30 : 25); phaseStarted.Restart(); Console.WriteLine("[native-diagnostic] phase: " + phase); } if (current is "tests-failed" or "bootstrap-failed" or "installation-failed") throw new InvalidOperationException("Guest phase failed: " + current); } var fullResult = Path.Combine(output, "full-result.json"); - if (permitted && File.Exists(fullResult)) + if (permitted && phase != "permit-handoff" && File.Exists(fullResult)) { ValidateFullResult(File.ReadAllText(fullResult), token, sourceCommit, File.ReadAllText(Path.Combine(output, "archive.sha256")).Trim()); ValidateTrx(File.ReadAllBytes(Path.Combine(output, "native.trx")), File.ReadAllBytes(Path.Combine(output, "discovery.txt")), File.ReadAllText(fullResult)); @@ -877,19 +892,150 @@ static class NativeDiagnostic return Encoding.UTF8.GetString(bytes); } - static string CreateFullReadiness(string hook) => ReplaceOnce(hook, + static async Task ValidatePermitHandoff(string output) + { + const string token = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const string commit = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + var complete = token + "\n/dev/disk1\n" + commit + "\n"; + var cases = new[] + { + (Name: "stale-stat", Record: (string?)complete, Accepted: true), + (Name: "timeout", Record: (string?)null, Accepted: false), + (Name: "foreign-token", Record: complete.Replace(token, new string('f', 32)), Accepted: false), + (Name: "foreign-disk", Record: complete.Replace("/dev/disk1", "/dev/disk2"), Accepted: false), + (Name: "foreign-commit", Record: complete.Replace(commit, new string('f', 40)), Accepted: false), + (Name: "foreign-owner", Record: (string?)complete, Accepted: false), + (Name: "partial", Record: token + "\n/dev/disk1\n", Accepted: false), + (Name: "unterminated", Record: complete.TrimEnd('\n'), Accepted: false), + (Name: "extra-empty-line", Record: complete + "\n", Accepted: false), + (Name: "extra-unterminated", Record: complete + "extra", Accepted: false) + }; + var hook = CreateFullReadiness(File.ReadAllText("tools/ci/macos-native-readiness.sh").Replace("\r\n", "\n", StringComparison.Ordinal)); + var start = hook.IndexOf("finish() {", StringComparison.Ordinal); + var end = hook.IndexOf("\ninit_timer_fifo()", start, StringComparison.Ordinal); + if (start < 0 || end < 0) throw new InvalidOperationException("Recovery finish fixture boundary changed."); + foreach (var test in cases) + { + var state = Path.Combine(output, "permit-" + test.Name + "-fixture"); + if (Directory.Exists(state)) throw new InvalidOperationException("Permit fixtures require a fresh validation output."); + Directory.CreateDirectory(state); + File.WriteAllText(Path.Combine(state, "run.owner"), token + "\n"); + File.WriteAllText(Path.Combine(state, "source.commit"), commit + "\n"); + if (test.Record is not null) File.WriteAllText(Path.Combine(state, "install.permit.tmp"), test.Record); + var script = """ + set -u + STATE_DIR="$1"; PROOF_TOKEN="$2"; COMMIT="$3"; PUBLICATION="$4"; CASE="$5" + PROOF_LOG="$STATE_DIR/proof.log"; RESULT="$STATE_DIR/result.json" + TIMER_FIFO="$STATE_DIR/no-timer"; selected_disk=/dev/disk1 + os_version=13.6; architecture=x86_64; uid=0 + system_exit=0; arbitration_exit=0; recovery_exit=0; disk_list_exit=0; disk_bytes=68719476736 + exec 3>> "$PROOF_LOG"; exec 9<> /dev/null + flush_outputs() { return 0; } + # External filesystem metadata seam: the share still reports an empty permit. + [() { + if [[ "$1" == -s && "$2" == "$STATE_DIR/install.permit" ]]; then return 1; fi + builtin [ "$@" + } + sleep() { + [ "$1" != 60 ] || exit 0 + if [ ! -e "$STATE_DIR/published" ]; then + stat -c '%i:%s' "$STATE_DIR/install.permit" > "$STATE_DIR/mailbox-before" + if [ -f "$STATE_DIR/install.permit.tmp" ]; then + if [ "$(wc -c < "$STATE_DIR/install.permit.tmp")" -eq "$(wc -c < "$STATE_DIR/install.permit")" ]; then + /bin/sh -c "$PUBLICATION" owned-permit-publication "$STATE_DIR/install.permit" || exit 1 + else + # Inject malformed share contents at the external filesystem boundary. + cat "$STATE_DIR/install.permit.tmp" > "$STATE_DIR/install.permit" + fi + fi + stat -c '%i:%s' "$STATE_DIR/install.permit" > "$STATE_DIR/mailbox-after" + if [ "$CASE" = foreign-owner ]; then printf 'foreign\n' > "$STATE_DIR/run.owner"; fi + : > "$STATE_DIR/published" + fi + SECONDS=$((SECONDS + 60)) + } + exec() { + if (( $# == 0 )); then builtin exec; return; fi + printf '%s\n' "$*" > "$STATE_DIR/installer-exec.log"; exit 0 + } + """ + "\n" + hook[start..end] + "\nfinish true ready\n"; + var command = await Command("bash", ["-c", script, "permit-handoff-contract", state.Replace('\\', '/'), token, commit, PermitPublication, test.Name], output, "permit-" + test.Name, CancellationToken.None, requireSuccess: false); + if (command.ExitCode != (test.Name == "foreign-owner" ? 1 : 0)) throw new InvalidOperationException("Unexpected permit fixture exit: " + test.Name); + var executed = File.Exists(Path.Combine(state, "installer-exec.log")); + var receiptPath = Path.Combine(state, "install-permit-received.json"); + if (executed != test.Accepted || File.Exists(receiptPath) != test.Accepted) + throw new InvalidOperationException("Owned permit acceptance mismatch with stale shared metadata: " + test.Name); + if (test.Accepted) + { + using var receipt = JsonDocument.Parse(File.ReadAllText(receiptPath)); + if (receipt.RootElement.GetProperty("token").GetString() != token || receipt.RootElement.GetProperty("sourceCommit").GetString() != commit || receipt.RootElement.GetProperty("disk").GetString() != "/dev/disk1" || File.ReadAllText(Path.Combine(state, "mailbox-before")) != File.ReadAllText(Path.Combine(state, "mailbox-after"))) + throw new InvalidOperationException("Permit acknowledgement or stable mailbox identity changed."); + } + else if (test.Name == "foreign-owner") + { + if (File.Exists(Path.Combine(state, "guest-phase.json"))) throw new InvalidOperationException("Permit handoff overwrote foreign-owned state."); + } + else + { + using var failure = JsonDocument.Parse(File.ReadAllText(Path.Combine(state, "guest-phase.json"))); + if (failure.RootElement.GetProperty("token").GetString() != token || failure.RootElement.GetProperty("phase").GetString() != "installation-failed" || failure.RootElement.GetProperty("reason").GetString() != "permit-timeout") + throw new InvalidOperationException("Invalid or missing permit did not publish its owned terminal failure: " + test.Name); + } + } + Save(Path.Combine(output, "permit-handoff-validation.json"), new { success = true, cases = cases.Length, actualGeneratedRecoveryShell = true, actualHostPublicationCommand = true, staleStatFixture = true, sameMailboxInodeAndSize = true, invalidPermitsNeverExecuteInstaller = true, guestExecuted = false }); + } + + static string CreateFullReadiness(string hook) => ReplaceOnce(ReplaceOnce(hook, + " printf '[proof-result] %s: %s\\n' \"$success\" \"$reason\" >&3", + """ + if [ "$success" = true ]; then + local source_commit + source_commit=$(cat "$STATE_DIR/source.commit") + if [ "$(cat "$STATE_DIR/run.owner")" != "$PROOF_TOKEN" ] || + ! [[ "$source_commit" =~ ^[0-9a-f]{40}$ && "$selected_disk" =~ ^/dev/disk[0-9]+$ ]] || + [ -e "$STATE_DIR/install.permit" ] || [ -L "$STATE_DIR/install.permit" ]; then + success=false; reason=invalid_permit_mailbox + else + # The guest creates a fixed-size mailbox before publishing readiness. + # The host updates this same inode without truncating or renaming it. + printf '%32s\n%s\n%s\n' pending "$selected_disk" "$source_commit" > "$STATE_DIR/install.permit" || { + success=false; reason=permit_mailbox_write_failed; + } + fi + fi + printf '[proof-result] %s: %s\n' "$success" "$reason" >&3 + """), " # Keep the service alive for the bounded host diagnostic to capture evidence.\n while :; do sleep 60; done", """ # Full mode waits for the host's independently validated fresh owned-disk permit. if [ "$success" = true ]; then + local permit_token permit_disk permit_commit extra received permit_start=$SECONDS while (( SECONDS - permit_start < 300 )); do - if [ -s "$STATE_DIR/install.permit" ]; then + permit_token=; permit_disk=; permit_commit=; extra=; received=false + # Read the record, not cached share size/entry metadata. Require three + # terminated lines and EOF: partial or foreign permits never authorize. + { + if IFS= read -r permit_token && IFS= read -r permit_disk && IFS= read -r permit_commit; then + if ! IFS= read -r extra && [ -z "$extra" ]; then received=true; fi + fi + } < "$STATE_DIR/install.permit" + if [ "$received" = true ] && [ "$permit_token" = "$PROOF_TOKEN" ] && + [ "$permit_disk" = "$selected_disk" ] && [ "$permit_commit" = "$source_commit" ]; then + [ "$(cat "$STATE_DIR/run.owner")" = "$PROOF_TOKEN" ] || exit 1 + printf '{"token":"%s","sourceCommit":"%s","disk":"%s"}\n' \ + "$PROOF_TOKEN" "$permit_commit" "$permit_disk" > "$STATE_DIR/guest-phase.permit.$$.tmp" && + /bin/mv -f "$STATE_DIR/guest-phase.permit.$$.tmp" "$STATE_DIR/install-permit-received.json" || exit 1 + printf '[full-install] owned permit received and validated\n' >> "$PROOF_LOG" exec /bin/bash "$STATE_DIR/full-install.sh" fi sleep 1 done printf '[full-install] host permit was not received in five minutes\n' >> "$PROOF_LOG" + [ "$(cat "$STATE_DIR/run.owner")" = "$PROOF_TOKEN" ] || exit 1 + printf '{"token":"%s","phase":"installation-failed","reason":"permit-timeout"}\n' "$PROOF_TOKEN" \ + > "$STATE_DIR/guest-phase.permit.$$.tmp" && + /bin/mv -f "$STATE_DIR/guest-phase.permit.$$.tmp" "$STATE_DIR/guest-phase.json" || exit 1 fi while :; do sleep 60; done """); @@ -1126,10 +1272,12 @@ static class NativeDiagnostic if (owner.Output.Trim() != token) throw new InvalidOperationException("Native state owner mismatch."); using var receipt = JsonDocument.Parse(readiness); var disk = receipt.RootElement.GetProperty("disk").GetString(); + var mailbox = await Command("docker", ["exec", id, "sh", "-c", "test -f \"$1\" && test ! -L \"$1\" && cat \"$1\"", "owned-permit-mailbox", FullState + "/install.permit"], output, "owned-permit-mailbox", cancellation); + if (mailbox.Output != "pending".PadLeft(32) + "\n" + disk + "\n" + commit + "\n") throw new InvalidOperationException("Guest permit mailbox does not match fresh readiness."); var permit = Path.Combine(output, "install.permit"); File.WriteAllText(permit, token + "\n" + disk + "\n" + commit + "\n"); await Command("docker", ["cp", permit, id + ":" + FullState + "/install.permit.tmp"], output, "stage-owned-install-permit", cancellation); - await Command("docker", ["exec", id, "mv", FullState + "/install.permit.tmp", FullState + "/install.permit"], output, "authorize-owned-guest-installation", cancellation); + await Command("docker", ["exec", id, "sh", "-c", PermitPublication, "owned-permit-publication", FullState + "/install.permit"], output, "authorize-owned-guest-installation", cancellation); } static readonly string[] NativeFacts = [ @@ -1322,6 +1470,7 @@ static class NativeDiagnostic var files = new List<(string, string)> { ("proof.log", "guest-proof.log"), ("result.json", "guest-result.json") }; if (full) files.AddRange([("guest-phase.json", "guest-phase.json"), ("full-result.json", "full-result.json"), ("firstboot.log", "firstboot.log"), ("install.log", "install.log"), ("apple.log", "apple.log"), ("disk-ownership-ioreg.log", "disk-ownership-ioreg.log"), ("installed-root.plist", "installed-root.plist"), ("apfs-containers.plist", "apfs-containers.plist"), ("physical-store.plist", "physical-store.plist"), ("clt-catalog.log", "clt-catalog.log"), ("clt-install.log", "clt-install.log")]); if (full) files.Add(("clt-sdk.log", "clt-sdk.log")); + if (full) files.Add(("install-permit-received.json", "install-permit-received.json")); foreach (var file in files) { var result = await Command("docker", ["exec", id, "cat", (full ? FullState : "/dev/shm/installstate") + "/" + file.Item1], output, "capture-" + file.Item1.Replace('/', '-'), cancellation, requireSuccess: false);