From 2dd51123adcf505b4d4b547225123a041359bd31 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Tue, 6 Oct 2026 14:05:44 -0700 Subject: [PATCH 01/11] Start attended loops from the daemon when the Windows shell opens them On Windows the terminal pane only runs zmx attach, which created a bare cmd session under the loop's name before anything launched the agent, so an opened Turn-based or main loop never received its first instruction (beta10 D7, AttendedAgentNotLaunched). resumeSession now starts an unresolved, unstopped attended loop with no live session when the platform's panes do not launch sessions (Windows only; macOS unchanged). The shell sends it on every explicit open and attaches only once zmx ls shows the loop's session running; passive auto-attach and layout restore never create a loop session, and an ended loop pane is pruned from the persisted layout. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- GraphcodeKit/Sources/GraphStore.swift | 30 ++- Tools/windows/Tests/WindowsShell.Tests.ps1 | 2 + Tools/windows/windows-shell.ps1 | 23 +++ graphcode-windows/src/App.zig | 51 +++-- graphcode-windows/src/LoopLaunchWait.zig | 198 ++++++++++++++++++ graphcode-windows/src/TerminalSurface.zig | 222 ++++++++++++++++++++- graphcode-windows/src/Wire.zig | 12 +- graphcode-windows/src/WorkspaceLayout.zig | 25 +++ investigation/ui-parity-matrix.md | 2 +- windows-tests/WindowsDaemonTests.swift | 61 ++++++ 10 files changed, 592 insertions(+), 34 deletions(-) create mode 100644 graphcode-windows/src/LoopLaunchWait.zig diff --git a/GraphcodeKit/Sources/GraphStore.swift b/GraphcodeKit/Sources/GraphStore.swift index 3b7f7c63..c128ad1e 100644 --- a/GraphcodeKit/Sources/GraphStore.swift +++ b/GraphcodeKit/Sources/GraphStore.swift @@ -122,6 +122,19 @@ public actor GraphStore { /// Whether a local loop's session is alive and not a husk — what decides if a pane /// closing may resolve the loop (`sessionPermitsResolution`). private let onSessionAlive: (@Sendable (LoopNode, String?) async -> Bool)? + + /// Whether the terminal pane that opens an attended loop launches its session. The Mac + /// app's panes do (`GhosttyTerminalView` starts the agent); the Windows shell's panes only + /// attach, so there opening a loop (`resumeSession`) is the daemon's cue to start it. + private let panesLaunchAttendedSessions: Bool + + public static let platformPanesLaunchAttendedSessions: Bool = { + #if os(Windows) + false + #else + true + #endif + }() /// Cross-graph `.spawn`. `GraphStore` owns exactly one graph and cannot reach another, /// so it hands the request up to `ProjectRegistry`, which is the layer that knows every /// open project — the same split that keeps this actor unaware multi-project routing @@ -403,10 +416,12 @@ public actor GraphStore { recurrence: RecurrenceSink? = nil, presenceReadDeadline: Duration = .seconds(45), drainLeaseDuration: Duration = .seconds(300), - subGraphDepth: Int = 0 + subGraphDepth: Int = 0, + panesLaunchAttendedSessions: Bool = GraphStore.platformPanesLaunchAttendedSessions ) { self.graph = graph self.subGraphDepth = subGraphDepth + self.panesLaunchAttendedSessions = panesLaunchAttendedSessions self.onGraphChanged = onGraphChanged self.onGraphEvent = onGraphEvent self.onConnectionFailure = onConnectionFailure @@ -2555,15 +2570,22 @@ public actor GraphStore { } /// A chat-surface loop (Nod) has no terminal pane whose attach would start its session, - /// so opening it, or sending to it with nothing running, asks for one here. Unattended - /// loops already run; this starts the rest, and is a no-op while a session is alive. + /// so opening it, or sending to it with nothing running, asks for one here. So does an + /// attended terminal loop where panes only attach (`panesLaunchAttendedSessions`). + /// Unattended loops already run; this starts the rest, and is a no-op while a session is + /// alive. private func ensureChatSession(_ node: LoopNode) async { - guard node.backend.surface == .chat, node.state != .stopped, + guard launchesWhenOpened(node), node.state != .stopped, await onSessionAlive?(node, graph.project.path) != true else { return } ensureSession(node) } + private func launchesWhenOpened(_ node: LoopNode) -> Bool { + if node.backend.surface == .chat { return true } + return !panesLaunchAttendedSessions && !node.runsUnattended && node.loopType != .composite + } + /// Arms the end of a resolved loop's session, after the grace the Settings choose — long /// enough for the resolution ask to be answered. No grace configured keeps it. private func scheduleSessionEnd(_ nodeID: UUID, confirming: Bool = false) { diff --git a/Tools/windows/Tests/WindowsShell.Tests.ps1 b/Tools/windows/Tests/WindowsShell.Tests.ps1 index 34aa2cba..11a49925 100644 --- a/Tools/windows/Tests/WindowsShell.Tests.ps1 +++ b/Tools/windows/Tests/WindowsShell.Tests.ps1 @@ -876,6 +876,8 @@ Invoke-Native "Zmx session identity executable tests" { Push-Location $shellRoot try { & $zig test src\ZmxSession.zig + if ($LASTEXITCODE -ne 0) { throw "zmx session identity tests failed" } + & $zig test src\LoopLaunchWait.zig } finally { Pop-Location } } Invoke-Native "Loop bar layout executable tests" { diff --git a/Tools/windows/windows-shell.ps1 b/Tools/windows/windows-shell.ps1 index 12810245..039f3811 100644 --- a/Tools/windows/windows-shell.ps1 +++ b/Tools/windows/windows-shell.ps1 @@ -105,6 +105,27 @@ function Record-TestOwnedSessions { } } +# The stub stands in for graphcoded, which owns starting a loop's zmx session; the shell +# only attaches to a loop session that already exists and never creates one. +function Start-StubLoopSessions { + if (-not $UseStubDaemon) { return } + foreach ($id in $testSessionIds) { + $name = "graphcode-$id" + $probe = Start-Process -FilePath $env:GRAPHCODE_ZMX -ArgumentList @("info", $name) ` + -PassThru -WindowStyle Hidden + $probe.WaitForExit() + if ($probe.ExitCode -eq 0) { continue } + # WaitForExit on the process only: the detached session it starts outlives it. + $run = Start-Process -FilePath $env:GRAPHCODE_ZMX -ArgumentList @("run", $name, "-d") ` + -PassThru -WindowStyle Hidden + $run.WaitForExit() + if ($run.ExitCode -ne 0) { + throw "zmx could not start stub loop session $name (exit $($run.ExitCode))" + } + } + Record-TestOwnedSessions +} + function Write-OwnedResourceMetrics([string] $phase, [int[]] $focusPids = @()) { $script:metricSequence++ $metricPids = if ($focusPids.Count -gt 0) { @@ -131,6 +152,7 @@ function Write-OwnedResourceMetrics([string] $phase, [int[]] $focusPids = @()) { } function Invoke-ShellProcess([string[]] $arguments, [string] $phase) { + Start-StubLoopSessions $script:shellProcess = Start-Process -FilePath $app -ArgumentList $arguments -PassThru -WindowStyle Hidden [void] $ownedProcessIds.Add($script:shellProcess.Id) Start-Sleep -Milliseconds 250 @@ -335,6 +357,7 @@ try { $env:GRAPHCODE_SHELL_NONREADING_ATTACH = "1" $env:GRAPHCODE_SHELL_LARGE_PASTE = "1" Remove-Item -LiteralPath $inputError -Force -ErrorAction SilentlyContinue + Start-StubLoopSessions $inputDeadline = [DateTime]::UtcNow.AddSeconds(8) $inputApp = Start-Process -FilePath $app -ArgumentList @("--smoke") -PassThru ` -RedirectStandardError $inputError diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index 8f6b59da..e32b58c3 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -1695,17 +1695,34 @@ pub const App = struct { fn refreshWorkspace(self: *App) void { const workspace = if (self.workspace) |value| value else return; const graph = if (self.model.graph) |value| value else return; - if (graph.nodes.items.len > 0 and !workspace.hasSurface(0)) { - workspace.openNode(0, graph.nodes.items[0].id) catch { - self.setStatus("Unable to attach terminal A"); + // Only shows loops that are already running: attaching here must never create a + // session, or a loop's agent loses the race to a bare shell (LoopLaunchWait). + for (0..@min(graph.nodes.items.len, 2)) |pane| { + if (workspace.hasSurface(pane) or workspace.isAwaitingLaunch(pane)) continue; + workspace.openLaunchedNode(pane, graph.nodes.items[pane].id, 0) catch { + self.setStatus(if (pane == 0) "Unable to attach terminal A" else "Unable to attach terminal B"); }; } + } - if (graph.nodes.items.len > 1 and !workspace.hasSurface(1)) { - workspace.openNode(1, graph.nodes.items[1].id) catch { - self.setStatus("Unable to attach terminal B"); - }; - } + /// Asks the daemon to start an attended loop's agent — a no-op for a loop already + /// running — and opens its pane once that session exists, never as a bare shell. + fn openGraphLoop(self: *App, workspace: *TerminalWorkspace.Workspace, project_path: []const u8, node_id: []const u8) void { + self.client.sendNodeAction(project_path, node_id, "resumeSession", null); + workspace.openLaunchedNode(0, node_id, TerminalWorkspace.Workspace.loop_open_timeout_ms) catch { + self.setStatus("Unable to open selected loop"); + return; + }; + if (workspace.isAwaitingLaunch(0)) self.setStatus("Starting loop"); + } + + fn reportLaunchOutcome(self: *App, workspace: *TerminalWorkspace.Workspace) void { + const outcome = workspace.takeLaunchOutcome() orelse return; + self.setStatus(switch (outcome) { + .started => "Loop opened", + .not_started => "Loop session did not start; check the loop's agent and try again", + .attach_failed => "Unable to open selected loop", + }); } fn rebindWorkspace(self: *App, path: []const u8) void { @@ -3313,14 +3330,13 @@ pub const App = struct { return; } const workspace = if (self.workspace) |value| value else return; + const project_path = self.currentProject() orelse return; self.workspace_is_quick_chat = false; self.surface = .workspace; self.workspace_controls.panel_visible = true; self.layoutWorkspace(); self.layoutEmptyStateControls(); - workspace.openNode(0, graph.nodes.items[index].id) catch { - self.setStatus("Unable to open selected node"); - }; + self.openGraphLoop(workspace, project_path, graph.nodes.items[index].id); } fn stopSelectedNode(self: *App) void { @@ -6638,9 +6654,7 @@ pub const App = struct { self.clearEdgeSelection(); self.rebindWorkspace(project_path); if (self.workspace) |workspace| { - workspace.openNode(0, graph.nodes.items[index].id) catch { - self.setStatus("Unable to open selected loop"); - }; + self.openGraphLoop(workspace, project_path, graph.nodes.items[index].id); workspace.focusRestoredPane() catch self.setStatus("Unable to restore selected terminal focus"); } self.syncAccessibility(); @@ -7551,6 +7565,7 @@ fn onWindowMessage( if (app.client.isIdle()) app.smoke_idle_ticks += 1 else app.smoke_idle_ticks = 0; if (app.workspace) |workspace| { workspace.poll(); + app.reportLaunchOutcome(workspace); if (!app.smoke_workspace_restart_observed) { if (app.smoke_restart_index) |index| { if (workspace.surfaceIdentityReady(index, app.smoke_restart_session, workspace.projectPath())) { @@ -7823,9 +7838,7 @@ fn onWindowMessage( app.rebindWorkspace(graph.project.path); _ = app.selectNodeIndex(hit.node_index); if (app.workspace) |workspace| { - workspace.openNode(0, graph.nodes.items[hit.node_index].id) catch { - app.setStatus("Unable to open selected loop"); - }; + app.openGraphLoop(workspace, graph.project.path, graph.nodes.items[hit.node_index].id); workspace.focusRestoredPane() catch app.setStatus("Unable to restore selected terminal focus"); } } @@ -8034,9 +8047,7 @@ fn onWindowMessage( if (row.index >= selected_graph.nodes.items.len) return true; _ = app.selectNodeIndex(row.index); if (app.workspace) |workspace| { - workspace.openNode(0, selected_graph.nodes.items[row.index].id) catch { - app.setStatus("Unable to open selected loop"); - }; + app.openGraphLoop(workspace, path, selected_graph.nodes.items[row.index].id); workspace.focusRestoredPane() catch app.setStatus("Unable to restore selected terminal focus"); } } diff --git a/graphcode-windows/src/LoopLaunchWait.zig b/graphcode-windows/src/LoopLaunchWait.zig new file mode 100644 index 00000000..19ac75ca --- /dev/null +++ b/graphcode-windows/src/LoopLaunchWait.zig @@ -0,0 +1,198 @@ +//! Waits for the daemon to start a graph loop's zmx session before a pane attaches. +//! +//! `zmx attach` creates a plain shell session when the named one is not reachable, so a +//! pane that attached first would win the race against the daemon's launch and the loop's +//! agent would never start. The pane instead probes (`zmx info`, which never creates) +//! until the session exists, then attaches. Pure state so it is testable without zmx. + +const std = @import("std"); +const ZmxSession = @import("ZmxSession.zig"); + +pub const probe_interval_ms: i64 = 250; +/// How long an explicit open waits for the daemon to start the loop's agent. +pub const open_timeout_ms: i64 = 30_000; +/// After a passive check found nothing, how long before another passive check. +pub const passive_retry_ms: i64 = 5_000; + +pub const Probe = enum { running, live, missing }; +pub const Step = enum { idle, start_probe, attach, give_up }; + +/// Whether a persisted pane is restored. A shell tab is (its session is recreated if it +/// ended); a loop pane only while the daemon's session is still running. Restoring it +/// otherwise would create a bare shell under the loop's name, and every later open would +/// then find the loop "running" and never launch its agent. +pub fn restoreKeepsPane(launches_agent: bool, session_live: bool) bool { + return !launches_agent or session_live; +} + +pub const Wait = struct { + session: []u8 = &.{}, + deadline_ms: i64 = 0, + next_probe_ms: i64 = 0, + probing: bool = false, + /// An explicit open reports a launch that never came; a passive check stays quiet. + reports_timeout: bool = false, + + pub fn active(self: *const Wait) bool { + return self.session.len != 0; + } + + pub fn begin(self: *Wait, session: []u8, now_ms: i64, timeout_ms: i64, reports_timeout: bool) void { + self.* = .{ + .session = session, + .deadline_ms = now_ms + @max(timeout_ms, 0), + .next_probe_ms = now_ms, + .reports_timeout = reports_timeout, + }; + } + + /// An explicit open of a loop already being waited on keeps the wait, but no longer + /// gives up sooner than the open asks and reports if it does. + pub fn extend(self: *Wait, now_ms: i64, timeout_ms: i64, reports_timeout: bool) void { + self.deadline_ms = @max(self.deadline_ms, now_ms + @max(timeout_ms, 0)); + self.reports_timeout = self.reports_timeout or reports_timeout; + } + + /// The caller owns `session` again once this returns. + pub fn finish(self: *Wait) []u8 { + const session = self.session; + self.* = .{}; + return session; + } + + /// `probe` is the outcome of the probe in flight, or null when none was started. + pub fn step(self: *Wait, now_ms: i64, probe: ?Probe) Step { + if (!self.active()) return .idle; + if (self.probing) { + const outcome = probe orelse .missing; + switch (outcome) { + .running => return .idle, + .live => { + self.probing = false; + return .attach; + }, + .missing => { + self.probing = false; + if (now_ms >= self.deadline_ms) return .give_up; + self.next_probe_ms = now_ms + probe_interval_ms; + return .idle; + }, + } + } + if (now_ms < self.next_probe_ms) return .idle; + self.probing = true; + return .start_probe; + } +}; + +/// `zmx ls`, never `info` or `attach`: only the listing says whether a session's task has +/// ended, and attaching to such a husk exits at once. +pub fn probeArguments(program: []const u8, output: *[2][]const u8) []const []const u8 { + output.* = .{ program, "ls" }; + return output; +} + +/// The daemon's own test (`ZmxSessionLauncher.isSessionAlive`): the session is listed and +/// its task has neither ended nor become unreachable. +pub fn listingShowsLive(listing: []const u8, session: []const u8) bool { + var name_buffer: [ZmxSession.prefix.len + 128]u8 = undefined; + const name = ZmxSession.nameBuffer(session, &name_buffer) catch return false; + var lines = std.mem.splitScalar(u8, listing, '\n'); + while (lines.next()) |raw| { + const line = std.mem.trimRight(u8, raw, "\r"); + if (std.mem.indexOf(u8, line, "\tended=") != null or + std.mem.indexOf(u8, line, "\texit_code=") != null or + std.mem.indexOf(u8, line, "\terr=") != null) continue; + var fields = std.mem.tokenizeAny(u8, line, " \t"); + while (fields.next()) |field| { + if (std.mem.startsWith(u8, field, "name=") and std.mem.eql(u8, field["name=".len..], name)) return true; + } + } + return false; +} + +test "an explicit open probes until the daemon's session exists, then attaches" { + var name = "11111111-1111-4111-8111-111111111111".*; + var wait: Wait = .{}; + wait.begin(&name, 1_000, open_timeout_ms, true); + try std.testing.expectEqual(Step.start_probe, wait.step(1_000, null)); + try std.testing.expectEqual(Step.idle, wait.step(1_010, .running)); + try std.testing.expectEqual(Step.idle, wait.step(1_100, .missing)); + try std.testing.expectEqual(Step.idle, wait.step(1_200, null)); + try std.testing.expectEqual(Step.start_probe, wait.step(1_350, null)); + try std.testing.expectEqual(Step.attach, wait.step(1_400, .live)); + try std.testing.expectEqualStrings(&name, wait.finish()); + try std.testing.expect(!wait.active()); +} + +test "an open whose launch never comes gives up at the deadline and says so" { + var name = "loop".*; + var wait: Wait = .{}; + wait.begin(&name, 0, 500, true); + var now: i64 = 0; + var outcome = Step.idle; + while (now <= 10_000 and outcome != .give_up) : (now += 50) { + outcome = wait.step(now, if (wait.probing) .missing else null); + try std.testing.expect(outcome != .attach); + } + try std.testing.expectEqual(Step.give_up, outcome); + try std.testing.expect(now - 50 >= 500); + try std.testing.expect(wait.reports_timeout); +} + +test "a passive check probes once and never creates or retries a missing session" { + var name = "loop".*; + var wait: Wait = .{}; + wait.begin(&name, 5, 0, false); + try std.testing.expectEqual(Step.start_probe, wait.step(5, null)); + try std.testing.expectEqual(Step.give_up, wait.step(90, .missing)); + try std.testing.expect(!wait.reports_timeout); +} + +test "a failed probe spawn counts as missing rather than wedging the wait" { + var name = "loop".*; + var wait: Wait = .{}; + wait.begin(&name, 0, 1_000, true); + try std.testing.expectEqual(Step.start_probe, wait.step(0, null)); + try std.testing.expectEqual(Step.idle, wait.step(1, null)); + try std.testing.expectEqual(Step.start_probe, wait.step(251, null)); +} + +test "re-opening a loop being waited on extends the wait without shortening it" { + var name = "loop".*; + var wait: Wait = .{}; + wait.begin(&name, 0, 0, false); + wait.extend(100, open_timeout_ms, true); + try std.testing.expectEqual(@as(i64, 100 + open_timeout_ms), wait.deadline_ms); + try std.testing.expect(wait.reports_timeout); + wait.extend(200, 0, false); + try std.testing.expectEqual(@as(i64, 100 + open_timeout_ms), wait.deadline_ms); + try std.testing.expect(wait.reports_timeout); +} + +test "a restored loop pane is kept only while its session runs; a shell tab always is" { + try std.testing.expect(restoreKeepsPane(true, true)); + try std.testing.expect(!restoreKeepsPane(true, false)); + try std.testing.expect(restoreKeepsPane(false, false)); + try std.testing.expect(restoreKeepsPane(false, true)); +} + +test "the probe lists sessions without attaching to or creating one" { + var output: [2][]const u8 = undefined; + const argv = probeArguments("zmx.exe", &output); + try std.testing.expectEqual(@as(usize, 2), argv.len); + try std.testing.expectEqualStrings("zmx.exe", argv[0]); + try std.testing.expectEqualStrings("ls", argv[1]); +} + +test "a listed session counts as live only while its task runs" { + const listing = + "name=graphcode-00000000-0000-4000-8000-0A6CC9277ED5\tpid=11048\tclients=0\tcreated=1791318218\tcwd=C:\\p\tended=1791318818\texit_code=0\r\n" ++ + "name=graphcode-00000000-0000-4000-8000-0B492B5F5249\tpid=23500\tclients=1\tcreated=1791319165\tcwd=C:\\p\tcmd=copilot --interactive x\r\n" ++ + " name=graphcode-gone\terr=PipeBusy\tstatus=unreachable\n"; + try std.testing.expect(listingShowsLive(listing, "00000000-0000-4000-8000-0B492B5F5249")); + try std.testing.expect(!listingShowsLive(listing, "00000000-0000-4000-8000-0A6CC9277ED5")); + try std.testing.expect(!listingShowsLive(listing, "gone")); + try std.testing.expect(!listingShowsLive(listing, "00000000-0000-4000-8000-0B492B5F524")); + try std.testing.expect(!listingShowsLive("", "anything")); +} diff --git a/graphcode-windows/src/TerminalSurface.zig b/graphcode-windows/src/TerminalSurface.zig index 5a2e54c8..b6e21656 100644 --- a/graphcode-windows/src/TerminalSurface.zig +++ b/graphcode-windows/src/TerminalSurface.zig @@ -8,6 +8,7 @@ const GdiGradient = @import("GdiGradient.zig"); const Dpi = @import("Dpi.zig"); const TerminalVt = @import("TerminalVt.zig"); const ZmxSession = @import("ZmxSession.zig"); +const LoopLaunchWait = @import("LoopLaunchWait.zig"); const columns: usize = 120; const rows: usize = 40; @@ -339,6 +340,13 @@ fn moveReplacementSurface( } pub const Workspace = struct { + pub const LaunchOutcome = enum { started, not_started, attach_failed }; + pub const loop_open_timeout_ms = LoopLaunchWait.open_timeout_ms; + // A `zmx info` that outlives its wait's deadline by this much is treated as no answer. + const launch_probe_grace_ms: i64 = 5_000; + const restore_probe_timeout_ms: i64 = 2_000; + const max_listing_bytes: usize = 1 << 20; + parent: c.HWND, host: ?*c.winghostty_host = null, surfaces: [max_surfaces]Surface = [_]Surface{.{}} ** max_surfaces, @@ -351,6 +359,13 @@ pub const Workspace = struct { recreate_due_ms: [max_surfaces]i64 = [_]i64{0} ** max_surfaces, recreate_delay_ms: [max_surfaces]i64 = [_]i64{100} ** max_surfaces, restore_errors: [max_surfaces][]u8 = [_][]u8{&.{}} ** max_surfaces, + // A graph loop's pane attaches only after the daemon's session exists (LoopLaunchWait). + launch_waits: [max_surfaces]LoopLaunchWait.Wait = [_]LoopLaunchWait.Wait{.{}} ** max_surfaces, + launch_probes: [max_surfaces]?std.process.Child = [_]?std.process.Child{null} ** max_surfaces, + launch_probe_output: [max_surfaces]std.ArrayListUnmanaged(u8) = [_]std.ArrayListUnmanaged(u8){.empty} ** max_surfaces, + daemon_sessions: [max_surfaces]bool = [_]bool{false} ** max_surfaces, + passive_retry_due_ms: [max_surfaces]i64 = [_]i64{0} ** max_surfaces, + launch_outcome: ?LaunchOutcome = null, fatal_error: bool = false, render_error: c.winghostty_result = c.WINGHOSTTY_OK, input_mutex: std.Thread.Mutex = .{}, @@ -445,6 +460,7 @@ pub const Workspace = struct { pub fn deinit(self: *Workspace) void { self.stopResizeChild(); self.stopInputWorker(); + self.cancelAllLaunchWaits(); for (self.surfaces, 0..) |_, index| self.destroySurface(index); for (&self.recreate_sessions) |*session| { if (session.*.len != 0) self.allocator.free(session.*); @@ -570,7 +586,177 @@ pub const Workspace = struct { } + /// Attaches a pane to a session, creating a plain shell session when none is running — + /// right for a shell tab, wrong for a graph loop, whose pane uses `openLaunchedNode`. pub fn openNode(self: *Workspace, index: usize, node_id: []const u8) !void { + if (index >= self.surfaces.len) return error.InvalidSurface; + self.cancelLaunchWait(index); + self.daemon_sessions[index] = false; + try self.attachNode(index, node_id, false); + } + + /// Opens a graph loop's pane once the daemon has started its session, never creating + /// one itself. A `timeout_ms` of 0 is a passive check: attach if the loop is already + /// running, otherwise leave the pane alone. + pub fn openLaunchedNode(self: *Workspace, index: usize, node_id: []const u8, timeout_ms: i64) !void { + if (index >= self.surfaces.len) return error.InvalidSurface; + const slot = &self.surfaces[index]; + if ((slot.surface != null or slot.attach != null) and + std.mem.eql(u8, slot.session_name, node_id)) return; + const now = nowMilliseconds(); + const explicit = timeout_ms > 0; + const wait = &self.launch_waits[index]; + if (wait.active() and std.mem.eql(u8, wait.session, node_id)) { + wait.extend(now, timeout_ms, explicit); + return; + } + if (!explicit and now < self.passive_retry_due_ms[index]) return; + const session = try self.allocator.dupe(u8, node_id); + self.cancelLaunchWait(index); + self.clearRecreateSession(index); + self.launch_waits[index].begin(session, now, timeout_ms, explicit); + } + + pub fn isAwaitingLaunch(self: *const Workspace, index: usize) bool { + return index < self.launch_waits.len and self.launch_waits[index].active(); + } + + pub fn takeLaunchOutcome(self: *Workspace) ?LaunchOutcome { + defer self.launch_outcome = null; + return self.launch_outcome; + } + + /// Asks zmx, without attaching or creating, whether a session is running. Bounded so a + /// wedged zmx cannot hold the UI thread. + fn sessionIsLive(self: *Workspace, session: []const u8) bool { + var child = self.spawnListing() orelse return false; + var output: std.ArrayListUnmanaged(u8) = .empty; + defer output.deinit(self.allocator); + const deadline = nowMilliseconds() + restore_probe_timeout_ms; + var exit_code: c.DWORD = c.STILL_ACTIVE; + while (true) { + self.drainListing(&child, &output); + if (c.GetExitCodeProcess(child.id, &exit_code) == 0) break; + if (exit_code != c.STILL_ACTIVE) break; + if (nowMilliseconds() >= deadline) break; + std.Thread.sleep(10 * std.time.ns_per_ms); + } + if (exit_code == c.STILL_ACTIVE) { + _ = child.kill() catch {}; + return false; + } + self.drainListing(&child, &output); + _ = child.wait() catch {}; + return exit_code == 0 and LoopLaunchWait.listingShowsLive(output.items, session); + } + + fn spawnListing(self: *Workspace) ?std.process.Child { + var args: [2][]const u8 = undefined; + var child = std.process.Child.init(LoopLaunchWait.probeArguments(self.zmx_path, &args), self.allocator); + child.cwd = self.cwd; + child.stdin_behavior = .Ignore; + child.stdout_behavior = .Pipe; + child.stderr_behavior = .Ignore; + child.create_no_window = true; + child.spawn() catch return null; + return child; + } + + /// Reads whatever the listing has written so far, so a long one never blocks on a + /// full pipe. Bounded: the listing is a few hundred bytes per session. + fn drainListing(self: *Workspace, child: *std.process.Child, output: *std.ArrayListUnmanaged(u8)) void { + const stdout = child.stdout orelse return; + var buffer: [4096]u8 = undefined; + while (output.items.len < max_listing_bytes) { + var available: c.DWORD = 0; + if (c.PeekNamedPipe(stdout.handle, null, 0, null, &available, null) == 0 or available == 0) return; + var count: c.DWORD = 0; + const want: c.DWORD = @intCast(@min(buffer.len, available)); + if (c.ReadFile(stdout.handle, &buffer, want, &count, null) == 0 or count == 0) return; + output.appendSlice(self.allocator, buffer[0..count]) catch return; + } + } + + fn pollLaunchWaits(self: *Workspace) void { + const now = nowMilliseconds(); + for (&self.launch_waits, 0..) |*wait, index| { + if (!wait.active()) continue; + const outcome = self.launchProbeOutcome(index, now >= wait.deadline_ms + launch_probe_grace_ms); + switch (wait.step(now, outcome)) { + .idle => {}, + .start_probe => self.startLaunchProbe(index), + .attach => { + const explicit = wait.reports_timeout; + const session = wait.finish(); + defer self.allocator.free(session); + self.attachNode(index, session, true) catch { + if (explicit) self.launch_outcome = .attach_failed; + continue; + }; + self.daemon_sessions[index] = true; + if (explicit) { + self.launch_outcome = .started; + self.focusRestoredPane() catch {}; + } + }, + .give_up => { + if (wait.reports_timeout) { + self.launch_outcome = .not_started; + } else { + self.passive_retry_due_ms[index] = now + LoopLaunchWait.passive_retry_ms; + } + self.allocator.free(wait.finish()); + }, + } + } + } + + fn startLaunchProbe(self: *Workspace, index: usize) void { + self.launch_probe_output[index].clearRetainingCapacity(); + self.launch_probes[index] = self.spawnListing(); + } + + fn launchProbeOutcome(self: *Workspace, index: usize, overdue: bool) ?LoopLaunchWait.Probe { + const child = if (self.launch_probes[index]) |*value| value else return null; + const output = &self.launch_probe_output[index]; + self.drainListing(child, output); + var exit_code: c.DWORD = 0; + if (c.GetExitCodeProcess(child.id, &exit_code) == 0 or + (exit_code == c.STILL_ACTIVE and overdue)) + { + _ = child.kill() catch {}; + self.launch_probes[index] = null; + return .missing; + } + if (exit_code == c.STILL_ACTIVE) return .running; + self.drainListing(child, output); + _ = child.wait() catch {}; + self.launch_probes[index] = null; + const live = exit_code == 0 and + LoopLaunchWait.listingShowsLive(output.items, self.launch_waits[index].session); + return if (live) .live else .missing; + } + + fn cancelLaunchWait(self: *Workspace, index: usize) void { + if (self.launch_probes[index]) |*child| { + _ = child.kill() catch {}; + self.launch_probes[index] = null; + } + self.launch_probe_output[index].clearAndFree(self.allocator); + if (self.launch_waits[index].active()) self.allocator.free(self.launch_waits[index].finish()); + } + + fn cancelAllLaunchWaits(self: *Workspace) void { + for (0..max_surfaces) |index| { + self.cancelLaunchWait(index); + self.daemon_sessions[index] = false; + self.passive_retry_due_ms[index] = 0; + } + self.launch_outcome = null; + } + + /// `loop_pane` marks a pane the daemon owns, so a restore never recreates its session. + fn attachNode(self: *Workspace, index: usize, node_id: []const u8, loop_pane: bool) !void { if (index >= self.surfaces.len) return error.InvalidSurface; if (self.surfaces[index].surface != null or self.surfaces[index].attach != null) { const old_id = try self.allocator.dupe(u8, self.surfaces[index].session_name); @@ -581,6 +767,7 @@ pub const Workspace = struct { self.destroySurface(replacement_index); return err; }; + _ = self.layout.setLaunchesAgent(node_id, loop_pane); self.persistLayout() catch |err| { self.layout.replacePaneID(node_id, old_id) catch {}; self.destroySurface(replacement_index); @@ -603,7 +790,7 @@ pub const Workspace = struct { if (self.layout.tabs.items.len == 0) { try self.layout.addTab(node_id, true); } else if (index > 0 and self.layout.tabs.items.len == 1) { - try self.layout.addTab(node_id, false); + try self.layout.addTab(node_id, loop_pane); } try self.persistLayout(); var options = self.surfaceOptions(index); @@ -686,30 +873,41 @@ pub const Workspace = struct { fn restorePersistedSurfaces(self: *Workspace) void { var ids: [max_surfaces][]u8 = undefined; + var agents: [max_surfaces]bool = undefined; var count: usize = 0; for (self.layout.tabs.items) |tab| for (tab.panes.items) |pane| { if (count == ids.len) break; ids[count] = self.allocator.dupe(u8, pane.id) catch continue; + agents[count] = pane.launches_agent; count += 1; }; defer for (ids[0..count]) |id| self.allocator.free(id); - for (ids[0..count]) |id| { + var pruned = false; + for (ids[0..count], agents[0..count]) |id, launches_agent| { + const live = launches_agent and self.sessionIsLive(id); + if (!LoopLaunchWait.restoreKeepsPane(launches_agent, live)) { + pruned = self.layout.removePane(id) or pruned; + continue; + } const initial_grid = self.gridForSession(id) catch |err| { - self.queueRestoreRetry(id, err); + self.queueRestoreRetry(id, err, launches_agent); continue; }; if (self.createAttachedSurface(id, initial_grid)) |index| { + self.daemon_sessions[index] = launches_agent; self.clearRestoreError(index); } else |err| { - self.queueRestoreRetry(id, err); + self.queueRestoreRetry(id, err, launches_agent); } } + if (pruned) self.persistLayout() catch {}; self.syncTopology(); } - fn queueRestoreRetry(self: *Workspace, session: []const u8, err: anyerror) void { + fn queueRestoreRetry(self: *Workspace, session: []const u8, err: anyerror, daemon_session: bool) void { for (self.surfaces, 0..) |slot, index| { if (slot.surface == null and slot.attach == null and self.recreate_sessions[index].len == 0) { + self.daemon_sessions[index] = daemon_session; self.recreate_sessions[index] = self.allocator.dupe(u8, session) catch &.{}; self.recreate_due_ms[index] = nowMilliseconds() + self.recreate_delay_ms[index]; const message = std.fmt.allocPrint(self.allocator, "workspace restore pending: {s}", .{@errorName(err)}) catch return; @@ -730,6 +928,7 @@ pub const Workspace = struct { } fn clearAllRecreateState(self: *Workspace) void { + self.cancelAllLaunchWaits(); for (&self.recreate_sessions, 0..) |*session, index| { if (session.*.len != 0) self.allocator.free(session.*); session.* = &.{}; @@ -862,7 +1061,8 @@ pub const Workspace = struct { const session = if (self.surfaces[index].session_name.len == 0) return else try self.allocator.dupe(u8, self.surfaces[index].session_name); defer self.allocator.free(session); self.destroySurface(index); - try self.openNode(index, session); + if (self.daemon_sessions[index]) return self.openLaunchedNode(index, session, 0); + try self.attachNode(index, session, false); } pub fn resize(self: *Workspace, origin_x: i32, origin_y: i32, width: i32, height: i32) void { @@ -1067,6 +1267,7 @@ pub const Workspace = struct { // sessions server-side, so it's safe to stop draining the local attach pipe while hidden. if (self.collapsed) return; for (self.surfaces, 0..) |_, index| self.readAttachOutput(index); + self.pollLaunchWaits(); self.pollRecreates(); self.pollResizeControl(); } @@ -1888,7 +2089,14 @@ pub const Workspace = struct { if (session.len == 0 or self.surfaces[index].surface != null or now < self.recreate_due_ms[index]) { continue; } - self.openNode(index, session) catch { + // A loop whose session ended is not re-created as a bare shell; it is re-attached + // only if the daemon's session is still there. + if (self.daemon_sessions[index]) { + self.openLaunchedNode(index, session, 0) catch {}; + self.clearRecreateSession(index); + continue; + } + self.attachNode(index, session, false) catch { self.recreate_due_ms[index] = now + self.recreate_delay_ms[index]; self.recreate_delay_ms[index] = @min(self.recreate_delay_ms[index] * 2, 4_000); continue; diff --git a/graphcode-windows/src/Wire.zig b/graphcode-windows/src/Wire.zig index e66b3f0d..60f7585d 100644 --- a/graphcode-windows/src/Wire.zig +++ b/graphcode-windows/src/Wire.zig @@ -475,11 +475,13 @@ pub fn commandGraphNodeAction( }); } - if (std.mem.eql(u8, action, "stopNode")) { + if (std.mem.eql(u8, action, "stopNode") or std.mem.eql(u8, action, "resumeSession")) { return std.mem.concat(allocator, u8, &.{ "{\"graphCommand\":{\"projectPath\":", quoted_path, - ",\"command\":{\"stopNode\":{\"_0\":", + ",\"command\":{\"", + action, + "\":{\"_0\":", quoted_node, "}}}}", }); @@ -1281,6 +1283,12 @@ test "graph commands match Swift Codable associated-value shapes" { "{\"graphCommand\":{\"projectPath\":\"C:\\\\work\\\\graph\",\"command\":{\"stopNode\":{\"_0\":\"11111111-1111-4111-8111-111111111111\"}}}}", stop, ); + const resume_session = try commandGraphNodeAction(allocator, project, node, "resumeSession", null); + defer allocator.free(resume_session); + try std.testing.expectEqualStrings( + "{\"graphCommand\":{\"projectPath\":\"C:\\\\work\\\\graph\",\"command\":{\"resumeSession\":{\"_0\":\"11111111-1111-4111-8111-111111111111\"}}}}", + resume_session, + ); const detach_template = try commandGraphDetachTemplate(allocator, project, node); defer allocator.free(detach_template); try std.testing.expectEqualStrings( diff --git a/graphcode-windows/src/WorkspaceLayout.zig b/graphcode-windows/src/WorkspaceLayout.zig index 8f6f0608..9cb87530 100644 --- a/graphcode-windows/src/WorkspaceLayout.zig +++ b/graphcode-windows/src/WorkspaceLayout.zig @@ -214,6 +214,17 @@ pub const Layout = struct { return error.InvalidSurface; } + /// Records whether a pane's session is a loop the daemon starts (see `Pane`). + pub fn setLaunchesAgent(self: *Layout, id: []const u8, launches_agent: bool) bool { + for (self.tabs.items) |*tab| for (tab.panes.items) |*pane| { + if (std.mem.eql(u8, pane.id, id)) { + pane.launches_agent = launches_agent; + return true; + } + }; + return false; + } + pub fn removePane(self: *Layout, id: []const u8) bool { for (self.tabs.items, 0..) |*tab, tab_index| { for (tab.panes.items, 0..) |pane, pane_index| { @@ -433,6 +444,20 @@ test "validated persistence rejects corruption and scopes projects" { )); } +test "a loop opened into a shell tab's slot is persisted as a loop pane" { + var layout = try Layout.init(std.testing.allocator, "project-a"); + defer layout.deinit(); + try layout.addTab("shell-tab", false); + try layout.replacePaneID("shell-tab", "loop-node"); + try std.testing.expect(layout.setLaunchesAgent("loop-node", true)); + try std.testing.expect(!layout.setLaunchesAgent("missing", true)); + try layout.save("workspace-layout-agent-test.json"); + defer std.fs.cwd().deleteFile("workspace-layout-agent-test.json") catch {}; + var restored = try Layout.load(std.testing.allocator, "workspace-layout-agent-test.json", "project-a"); + defer restored.deinit(); + try std.testing.expect(restored.tabs.items[0].panes.items[0].launches_agent); +} + test "generated surface IDs are unique across tabs" { var layout = try Layout.init(std.testing.allocator, "project"); defer layout.deinit(); diff --git a/investigation/ui-parity-matrix.md b/investigation/ui-parity-matrix.md index c9e7c07a..edb59455 100644 --- a/investigation/ui-parity-matrix.md +++ b/investigation/ui-parity-matrix.md @@ -178,7 +178,7 @@ native keyboard/accelerator/window proof; the workspace row remains Partial. | macOS surface | Required visible behavior | Windows evidence | Status | |---|---|---|---| | Terminal VT state and rendering | Incremental VT parsing, complete Unicode text, styles, cursor, resize and scrollback with matching visible output | Explicit startup opt-in `GRAPHCODE_EXPERIMENTAL_TERMINAL_VT=1` uses the existing pinned public scalar libghostty-vt API; default ASCII behavior is unchanged. Real-library memory tests cover chunk boundaries, grapheme arrays/UTF-16 offsets, wide occupancy, colors, alternate screen, viewport/reflow, owned snapshots, allocation failures, and bounded pane-owned replies through the production input queue. The same publication seam fails with the legacy parser and passes with the opt-in state. Strict projection rejects unsupported clusters/wide/decorated cells with explicit unconfirmed-render status while preserving authoritative accessible text; the host remains a one-codepoint 5x7 renderer, not a full Unicode/glyph renderer. The earlier "production is still 120x40" claim was stale: `TerminalSurface.zig` already derives each pane's grid from live pane bounds and the measured cell metrics, and resizes the backend surface when the topology syncs. The remaining defect found there was degenerate geometry — a minimized or zero-sized pane clamped the grid to 1x1 and the old size was lost on restore. `paneBounds` now guards zero-sized rects and `syncPaneGrid` takes an injected resize callback so the grid-state transition is testable without the native provider, with behavioral minimize-then-restore tests asserting the pre-minimize grid is preserved. That is unit evidence over the pure state transition; live PTY resize negotiation against a real backend is still not demonstrated. Wheel/selection, visible cursor, native TextPattern/glyph parity, and OSC 7 PWD retention remain residuals. Memory tests and a build do not establish native UI parity or SIMD performance. | Partial | -| Workspace detail screen | Selected loop replaces canvas detail while sidebar remains | Selecting a sidebar or overview loop now replaces the canvas detail with the full terminal workspace while retaining the sidebar; the workspace UIA tree now exposes a destination-specific toolbar, loop bar, tab controls, and Show in Graph action. The `windows-shell` CI job builds the real Swift daemon, Zig shell, and pinned zmx/Winghostty providers and runs `Tools\windows\uia-live-gate.ps1` against the live workspace: the gate independently asserts the toolbar identity child, Show in Graph child, all three split controls, and at least one tab child are present under a real, non-gated `Workspace.init()` (commit `a31813b`, run https://github.com/scgopi/GraphCode/actions/runs/35415967793, passing) | Validated | +| Workspace detail screen | Selected loop replaces canvas detail while sidebar remains | Selecting a sidebar or overview loop now replaces the canvas detail with the full terminal workspace while retaining the sidebar; the workspace UIA tree now exposes a destination-specific toolbar, loop bar, tab controls, and Show in Graph action. The `windows-shell` CI job builds the real Swift daemon, Zig shell, and pinned zmx/Winghostty providers and runs `Tools\windows\uia-live-gate.ps1` against the live workspace: the gate independently asserts the toolbar identity child, Show in Graph child, all three split controls, and at least one tab child are present under a real, non-gated `Workspace.init()` (commit `a31813b`, run https://github.com/scgopi/GraphCode/actions/runs/35415967793, passing). Beta10 Dev Box qualification (D7) then found that opening an attended (Turn-based or main) loop showed a bare `cmd` shell: the pane's `zmx attach` created the session before the daemon launched the agent, so the first instruction was never sent. The shell now asks the daemon to start the loop (`resumeSession`) and attaches only once its session is running, and restores persisted loop panes only while their session is live. That fix has unit evidence and a local dev-layout walkthrough with a stub agent, not a Dev Box requalification with a real backend, so the row is `Partial` | Partial | | Folder toolbar identity | Project name and local/remote identity | Workspace chrome now paints an explicit Workspace title, project name, and Local folder/Remote repository identity over the native header, with a matching stable UIA toolbar child. The live gate's `workspace-toolbar-*` assertion (named `"UIA project"`) now runs against the real shell build and passes on the `windows-shell` CI job (run https://github.com/scgopi/GraphCode/actions/runs/35415967793) | Validated | | Loop bar | Type stripe, title/state pill, live goal, pass trend, elapsed/usage, Stop, Show in graph | The native 46px workspace band shows loop-type stripe, title, state, current activity, backend, elapsed label from `createdAt`, metric-history pass count, token usage when reported, Stop for unresolved loops, and Show in graph. Focused hit-testing/UIA unit coverage plus the live `windows-shell` CI run (workspace toolbar/loop-bar UIA assertions passing at run https://github.com/scgopi/GraphCode/actions/runs/35415967793) validated presence end to end. The beta10 Dev Box qualification (100% DPI) then observed the metadata text drawn under Stop/Show in graph at a 960px window with the sidebar rail and loop detail panel visible. Painting, mouse hit-testing, and UIA bounds now share `LoopBarLayout.zig`, which clamps every text run to the space left of the leftmost button, ellipsizes squeezed runs, and drops runs narrower than 24px; at 960px with rail and panel that drops the metadata line, where macOS truncates instead. Only focused `LoopBarLayout.zig` unit tests cover this; no live observation of the corrected layout has been made | Partial | | Tab pills | Named tabs, selection, state indicator, shortcuts, per-tab close | The native tab strip paints agent/shell/split labels, live state indicators, Ctrl+1-style shortcut hints, and per-tab close affordances. Close routing removes only the selected tab topology and refuses the final tab. The live gate's `workspace-tab-*` assertion now runs against the real shell build and passes on `windows-shell` (run https://github.com/scgopi/GraphCode/actions/runs/35415967793) | Validated | diff --git a/windows-tests/WindowsDaemonTests.swift b/windows-tests/WindowsDaemonTests.swift index 572a9ca9..4470be48 100644 --- a/windows-tests/WindowsDaemonTests.swift +++ b/windows-tests/WindowsDaemonTests.swift @@ -7,6 +7,21 @@ import XCTest import WinSDK #endif +/// Records the node ids a store asked to launch; `onEnsureSession` is `@Sendable`. +private final class AttendedLaunchCapture: @unchecked Sendable { + private let lock = NSLock() + private var recorded: [Foundation.UUID] = [] + func append(_ id: Foundation.UUID) { lock.withLock { recorded.append(id) } } + var ids: [Foundation.UUID] { lock.withLock { recorded } } +} + +private final class AttendedLaunchFlag: @unchecked Sendable { + private let lock = NSLock() + private var current = false + func set(_ value: Bool) { lock.withLock { current = value } } + var value: Bool { lock.withLock { current } } +} + final class WindowsDaemonTests: XCTestCase { private actor PredicateCapture { private(set) var workingDirectory: String? @@ -37,6 +52,52 @@ final class WindowsDaemonTests: XCTestCase { } #if os(Windows) + // The Windows shell only attaches to a loop's zmx session; it never launches the + // agent. Opening an attended loop must therefore get the daemon to start it, or the + // attach creates a bare shell and the first instruction is never sent (beta10). + func testOpeningAnAttendedLoopStartsItWhenPanesDoNotLaunchSessions() async { + let turn = LoopNode(title: "Turn", loopType: .turnBased, backend: .copilotCLI) + let sketch = LoopNode(title: "Main", loopType: .sketch, backend: .copilotCLI) + let stopped = LoopNode( + title: "Stopped", loopType: .turnBased, backend: .copilotCLI, state: .stopped) + let group = LoopNode(title: "Group", loopType: .composite) + let started = AttendedLaunchCapture() + let alive = AttendedLaunchFlag() + var graph = LoopGraph(project: ProjectRef(path: "", name: "p")) + graph.nodes.append(contentsOf: [turn, sketch, stopped, group]) + let store = GraphStore( + graph: graph, + onEnsureSession: { node, _ in started.append(node.id) }, + onSessionAlive: { _, _ in alive.value }, + panesLaunchAttendedSessions: false) + + for node in [turn, sketch, stopped, group] { await store.handle(.resumeSession(node.id)) } + alive.set(true) + await store.handle(.resumeSession(turn.id)) + + XCTAssertEqual(started.ids, [turn.id, sketch.id]) + } + + func testPanesThatLaunchSessionsKeepAttendedLoopsToThemselves() async { + let turn = LoopNode(title: "Turn", loopType: .turnBased, backend: .copilotCLI) + let started = AttendedLaunchCapture() + var graph = LoopGraph(project: ProjectRef(path: "", name: "p")) + graph.nodes.append(turn) + let store = GraphStore( + graph: graph, + onEnsureSession: { node, _ in started.append(node.id) }, + onSessionAlive: { _, _ in false }, + panesLaunchAttendedSessions: true) + + await store.handle(.resumeSession(turn.id)) + + XCTAssertEqual(started.ids, []) + } + + func testWindowsPanesDoNotLaunchAttendedSessions() { + XCTAssertFalse(GraphStore.platformPanesLaunchAttendedSessions) + } + func testZmxLocatorUsesWindowsExecutableName() { XCTAssertEqual(ZmxLocator.binaryURL.lastPathComponent, "zmx.exe") } From d06423f024e0d72152f9eb1b0e8de28660cf6f92 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Tue, 6 Oct 2026 14:51:32 -0700 Subject: [PATCH 02/11] Keep the Windows shell smoke attaching to stub loops in CI The validation root redirects LOCALAPPDATA deep enough that zmx's IPC lease path for a graphcode- session passes 260 characters, so the harness's zmx run for the stub loops exited 1. Give the smoke a short owned ZMX_DIR under RUNNER_TEMP and remove it afterwards. Launch waits also now run while the workspace is collapsed, as the old synchronous auto-attach did; the large-paste smoke sends its paste with the workspace hidden and needs the loop attached by then. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- Tools/windows/windows-shell.ps1 | 15 +++++++++++++++ graphcode-windows/src/TerminalSurface.zig | 5 ++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/Tools/windows/windows-shell.ps1 b/Tools/windows/windows-shell.ps1 index 039f3811..389f2ff8 100644 --- a/Tools/windows/windows-shell.ps1 +++ b/Tools/windows/windows-shell.ps1 @@ -48,6 +48,13 @@ $oldWorkspaceLayout = [Environment]::GetEnvironmentVariable("GRAPHCODE_WORKSPACE $oldStubNodeA = [Environment]::GetEnvironmentVariable("GRAPHCODE_STUB_NODE_A") $oldStubNodeB = [Environment]::GetEnvironmentVariable("GRAPHCODE_STUB_NODE_B") $oldSessionPrefix = [Environment]::GetEnvironmentVariable("GRAPHCODE_SHELL_SESSION_PREFIX") +$oldZmxDir = [Environment]::GetEnvironmentVariable("ZMX_DIR") +# zmx keeps per-session IPC files under its root, named by the hex-encoded session name; +# beneath the validation root's redirected LOCALAPPDATA a `graphcode-` session +# path passes 260 characters and `zmx run` fails. A short owned root keeps it legal. +$smokeZmxDir = Join-Path $(if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { + [IO.Path]::GetPathRoot([string] $repoRoot) + }) "gcz-$PID" $workspaceLayoutBase = Join-Path $shellRoot "graphcode-workspace-$PID.json" $ownedSessionNames = [System.Collections.Generic.HashSet[string]]::new() $ownedProcessIds = [System.Collections.Generic.HashSet[int]]::new() @@ -246,6 +253,8 @@ try { throw "GraphCode Windows shell version mismatch: expected $Version, executable reports $reportedVersion" } $env:GRAPHCODE_ZMX = Join-Path $ZmxRoot "zig-out\bin\zmx.exe" + New-Item -ItemType Directory -Force -Path $smokeZmxDir | Out-Null + $env:ZMX_DIR = $smokeZmxDir $env:GRAPHCODE_GATE_CWD = $repoRoot $env:GRAPHCODE_SHELL_WORKSPACE_ACTIONS = "1" $env:GRAPHCODE_WORKSPACE_LAYOUT = $workspaceLayoutBase @@ -461,6 +470,12 @@ finally { } Remove-Item Env:GRAPHCODE_ZMX -ErrorAction SilentlyContinue Remove-Item Env:GRAPHCODE_GATE_CWD -ErrorAction SilentlyContinue + if ($null -eq $oldZmxDir) { + Remove-Item Env:ZMX_DIR -ErrorAction SilentlyContinue + } else { + $env:ZMX_DIR = $oldZmxDir + } + Remove-Item -LiteralPath $smokeZmxDir -Recurse -Force -ErrorAction SilentlyContinue if ($null -eq $oldSessionPrefix) { Remove-Item Env:GRAPHCODE_SHELL_SESSION_PREFIX -ErrorAction SilentlyContinue } else { diff --git a/graphcode-windows/src/TerminalSurface.zig b/graphcode-windows/src/TerminalSurface.zig index b6e21656..f277c0a6 100644 --- a/graphcode-windows/src/TerminalSurface.zig +++ b/graphcode-windows/src/TerminalSurface.zig @@ -1257,6 +1257,10 @@ pub const Workspace = struct { } pub fn poll(self: *Workspace) void { + // Launch waits run even while collapsed: attaching a loop whose session just came up + // is what the old synchronous auto-attach did regardless of visibility, and the + // probes are console-less `zmx ls` runs that never touch accessibility. + self.pollLaunchWaits(); // While the workspace is collapsed (not visible as either the full surface or the // picture-in-picture panel), skip draining terminal output entirely. Feeding output // notifies winghostty's own accessibility layer via @@ -1267,7 +1271,6 @@ pub const Workspace = struct { // sessions server-side, so it's safe to stop draining the local attach pipe while hidden. if (self.collapsed) return; for (self.surfaces, 0..) |_, index| self.readAttachOutput(index); - self.pollLaunchWaits(); self.pollRecreates(); self.pollResizeControl(); } From 24a6245d63d2de5aaedd041ef2ba8c565b2fa8e0 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Tue, 6 Oct 2026 15:33:12 -0700 Subject: [PATCH 03/11] Verify real zmx fixture sessions instead of bypassing launch readiness Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Tools/windows/windows-shell.ps1 | 104 +++++++++++++++------- graphcode-windows/src/LoopLaunchWait.zig | 4 +- graphcode-windows/src/TerminalSurface.zig | 2 +- 3 files changed, 75 insertions(+), 35 deletions(-) diff --git a/Tools/windows/windows-shell.ps1 b/Tools/windows/windows-shell.ps1 index 389f2ff8..aa2b4293 100644 --- a/Tools/windows/windows-shell.ps1 +++ b/Tools/windows/windows-shell.ps1 @@ -49,12 +49,8 @@ $oldStubNodeA = [Environment]::GetEnvironmentVariable("GRAPHCODE_STUB_NODE_A") $oldStubNodeB = [Environment]::GetEnvironmentVariable("GRAPHCODE_STUB_NODE_B") $oldSessionPrefix = [Environment]::GetEnvironmentVariable("GRAPHCODE_SHELL_SESSION_PREFIX") $oldZmxDir = [Environment]::GetEnvironmentVariable("ZMX_DIR") -# zmx keeps per-session IPC files under its root, named by the hex-encoded session name; -# beneath the validation root's redirected LOCALAPPDATA a `graphcode-` session -# path passes 260 characters and `zmx run` fails. A short owned root keeps it legal. -$smokeZmxDir = Join-Path $(if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { - [IO.Path]::GetPathRoot([string] $repoRoot) - }) "gcz-$PID" +$smokeZmxDir = Join-Path ([IO.Path]::GetPathRoot([string] $repoRoot)) ("gcz-" + [guid]::NewGuid().ToString("N").Substring(0, 12)) +$stubSessionHosts = @{} $workspaceLayoutBase = Join-Path $shellRoot "graphcode-workspace-$PID.json" $ownedSessionNames = [System.Collections.Generic.HashSet[string]]::new() $ownedProcessIds = [System.Collections.Generic.HashSet[int]]::new() @@ -88,8 +84,8 @@ function Test-TestSessionProcess([object] $process) { } function Get-ZmxSessionRecords { - $names = @($testSessionIds | ForEach-Object { [regex]::Escape($_) }) - $names += [regex]::Escape($sessionPrefix) + "-[A-Za-z0-9_-]+" + $names = @($testSessionIds | ForEach-Object { [regex]::Escape("graphcode-$_") }) + $names += [regex]::Escape("graphcode-$sessionPrefix") + "-[A-Za-z0-9_-]+" $pattern = "(? Date: Tue, 6 Oct 2026 15:44:39 -0700 Subject: [PATCH 04/11] Fix elevated CI fixture ownership and launch empty Windows Main loops Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- GraphcodeKit/Sources/Domain/LoopType.swift | 5 ++- .../Sources/Sessions/ZmxSessionLauncher.swift | 17 +++++-- Tools/windows/Tests/WindowsShell.Tests.ps1 | 45 ++++++++++++++++--- Tools/windows/windows-shell.ps1 | 22 ++++++++- windows-tests/WindowsDaemonTests.swift | 26 +++++++++++ 5 files changed, 101 insertions(+), 14 deletions(-) diff --git a/GraphcodeKit/Sources/Domain/LoopType.swift b/GraphcodeKit/Sources/Domain/LoopType.swift index d490a924..f989b1b2 100644 --- a/GraphcodeKit/Sources/Domain/LoopType.swift +++ b/GraphcodeKit/Sources/Domain/LoopType.swift @@ -33,8 +33,9 @@ public enum LoopType: String, Codable, CaseIterable, Sendable { /// Whether `graphcoded` starts this loop's session and keeps it alive across its own /// restarts, because nothing else would. A turn-based loop or a sketch is *attended*: - /// it only ever runs because a human opened it, so whichever pane opens it owns the - /// launch — locally and, over ssh, on the remote host. Every gate that decides who + /// it only ever runs because a human opened it. macOS panes own the launch; Windows + /// panes ask the daemon with `resumeSession` before attaching. Over ssh the launch + /// still happens on the remote host. Every gate that decides who /// launches must read this rather than name `turnBased`, which is how remote sketches /// came to wait forever for a daemon that deliberately never starts them (#253). public var runsUnattended: Bool { diff --git a/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift b/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift index fe14e406..bd1c1fb5 100644 --- a/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift +++ b/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift @@ -1268,9 +1268,20 @@ public enum ZmxSessionLauncher { settings: GraphcodeSettings = GraphcodeSettingsStore.load(), shedPrompt: ShedPromptReport? = nil ) -> [String]? { - guard let prompt = node.sessionPrompt(forProjectPath: projectPath), !prompt.isEmpty else { - return node.backend == .nod - ? nodRunArguments(forNode: node, projectPath: projectPath, settings: settings) : nil + let prompt: String + if let opening = node.sessionPrompt(forProjectPath: projectPath), !opening.isEmpty { + prompt = opening + } else { + if node.backend == .nod { + return nodRunArguments(forNode: node, projectPath: projectPath, settings: settings) + } + #if os(Windows) + // Windows panes only attach; even an empty Main loop needs a daemon launch. + guard node.loopType == .sketch else { return nil } + prompt = "" + #else + return nil + #endif } // A backend graphcode can't launch has no argv. `canHost` already refuses to create // such a node, so this is the belt to that braces — but silently starting the wrong diff --git a/Tools/windows/Tests/WindowsShell.Tests.ps1 b/Tools/windows/Tests/WindowsShell.Tests.ps1 index 11a49925..ae5b0d71 100644 --- a/Tools/windows/Tests/WindowsShell.Tests.ps1 +++ b/Tools/windows/Tests/WindowsShell.Tests.ps1 @@ -333,16 +333,47 @@ Assert-Contract ($shellSource -match '(?s)\$evidence = .*?STUB_DAEMON_EVIDENCE_J "stub protocol evidence is not emitted before validation can fail" Assert-Contract ($shellSource -notmatch '\$env:GRAPHCODE_ZMX list') ` "session tracking must not block on unrelated zmx namespaces" +Assert-Contract ($shellSource -notmatch 'ASSUME_LOOP_SESSIONS') ` + "smoke must verify real loop sessions, not bypass launch readiness" +& { + $tokens = $null + $errors = $null + $ast = [Management.Automation.Language.Parser]::ParseInput( + $shellSource, [ref]$tokens, [ref]$errors) + $function = $ast.Find({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -eq "New-SmokeZmxRoot" + }, $true) + Assert-Contract ($null -ne $function) "smoke zmx root initializer is missing" + . ([scriptblock]::Create($function.Extent.Text)) + $ownedRoot = Join-Path ([IO.Path]::GetTempPath()) ("zmx-owner-test-" + [guid]::NewGuid().ToString("N")) + try { + New-SmokeZmxRoot $ownedRoot + $acl = Get-Acl -LiteralPath $ownedRoot + $sid = [Security.Principal.WindowsIdentity]::GetCurrent().User + Assert-Contract ($acl.GetOwner([Security.Principal.SecurityIdentifier]).Value -eq $sid.Value) ` + "smoke root owner must be the user SID, not Administrators" + Assert-Contract $acl.AreAccessRulesProtected "smoke root must not inherit foreign access" + $refused = $false + try { New-SmokeZmxRoot $ownedRoot } catch { $refused = $true } + Assert-Contract $refused "smoke must never take ownership of a preexisting directory" + } finally { + if (Test-Path -LiteralPath $ownedRoot) { Remove-Item -LiteralPath $ownedRoot -Recurse -Force } + } +} & { $sessionPrefix = "gs-owned" $testSessionIds = @("11111111-1111-4111-8111-111111111111") $processFixtures = @( - [pscustomobject]@{ ProcessId = 101; CommandLine = 'zmx.exe --daemon gs-owned-pane' }, - [pscustomobject]@{ ProcessId = 102; CommandLine = 'zmx.exe attach "11111111-1111-4111-8111-111111111111"' }, - [pscustomobject]@{ ProcessId = 103; CommandLine = 'zmx.exe --daemon gs-other-pane' }, - [pscustomobject]@{ ProcessId = 104; CommandLine = 'zmx.exe --daemon prefix-gs-owned-pane' }, - [pscustomobject]@{ ProcessId = 105; CommandLine = 'zmx.exe --daemon 11111111-1111-4111-8111-111111111111-suffix' }, - [pscustomobject]@{ ProcessId = 106; CommandLine = $null } + [pscustomobject]@{ ProcessId = 101; CommandLine = 'zmx.exe --daemon graphcode-gs-owned-pane' }, + [pscustomobject]@{ ProcessId = 102; CommandLine = 'zmx.exe attach "graphcode-11111111-1111-4111-8111-111111111111"' }, + [pscustomobject]@{ ProcessId = 103; CommandLine = 'zmx.exe --daemon graphcode-gs-other-pane' }, + [pscustomobject]@{ ProcessId = 104; CommandLine = 'zmx.exe --daemon prefix-graphcode-gs-owned-pane' }, + [pscustomobject]@{ ProcessId = 105; CommandLine = 'zmx.exe --daemon graphcode-11111111-1111-4111-8111-111111111111-suffix' }, + [pscustomobject]@{ ProcessId = 106; CommandLine = $null }, + [pscustomobject]@{ ProcessId = 107; CommandLine = 'zmx.exe --daemon gs-owned-pane' }, + [pscustomobject]@{ ProcessId = 108; CommandLine = 'zmx.exe attach 11111111-1111-4111-8111-111111111111' } ) function Get-CimInstance { $processFixtures } $tokens = $null @@ -359,7 +390,7 @@ Assert-Contract ($shellSource -notmatch '\$env:GRAPHCODE_ZMX list') ` Assert-Contract (($records.Pid -join ",") -eq "101,102") ` "session tracking included a foreign or partial-match process" Assert-Contract (($records.Name -join ",") -eq - "gs-owned-pane,11111111-1111-4111-8111-111111111111") ` + "graphcode-gs-owned-pane,graphcode-11111111-1111-4111-8111-111111111111") ` "session tracking did not preserve exact owned names" } if ($shellSource -match '(?m)^\s*Write-OwnedResourceMetrics\s*$') { diff --git a/Tools/windows/windows-shell.ps1 b/Tools/windows/windows-shell.ps1 index aa2b4293..ccec9fc5 100644 --- a/Tools/windows/windows-shell.ps1 +++ b/Tools/windows/windows-shell.ps1 @@ -73,6 +73,25 @@ function Assert-Equal([string] $actual, [string] $expected, [string] $label) { } } +function New-SmokeZmxRoot([string] $Path) { + if (Test-Path -LiteralPath $Path) { throw "Smoke zmx root already exists: $Path" } + New-Item -ItemType Directory -Path $Path | Out-Null + # Elevated runners default the owner to Administrators. zmx requires the token's + # actual user SID; set it only on this fresh, test-owned directory. + $sid = [Security.Principal.WindowsIdentity]::GetCurrent().User + $acl = [Security.AccessControl.DirectorySecurity]::new() + $acl.SetOwner($sid) + $acl.SetAccessRuleProtection($true, $false) + $acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new( + $sid, [Security.AccessControl.FileSystemRights]::FullControl, + [Security.AccessControl.InheritanceFlags]"ContainerInherit,ObjectInherit", + [Security.AccessControl.PropagationFlags]::None, + [Security.AccessControl.AccessControlType]::Allow)) + Set-Acl -LiteralPath $Path -AclObject $acl + $owner = (Get-Acl -LiteralPath $Path).GetOwner([Security.Principal.SecurityIdentifier]) + if ($owner.Value -ne $sid.Value) { throw "Smoke zmx root owner does not match the current user" } +} + function Test-TestSessionProcess([object] $process) { if (-not $process.CommandLine) { return $false } foreach ($session in $testSessionIds) { @@ -291,8 +310,7 @@ try { $env:GRAPHCODE_ZMX = Join-Path $ZmxRoot "zig-out\bin\zmx.exe" $env:GRAPHCODE_GATE_CWD = $repoRoot $env:GRAPHCODE_SHELL_WORKSPACE_ACTIONS = "1" - if (Test-Path -LiteralPath $smokeZmxDir) { throw "Smoke zmx root already exists: $smokeZmxDir" } - New-Item -ItemType Directory -Path $smokeZmxDir | Out-Null + New-SmokeZmxRoot $smokeZmxDir $env:ZMX_DIR = $smokeZmxDir $env:GRAPHCODE_WORKSPACE_LAYOUT = $workspaceLayoutBase $env:GRAPHCODE_SHELL_SESSION_PREFIX = $sessionPrefix diff --git a/windows-tests/WindowsDaemonTests.swift b/windows-tests/WindowsDaemonTests.swift index 4470be48..8c3e10e6 100644 --- a/windows-tests/WindowsDaemonTests.swift +++ b/windows-tests/WindowsDaemonTests.swift @@ -98,6 +98,32 @@ final class WindowsDaemonTests: XCTestCase { XCTAssertFalse(GraphStore.platformPanesLaunchAttendedSessions) } + func testWindowsMainLoopWithoutAnInstructionStillLaunchesItsBackend() throws { + let node = LoopNode(title: "Main", loopType: .sketch, backend: .copilotCLI) + var settings = GraphcodeSettings() + settings.copilotPermissions = .ask + let arguments = try XCTUnwrap(ZmxSessionLauncher.arguments(forNode: node, settings: settings)) + XCTAssertEqual( + Array(arguments.prefix(3)), + ["run", SurfaceRef(id: node.id, launchesClaudeCode: true).zmxSessionName, "-d"]) + XCTAssertTrue(arguments.contains("copilot")) + XCTAssertFalse(arguments.contains("--interactive")) + XCTAssertFalse(arguments.contains("--yolo")) + XCTAssertFalse(arguments.contains("/bin/zsh")) + } + + func testWindowsAttendedLaunchPreservesInstructionAndAskPermissions() throws { + var node = LoopNode(title: "Turn", loopType: .turnBased, backend: .copilotCLI) + node.firstInstruction = "Reply with a short confirmation." + var settings = GraphcodeSettings() + settings.copilotPermissions = .ask + let arguments = try XCTUnwrap(ZmxSessionLauncher.arguments(forNode: node, settings: settings)) + let promptIndex = try XCTUnwrap(arguments.firstIndex(of: "--interactive")) + XCTAssertTrue(arguments[promptIndex + 1].hasPrefix("Reply with a short confirmation.")) + XCTAssertFalse(arguments.contains("--yolo")) + XCTAssertFalse(arguments.contains("--autopilot")) + } + func testZmxLocatorUsesWindowsExecutableName() { XCTAssertEqual(ZmxLocator.binaryURL.lastPathComponent, "zmx.exe") } From cba32f36cdfef4b6d25a88bfacaad7deab12f22d Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Tue, 6 Oct 2026 15:58:41 -0700 Subject: [PATCH 05/11] Keep pending loop tabs visible and preserve layouts on failed session listings Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- graphcode-windows/src/LoopLaunchWait.zig | 20 +++- graphcode-windows/src/TerminalSurface.zig | 140 ++++++++++++++++++---- 2 files changed, 133 insertions(+), 27 deletions(-) diff --git a/graphcode-windows/src/LoopLaunchWait.zig b/graphcode-windows/src/LoopLaunchWait.zig index 2c752493..89937f86 100644 --- a/graphcode-windows/src/LoopLaunchWait.zig +++ b/graphcode-windows/src/LoopLaunchWait.zig @@ -21,8 +21,8 @@ pub const Step = enum { idle, start_probe, attach, give_up }; /// ended); a loop pane only while the daemon's session is still running. Restoring it /// otherwise would create a bare shell under the loop's name, and every later open would /// then find the loop "running" and never launch its agent. -pub fn restoreKeepsPane(launches_agent: bool, session_live: bool) bool { - return !launches_agent or session_live; +pub fn restoreKeepsPane(launches_agent: bool, session_live: ?bool) bool { + return !launches_agent or session_live != false; } pub const Wait = struct { @@ -104,9 +104,8 @@ pub fn listingShowsLive(listing: []const u8, session: []const u8) bool { std.mem.indexOf(u8, line, "\texit_code=") != null or std.mem.indexOf(u8, line, "\terr=") != null) continue; var fields = std.mem.tokenizeAny(u8, line, " \t"); - while (fields.next()) |field| { - if (std.mem.startsWith(u8, field, "name=") and std.mem.eql(u8, field["name=".len..], name)) return true; - } + const field = fields.next() orelse continue; + if (std.mem.startsWith(u8, field, "name=") and std.mem.eql(u8, field["name=".len..], name)) return true; } return false; } @@ -196,3 +195,14 @@ test "a listed session counts as live only while its task runs" { try std.testing.expect(!listingShowsLive(listing, "00000000-0000-4000-8000-0B492B5F524")); try std.testing.expect(!listingShowsLive("", "anything")); } + +test "a failed listing must not delete a saved loop pane" { + try std.testing.expect(restoreKeepsPane(true, null)); +} + +test "a session name mentioned in another task command is not a live session" { + try std.testing.expect(!listingShowsLive( + "name=graphcode-other\tpid=1\tcmd=echo name=graphcode-wanted\n", + "wanted", + )); +} diff --git a/graphcode-windows/src/TerminalSurface.zig b/graphcode-windows/src/TerminalSurface.zig index 2868ff97..e9d80079 100644 --- a/graphcode-windows/src/TerminalSurface.zig +++ b/graphcode-windows/src/TerminalSurface.zig @@ -602,16 +602,42 @@ pub const Workspace = struct { if (index >= self.surfaces.len) return error.InvalidSurface; const slot = &self.surfaces[index]; if ((slot.surface != null or slot.attach != null) and - std.mem.eql(u8, slot.session_name, node_id)) return; + std.mem.eql(u8, slot.session_name, node_id)) + { + self.cancelLaunchWait(index); + if (timeout_ms > 0) self.launch_outcome = .started; + return; + } const now = nowMilliseconds(); const explicit = timeout_ms > 0; const wait = &self.launch_waits[index]; + if (!explicit and now < self.passive_retry_due_ms[index]) return; + const session = try self.allocator.dupe(u8, node_id); + errdefer self.allocator.free(session); + // Mount the tab immediately, but not the terminal: pending/failed launches still + // need navigable workspace chrome, without creating a session behind the daemon. + if (explicit and slot.surface == null and slot.attach == null) { + if (self.layout.tabs.items.len == 0) { + const previous_next_id = self.layout.next_tab_id; + try self.layout.addTab(node_id, true); + self.persistLayout() catch |err| { + _ = self.layout.removePane(node_id); + self.layout.next_tab_id = previous_next_id; + return err; + }; + } else if (wait.active() and wait.reports_timeout) { + try self.layout.replacePaneID(wait.session, node_id); + self.persistLayout() catch |err| { + self.layout.replacePaneID(node_id, wait.session) catch {}; + return err; + }; + } + } if (wait.active() and std.mem.eql(u8, wait.session, node_id)) { + self.allocator.free(session); wait.extend(now, timeout_ms, explicit); return; } - if (!explicit and now < self.passive_retry_due_ms[index]) return; - const session = try self.allocator.dupe(u8, node_id); self.cancelLaunchWait(index); self.clearRecreateSession(index); self.launch_waits[index].begin(session, now, timeout_ms, explicit); @@ -628,26 +654,46 @@ pub const Workspace = struct { /// Asks zmx, without attaching or creating, whether a session is running. Bounded so a /// wedged zmx cannot hold the UI thread. - fn sessionIsLive(self: *Workspace, session: []const u8) bool { - var child = self.spawnListing() orelse return false; + fn sessionIsLive(self: *Workspace, session: []const u8) ?bool { + var child = self.spawnListing() orelse return null; var output: std.ArrayListUnmanaged(u8) = .empty; defer output.deinit(self.allocator); const deadline = nowMilliseconds() + restore_probe_timeout_ms; var exit_code: c.DWORD = c.STILL_ACTIVE; while (true) { - self.drainListing(&child, &output); - if (c.GetExitCodeProcess(child.id, &exit_code) == 0) break; + self.drainListing(&child, &output) catch { + _ = child.kill() catch {}; + self.setInputError("Unable to read loop session listing"); + return null; + }; + if (c.GetExitCodeProcess(child.id, &exit_code) == 0) { + _ = child.kill() catch {}; + self.setInputError("Unable to query loop session listing process"); + return null; + } if (exit_code != c.STILL_ACTIVE) break; if (nowMilliseconds() >= deadline) break; std.Thread.sleep(10 * std.time.ns_per_ms); } if (exit_code == c.STILL_ACTIVE) { _ = child.kill() catch {}; - return false; + self.setInputError("Loop session listing timed out"); + return null; } - self.drainListing(&child, &output); - _ = child.wait() catch {}; - return exit_code == 0 and LoopLaunchWait.listingShowsLive(output.items, session); + self.drainListing(&child, &output) catch { + _ = child.wait() catch {}; + self.setInputError("Unable to read loop session listing"); + return null; + }; + _ = child.wait() catch { + self.setInputError("Unable to reap loop session listing process"); + return null; + }; + if (exit_code != 0) { + self.setInputError("Loop session listing failed"); + return null; + } + return LoopLaunchWait.listingShowsLive(output.items, session); } fn spawnListing(self: *Workspace) ?std.process.Child { @@ -658,23 +704,32 @@ pub const Workspace = struct { child.stdout_behavior = .Pipe; child.stderr_behavior = .Ignore; child.create_no_window = true; - child.spawn() catch return null; + child.spawn() catch { + self.setInputError("Unable to start loop session listing"); + return null; + }; return child; } /// Reads whatever the listing has written so far, so a long one never blocks on a /// full pipe. Bounded: the listing is a few hundred bytes per session. - fn drainListing(self: *Workspace, child: *std.process.Child, output: *std.ArrayListUnmanaged(u8)) void { - const stdout = child.stdout orelse return; + fn drainListing(self: *Workspace, child: *std.process.Child, output: *std.ArrayListUnmanaged(u8)) !void { + const stdout = child.stdout orelse return error.SessionListingPipeMissing; var buffer: [4096]u8 = undefined; while (output.items.len < max_listing_bytes) { var available: c.DWORD = 0; - if (c.PeekNamedPipe(stdout.handle, null, 0, null, &available, null) == 0 or available == 0) return; + if (c.PeekNamedPipe(stdout.handle, null, 0, null, &available, null) == 0) { + if (c.GetLastError() == c.ERROR_BROKEN_PIPE) return; + return error.SessionListingReadFailed; + } + if (available == 0) return; var count: c.DWORD = 0; - const want: c.DWORD = @intCast(@min(buffer.len, available)); - if (c.ReadFile(stdout.handle, &buffer, want, &count, null) == 0 or count == 0) return; - output.appendSlice(self.allocator, buffer[0..count]) catch return; + const want: c.DWORD = @intCast(@min(buffer.len, available, max_listing_bytes - output.items.len)); + if (c.ReadFile(stdout.handle, &buffer, want, &count, null) == 0) return error.SessionListingReadFailed; + if (count == 0) return; + try output.appendSlice(self.allocator, buffer[0..count]); } + return error.SessionListingTooLarge; } fn pollLaunchWaits(self: *Workspace) void { @@ -719,7 +774,12 @@ pub const Workspace = struct { fn launchProbeOutcome(self: *Workspace, index: usize, overdue: bool) ?LoopLaunchWait.Probe { const child = if (self.launch_probes[index]) |*value| value else return null; const output = &self.launch_probe_output[index]; - self.drainListing(child, output); + self.drainListing(child, output) catch { + _ = child.kill() catch {}; + self.launch_probes[index] = null; + self.setInputError("Unable to read loop session listing"); + return .missing; + }; var exit_code: c.DWORD = 0; if (c.GetExitCodeProcess(child.id, &exit_code) == 0 or (exit_code == c.STILL_ACTIVE and overdue)) @@ -729,7 +789,12 @@ pub const Workspace = struct { return .missing; } if (exit_code == c.STILL_ACTIVE) return .running; - self.drainListing(child, output); + self.drainListing(child, output) catch { + _ = child.wait() catch {}; + self.launch_probes[index] = null; + self.setInputError("Unable to read loop session listing"); + return .missing; + }; _ = child.wait() catch {}; self.launch_probes[index] = null; const live = exit_code == 0 and @@ -840,7 +905,7 @@ pub const Workspace = struct { fn createAttachedSurface(self: *Workspace, session: []const u8, initial_grid: GridSize) !usize { for (&self.surfaces, 0..) |*slot, index| { - if (slot.surface != null or slot.attach != null) continue; + if (slot.surface != null or slot.attach != null or self.launch_waits[index].active()) continue; slot.session_name = try self.allocator.dupe(u8, session); errdefer self.destroySurface(index); slot.project_path = try self.allocator.dupe(u8, self.project_path); @@ -884,7 +949,11 @@ pub const Workspace = struct { defer for (ids[0..count]) |id| self.allocator.free(id); var pruned = false; for (ids[0..count], agents[0..count]) |id, launches_agent| { - const live = launches_agent and self.sessionIsLive(id); + const live: ?bool = if (launches_agent) self.sessionIsLive(id) else true; + if (live == null) { + self.queueRestoreRetry(id, error.SessionListingUnavailable, launches_agent); + continue; + } if (!LoopLaunchWait.restoreKeepsPane(launches_agent, live)) { pruned = self.layout.removePane(id) or pruned; continue; @@ -4072,6 +4141,33 @@ fn minimalWorkspaceForOptionsTest(allocator: std.mem.Allocator) !Workspace { }; } +test "opening a loop mounts its pending tab without spawning a terminal" { + var workspace = try minimalWorkspaceForOptionsTest(std.testing.allocator); + defer workspace.layout.deinit(); + defer workspace.cancelAllLaunchWaits(); + const path = "terminal-pending-loop-test.json"; + workspace.layout_path = @constCast(path); + defer std.fs.cwd().deleteFile(path) catch {}; + try workspace.openLaunchedNode(0, "first-loop", LoopLaunchWait.open_timeout_ms); + try std.testing.expectEqual(@as(usize, 1), workspace.tabCount()); + try std.testing.expect(workspace.isAwaitingLaunch(0)); + try std.testing.expect(workspace.surfaces[0].attach == null); + try std.testing.expect(workspace.surfaces[0].surface == null); + try std.testing.expectEqualStrings("first-loop", workspace.layout.tabs.items[0].panes.items[0].id); + try workspace.openLaunchedNode(0, "second-loop", LoopLaunchWait.open_timeout_ms); + try std.testing.expectEqual(@as(usize, 1), workspace.tabCount()); + try std.testing.expectEqualStrings("second-loop", workspace.layout.tabs.items[0].panes.items[0].id); +} + +test "passive loop observation does not create a pending tab" { + var workspace = try minimalWorkspaceForOptionsTest(std.testing.allocator); + defer workspace.layout.deinit(); + defer workspace.cancelAllLaunchWaits(); + try workspace.openLaunchedNode(0, "idle-loop", 0); + try std.testing.expectEqual(@as(usize, 0), workspace.tabCount()); + try std.testing.expect(workspace.surfaces[0].attach == null); +} + fn paneResizeWorkspaceForTest(allocator: std.mem.Allocator) !Workspace { var workspace = try minimalWorkspaceForOptionsTest(allocator); errdefer workspace.layout.deinit(); From 12ca0da9aec3a5e86a7583ad1776ef2ba74c4e54 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Tue, 6 Oct 2026 16:04:44 -0700 Subject: [PATCH 06/11] Teach the strict multi-project fixture to acknowledge loop-open requests Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Tools/windows/Stub-Daemon.ps1 | 25 +- .../windows/Tests/ValidationRunner.Tests.ps1 | 1835 +++++++++-------- 2 files changed, 945 insertions(+), 915 deletions(-) diff --git a/Tools/windows/Stub-Daemon.ps1 b/Tools/windows/Stub-Daemon.ps1 index f4f2a91d..140bc0d2 100644 --- a/Tools/windows/Stub-Daemon.ps1 +++ b/Tools/windows/Stub-Daemon.ps1 @@ -155,13 +155,22 @@ function Invoke-MultiProjectRequest($peer, $frame) { if ($verb -ceq "graphCommand") { $command = $frame.command.graphCommand Assert-MultiProjectObject $command @("projectPath", "command") "graphCommand" - Assert-MultiProjectObject $command.command @("renameNode") "graphCommand.command" - $rename = $command.command.renameNode - Assert-MultiProjectObject $rename @("_0", "title") "renameNode" - Assert-MultiProjectUuid $rename._0 "nodeID" - if ($rename.title -isnot [string]) { throw "MULTIPROJECT_TYPE: title must be a string" } - if ([string]::IsNullOrWhiteSpace($rename.title)) { throw "MULTIPROJECT_TITLE: title cannot be blank" } - $owner = Find-MultiProjectGraph $peer $command.projectPath $rename._0 + if ($command.command -is [Collections.IDictionary] -and + @($command.command.Keys) -ccontains "resumeSession") { + Assert-MultiProjectObject $command.command @("resumeSession") "graphCommand.command" + $resume = $command.command.resumeSession + Assert-MultiProjectObject $resume @("_0") "resumeSession" + Assert-MultiProjectUuid $resume._0 "nodeID" + $owner = Find-MultiProjectGraph $peer $command.projectPath $resume._0 + } else { + Assert-MultiProjectObject $command.command @("renameNode") "graphCommand.command" + $rename = $command.command.renameNode + Assert-MultiProjectObject $rename @("_0", "title") "renameNode" + Assert-MultiProjectUuid $rename._0 "nodeID" + if ($rename.title -isnot [string]) { throw "MULTIPROJECT_TYPE: title must be a string" } + if ([string]::IsNullOrWhiteSpace($rename.title)) { throw "MULTIPROJECT_TITLE: title cannot be blank" } + $owner = Find-MultiProjectGraph $peer $command.projectPath $rename._0 + } $response = [ordered]@{ version = 2; kind = "response"; requestID = $frame.requestID; success = $true } } elseif ($verb -ceq "listRecentProjects") { Assert-MultiProjectObject $frame.command.listRecentProjects @() "listRecentProjects" @@ -182,7 +191,7 @@ function Invoke-MultiProjectRequest($peer, $frame) { nodeID = $rename._0; beforeTitle = $owner.nodes[0].title; title = $rename.title }) $owner.nodes[0].title = $rename.title } - return [ordered]@{ response = $response; publishPath = if ($null -ne $owner) { $owner.project.path } else { $null } } + return [ordered]@{ response = $response; publishPath = if ($null -ne $rename) { $owner.project.path } else { $null } } } function Complete-MultiProjectResponse($peer, $response) { diff --git a/Tools/windows/Tests/ValidationRunner.Tests.ps1 b/Tools/windows/Tests/ValidationRunner.Tests.ps1 index 66759a4b..a4bf2f81 100644 --- a/Tools/windows/Tests/ValidationRunner.Tests.ps1 +++ b/Tools/windows/Tests/ValidationRunner.Tests.ps1 @@ -1,4 +1,4 @@ -$ErrorActionPreference = "Stop" +$ErrorActionPreference = "Stop" function Test-MultiProjectProtocolContracts([string] $stubSource, [string] $gateSource, [string] $stubPath, [string] $pwsh) { foreach ($source in @($stubSource, $gateSource)) { @@ -1611,6 +1611,27 @@ function Test-MultiProjectProtocolContracts([string] $stubSource, [string] $gate Invoke-MultiCase "offscreen target rejected" -Negative { return Reject-MultiCase { Get-MultiProjectClippedRectangle @(0,0,50,50) @(220,34,1200,900) } "MULTIPROJECT_BOUNDS" } + Invoke-MultiCase "resumeSession acknowledges an exact fixture owner without changing its graph" { + $state = New-MultiBaseline + $before = ConvertTo-MultiProjectCanonicalJson $state.graphs + $frame = Copy-MultiProjectValue (New-MultiRequest) + $frame.command.graphCommand.command = [ordered]@{ resumeSession = [ordered]@{ _0 = $a } } + $result = Invoke-MultiProjectRequest $state $frame + Assert-MultiCase ($result.response.success -eq $true -and $null -eq $result.publishPath -and + $state.applied.Count -eq 0 -and + (ConvertTo-MultiProjectCanonicalJson $state.graphs) -ceq $before) "session open mutated the fixture graph" + return "Exact owner accepted; acknowledgement only, no agent launch or graph mutation" + } + Invoke-MultiCase "resumeSession refuses a node belonging to another project" -Negative { + $state = New-MultiBaseline + $before = ConvertTo-MultiProjectCanonicalJson (Get-MultiProjectPeerSnapshot $state) + $frame = Copy-MultiProjectValue (New-MultiRequest) + $frame.command.graphCommand.command = [ordered]@{ resumeSession = [ordered]@{ _0 = $b } } + $result = Reject-MultiCase { Invoke-MultiProjectRequest $state $frame } "MULTIPROJECT_OWNER" + Assert-MultiCase ((ConvertTo-MultiProjectCanonicalJson (Get-MultiProjectPeerSnapshot $state)) -ceq $before) ` + "foreign session open changed peer state" + return $result + } $requestMutations = [ordered]@{ owner = @{ prefix = "MULTIPROJECT_OWNER"; change = { param($f) $f.command.graphCommand.projectPath = $beta } } node = @{ prefix = "MULTIPROJECT_OWNER"; change = { param($f) $f.command.graphCommand.command.renameNode._0 = $b } } @@ -2002,55 +2023,55 @@ function Assert-MultiProjectFreshCaptureContract([string] $source) { throw "RED: observer bypasses fresh/cached ownership or whole-observation/container verification" } } - -function Assert-ShellHostPrerequisite([string] $source) { - $tokens = $null - $errors = $null - $ast = [Management.Automation.Language.Parser]::ParseInput($source, [ref]$tokens, [ref]$errors) - if ($errors.Count -ne 0) { throw "Validation driver does not parse" } - $clauses = @($ast.FindAll({ - param($node) - $node -is [Management.Automation.Language.SwitchStatementAst] - }, $true).Clauses | Where-Object { $_.Item1.Value -eq "windows-shell" }) - if ($clauses.Count -ne 1) { throw "Expected one windows-shell validation branch" } - $body = $clauses[0].Item2 - $commands = @($body.FindAll({ - param($node) - $node -is [Management.Automation.Language.CommandAst] - }, $true)) - $build = @($commands | Where-Object { - $_.GetCommandName() -eq "Invoke-Native" -and - $_.CommandElements[1].Extent.Text -eq '"Pinned Winghostty host build for App contracts"' - }) - $contracts = @($commands | Where-Object { - $_.InvocationOperator -eq [Management.Automation.Language.TokenKind]::Ampersand -and - $_.CommandElements[0].Extent.Text -match 'Tools\\windows\\Tests\\WindowsShell\.Tests\.ps1' - }) - $smoke = @($commands | Where-Object { - $_.GetCommandName() -eq "Invoke-Native" -and - $_.CommandElements[1].Extent.Text -eq '"Pinned GraphCode Windows shell build and smoke"' - }) - $guards = @($body.FindAll({ - param($node) - $node -is [Management.Automation.Language.IfStatementAst] - }, $true)) - $pin = @($guards | Where-Object { $_.Extent.Text -match '\$actualWinghosttyPin -ne \$pins\.winghostty\.sha' }) - $clean = @($guards | Where-Object { $_.Extent.Text -match '\$providerStatus\.Count -ne 0' }) - $library = @($guards | Where-Object { $_.Extent.Text -match 'Test-Path -LiteralPath \$winghosttyLib -PathType Leaf' }) - foreach ($stage in @(@{ Values = $build }, @{ Values = $contracts }, @{ Values = $smoke }, - @{ Values = $pin }, @{ Values = $clean }, @{ Values = $library })) { - if ($stage.Values.Count -ne 1) { throw "Missing or repeated App host prerequisite stage" } - } - if ($clean[0].Extent.EndOffset -ge $build[0].Extent.StartOffset -or - $pin[0].Extent.EndOffset -ge $build[0].Extent.StartOffset -or - $build[0].Extent.EndOffset -ge $library[0].Extent.StartOffset -or - $library[0].Extent.EndOffset -ge $contracts[0].Extent.StartOffset -or - $contracts[0].Extent.EndOffset -ge $smoke[0].Extent.StartOffset -or - $build[0].Extent.Text -notmatch '(?s)Push-Location \$winghosttyRoot.*& \$zig0152 build -Demit-win32-host=true.*finally \{ Pop-Location \}') { - throw "Pinned host validation/build must precede App contracts, which must precede live smoke" - } -} - + +function Assert-ShellHostPrerequisite([string] $source) { + $tokens = $null + $errors = $null + $ast = [Management.Automation.Language.Parser]::ParseInput($source, [ref]$tokens, [ref]$errors) + if ($errors.Count -ne 0) { throw "Validation driver does not parse" } + $clauses = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.SwitchStatementAst] + }, $true).Clauses | Where-Object { $_.Item1.Value -eq "windows-shell" }) + if ($clauses.Count -ne 1) { throw "Expected one windows-shell validation branch" } + $body = $clauses[0].Item2 + $commands = @($body.FindAll({ + param($node) + $node -is [Management.Automation.Language.CommandAst] + }, $true)) + $build = @($commands | Where-Object { + $_.GetCommandName() -eq "Invoke-Native" -and + $_.CommandElements[1].Extent.Text -eq '"Pinned Winghostty host build for App contracts"' + }) + $contracts = @($commands | Where-Object { + $_.InvocationOperator -eq [Management.Automation.Language.TokenKind]::Ampersand -and + $_.CommandElements[0].Extent.Text -match 'Tools\\windows\\Tests\\WindowsShell\.Tests\.ps1' + }) + $smoke = @($commands | Where-Object { + $_.GetCommandName() -eq "Invoke-Native" -and + $_.CommandElements[1].Extent.Text -eq '"Pinned GraphCode Windows shell build and smoke"' + }) + $guards = @($body.FindAll({ + param($node) + $node -is [Management.Automation.Language.IfStatementAst] + }, $true)) + $pin = @($guards | Where-Object { $_.Extent.Text -match '\$actualWinghosttyPin -ne \$pins\.winghostty\.sha' }) + $clean = @($guards | Where-Object { $_.Extent.Text -match '\$providerStatus\.Count -ne 0' }) + $library = @($guards | Where-Object { $_.Extent.Text -match 'Test-Path -LiteralPath \$winghosttyLib -PathType Leaf' }) + foreach ($stage in @(@{ Values = $build }, @{ Values = $contracts }, @{ Values = $smoke }, + @{ Values = $pin }, @{ Values = $clean }, @{ Values = $library })) { + if ($stage.Values.Count -ne 1) { throw "Missing or repeated App host prerequisite stage" } + } + if ($clean[0].Extent.EndOffset -ge $build[0].Extent.StartOffset -or + $pin[0].Extent.EndOffset -ge $build[0].Extent.StartOffset -or + $build[0].Extent.EndOffset -ge $library[0].Extent.StartOffset -or + $library[0].Extent.EndOffset -ge $contracts[0].Extent.StartOffset -or + $contracts[0].Extent.EndOffset -ge $smoke[0].Extent.StartOffset -or + $build[0].Extent.Text -notmatch '(?s)Push-Location \$winghosttyRoot.*& \$zig0152 build -Demit-win32-host=true.*finally \{ Pop-Location \}') { + throw "Pinned host validation/build must precede App contracts, which must precede live smoke" + } +} + function Get-WorkflowJobs([string] $text) { # Windows checkouts may convert workflow files to CRLF. $text = $text.Replace("`r`n", "`n") @@ -2161,254 +2182,254 @@ function Test-CiAggregateGates([string] $repoRoot, [string] $pwsh) { } } -function Get-WorkflowSteps([string] $jobText) { - $jobText = $jobText.Replace("`r`n", "`n") - $heads = @([regex]::Matches($jobText, '(?m)^ - ')) - $steps = [Collections.Generic.List[string]]::new() - for ($index = 0; $index -lt $heads.Count; $index++) { - $end = if ($index + 1 -lt $heads.Count) { $heads[$index + 1].Index } else { $jobText.Length } - $steps.Add($jobText.Substring($heads[$index].Index, $end - $heads[$index].Index)) - } - , $steps.ToArray() -} - -# A cold provider cache miss must be able to seed its own immutable key even -# when a later gate fails or is cancelled, but never from a partial tree: the -# single save runs after a successful bootstrap, pin check, and build-only -# provider compile, and before any gate. Implicit success() gating (an `if:` -# without a status-check function) or an explicit success()/always() would -# reinstate whole-job gating or publish a failed build. -$script:ProviderCacheSaveCondition = "`${{ !cancelled() && steps.bootstrap.conclusion == 'success' && steps.verify-pins.conclusion == 'success' && steps.provider-build.conclusion == 'success' && steps.provider-cache.outputs.cache-hit != 'true' }}" -function Assert-ProviderCacheSeeding([string] $jobText, [string] $label, [bool] $requireGate) { - $steps = Get-WorkflowSteps $jobText - function Find-Step([scriptblock] $predicate) { - for ($index = 0; $index -lt $steps.Count; $index++) { - if (& $predicate $steps[$index]) { return $index } - } - return -1 - } - $restore = Find-Step { param($s) $s -match '(?m)^ id: provider-cache\s*$' -and $s -match 'actions/cache/restore@' } - $bootstrap = Find-Step { param($s) $s -match '(?m)^ id: bootstrap\s*$' -and $s -match '(?m)^ run: \./Tools/windows/bootstrap\.ps1 ' } - $verify = Find-Step { param($s) $s -match '(?m)^ id: verify-pins\s*$' -and $s -match '(?m)^ run: \./Tools/windows/Assert-ProviderCheckout\.ps1 -ProviderRoot \.ci-providers\s*$' } - $build = Find-Step { param($s) $s -match '(?m)^ id: provider-build\s*$' -and $s -match '(?m)^ run: \./Tools/windows/validate\.ps1 -Task provider-build\s*$' } - $saves = @(for ($index = 0; $index -lt $steps.Count; $index++) { if ($steps[$index] -match 'actions/cache/save@') { $index } }) - $gate = Find-Step { param($s) $s -match '(?m)^ run: \./Tools/windows/validate\.ps1 -Task windows-shell\b' } - foreach ($required in @( - @{ Index = $restore; Name = "provider cache restore (id provider-cache)" }, - @{ Index = $bootstrap; Name = "bootstrap (id bootstrap)" }, - @{ Index = $verify; Name = "pin verification (id verify-pins)" }, - @{ Index = $build; Name = "build-only provider compile (id provider-build, validate.ps1 -Task provider-build)" })) { - if ($required.Index -lt 0) { throw "RED: $label has no $($required.Name) before its provider cache save" } - } - if ($saves.Count -ne 1) { throw "RED: $label must have exactly one provider cache save, found $($saves.Count)" } - $save = $saves[0] - if ($requireGate -and $gate -lt 0) { throw "RED: $label has no downstream windows-shell gate" } - if (-not ($restore -lt $bootstrap -and $bootstrap -lt $verify -and $verify -lt $build -and $build -lt $save)) { - throw "RED: $label does not save only after restore, bootstrap, pin verification, and build-only compile" - } - if ($requireGate -and $save -gt $gate) { - throw "RED: $label saves the provider cache after the gate, so a failed or cancelled gate discards a cold build" - } - $saveText = $steps[$save] - $condition = [regex]::Match($saveText, '(?m)^ if:\s*(.+?)\s*$') - if (-not $condition.Success) { throw "RED: $label provider cache save has no explicit condition" } - $conditionText = $condition.Groups[1].Value - if ($conditionText -match '(?> "%FAKE_ZIG_LOG%"', - 'if "%FAKE_ZIG_FAIL%"=="1" exit /b 7', - 'if "%FAKE_ZIG_SKIP_ARTIFACT%"=="1" exit /b 0', - 'echo %* | findstr /c:"-Demit-win32-host=true" >nul && (mkdir zig-out\lib 2>nul & type nul > zig-out\lib\winghostty-win32-host.lib)', - 'echo %* | findstr /c:"-Dtarget=x86_64-windows-gnu" >nul && (mkdir zig-out\bin 2>nul & type nul > zig-out\bin\zmx.exe)', - 'exit /b 0') - $pins = [ordered]@{ schemaVersion = 1 } - foreach ($name in @("winghostty", "zmx")) { - $root = Join-Path $scratch $name - New-Item -ItemType Directory -Force $root | Out-Null - git -C $root init -q 2>$null - "zig-out/`n" | Set-Content -LiteralPath (Join-Path $root ".gitignore") -NoNewline - git -C $root add .gitignore - git -C $root -c user.name=t -c user.email=t@example.invalid commit -q -m pin 2>$null - $pins[$name] = [ordered]@{ sha = (git -C $root rev-parse HEAD) } - } - $pinsPath = Join-Path $scratch "provider-pins.json" - $pins | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $pinsPath - function Invoke-ProviderBuildCase([hashtable] $environment) { - foreach ($name in @("winghostty", "zmx")) { - Remove-Item -LiteralPath (Join-Path $scratch "$name\zig-out") -Recurse -Force -ErrorAction SilentlyContinue - } - $log = Join-Path $scratch "zig-$([guid]::NewGuid().ToString('N')).log" - $saved = @{} - $all = @{ FAKE_ZIG_LOG = $log; FAKE_ZIG_FAIL = $null; FAKE_ZIG_SKIP_ARTIFACT = $null } - foreach ($entry in $environment.GetEnumerator()) { $all[$entry.Key] = $entry.Value } - foreach ($entry in $all.GetEnumerator()) { - $saved[$entry.Key] = [Environment]::GetEnvironmentVariable($entry.Key) - [Environment]::SetEnvironmentVariable($entry.Key, $entry.Value) - } - try { - $output = @(& $pwsh -NoProfile -File $script ` - -WinghosttyRoot (Join-Path $scratch "winghostty") -ZmxRoot (Join-Path $scratch "zmx") ` - -Zig0152 $fakeZig -Zig0160 $fakeZig -PinsPath $pinsPath -ZmxAttempts 1 2>&1 | ForEach-Object { "$_" }) - $exit = $LASTEXITCODE - } finally { - foreach ($entry in $saved.GetEnumerator()) { [Environment]::SetEnvironmentVariable($entry.Key, $entry.Value) } - } - $invocations = if (Test-Path -LiteralPath $log) { @(Get-Content -LiteralPath $log) } else { @() } - [pscustomobject]@{ ExitCode = $exit; Output = $output; Invocations = $invocations } - } - $pass = Invoke-ProviderBuildCase @{} - $text = $pass.Output -join "`n" - $executed = [regex]::Match($text, '(?m)^PROVIDER_BUILD_EXECUTED=(\d+)\s*$') - $stepLines = @($pass.Output | Where-Object { $_ -match '^PROVIDER_BUILD_STEP=' }) - if ($pass.ExitCode -ne 0 -or -not $executed.Success -or [int]$executed.Groups[1].Value -ne 2 -or - $stepLines.Count -ne 2 -or $pass.Invocations.Count -ne 2) { - throw "RED: provider-build.ps1 did not execute exactly two pinned provider builds (exit=$($pass.ExitCode)): $text" - } - if ($pass.Invocations[0] -notmatch '^FAKE_ZIG build -Demit-win32-host=true\s*$' -or - $pass.Invocations[1] -notmatch '^FAKE_ZIG build -Dtarget=x86_64-windows-gnu\s*$') { - throw "RED: provider-build.ps1 changed the canonical provider build flags: $($pass.Invocations -join '; ')" - } - if ($text -match '(?i)terminal gate|smoke|TerminalGate\.Tests|zmx send|uia') { - throw "RED: provider-build.ps1 ran terminal tests or smoke: $text" - } - foreach ($case in @( - @{ Name = "missing artifact"; Environment = @{ FAKE_ZIG_SKIP_ARTIFACT = "1" } }, - @{ Name = "failed compiler"; Environment = @{ FAKE_ZIG_FAIL = "1" } })) { - $result = Invoke-ProviderBuildCase $case.Environment - if ($result.ExitCode -eq 0 -or ($result.Output -join "`n") -match '(?m)^PROVIDER_BUILD_EXECUTED=') { - throw "RED: provider-build.ps1 accepted a partial build ($($case.Name))" - } - } - "untracked" | Set-Content -LiteralPath (Join-Path $scratch "zmx\dirty.txt") - $dirty = Invoke-ProviderBuildCase @{} - Remove-Item -LiteralPath (Join-Path $scratch "zmx\dirty.txt") -Force - if ($dirty.ExitCode -eq 0 -or $dirty.Invocations.Count -ne 0) { - throw "RED: provider-build.ps1 built from a dirty provider worktree" - } - $wrongPins = [ordered]@{ schemaVersion = 1; winghostty = $pins.winghostty; zmx = [ordered]@{ sha = ("0" * 40) } } - $wrongPins | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $pinsPath - $wrong = Invoke-ProviderBuildCase @{} - if ($wrong.ExitCode -eq 0 -or $wrong.Invocations.Count -ne 0) { - throw "RED: provider-build.ps1 built an unpinned provider" - } - } finally { - Remove-Item -LiteralPath $scratch -Recurse -Force -ErrorAction SilentlyContinue - } -} - +function Get-WorkflowSteps([string] $jobText) { + $jobText = $jobText.Replace("`r`n", "`n") + $heads = @([regex]::Matches($jobText, '(?m)^ - ')) + $steps = [Collections.Generic.List[string]]::new() + for ($index = 0; $index -lt $heads.Count; $index++) { + $end = if ($index + 1 -lt $heads.Count) { $heads[$index + 1].Index } else { $jobText.Length } + $steps.Add($jobText.Substring($heads[$index].Index, $end - $heads[$index].Index)) + } + , $steps.ToArray() +} + +# A cold provider cache miss must be able to seed its own immutable key even +# when a later gate fails or is cancelled, but never from a partial tree: the +# single save runs after a successful bootstrap, pin check, and build-only +# provider compile, and before any gate. Implicit success() gating (an `if:` +# without a status-check function) or an explicit success()/always() would +# reinstate whole-job gating or publish a failed build. +$script:ProviderCacheSaveCondition = "`${{ !cancelled() && steps.bootstrap.conclusion == 'success' && steps.verify-pins.conclusion == 'success' && steps.provider-build.conclusion == 'success' && steps.provider-cache.outputs.cache-hit != 'true' }}" +function Assert-ProviderCacheSeeding([string] $jobText, [string] $label, [bool] $requireGate) { + $steps = Get-WorkflowSteps $jobText + function Find-Step([scriptblock] $predicate) { + for ($index = 0; $index -lt $steps.Count; $index++) { + if (& $predicate $steps[$index]) { return $index } + } + return -1 + } + $restore = Find-Step { param($s) $s -match '(?m)^ id: provider-cache\s*$' -and $s -match 'actions/cache/restore@' } + $bootstrap = Find-Step { param($s) $s -match '(?m)^ id: bootstrap\s*$' -and $s -match '(?m)^ run: \./Tools/windows/bootstrap\.ps1 ' } + $verify = Find-Step { param($s) $s -match '(?m)^ id: verify-pins\s*$' -and $s -match '(?m)^ run: \./Tools/windows/Assert-ProviderCheckout\.ps1 -ProviderRoot \.ci-providers\s*$' } + $build = Find-Step { param($s) $s -match '(?m)^ id: provider-build\s*$' -and $s -match '(?m)^ run: \./Tools/windows/validate\.ps1 -Task provider-build\s*$' } + $saves = @(for ($index = 0; $index -lt $steps.Count; $index++) { if ($steps[$index] -match 'actions/cache/save@') { $index } }) + $gate = Find-Step { param($s) $s -match '(?m)^ run: \./Tools/windows/validate\.ps1 -Task windows-shell\b' } + foreach ($required in @( + @{ Index = $restore; Name = "provider cache restore (id provider-cache)" }, + @{ Index = $bootstrap; Name = "bootstrap (id bootstrap)" }, + @{ Index = $verify; Name = "pin verification (id verify-pins)" }, + @{ Index = $build; Name = "build-only provider compile (id provider-build, validate.ps1 -Task provider-build)" })) { + if ($required.Index -lt 0) { throw "RED: $label has no $($required.Name) before its provider cache save" } + } + if ($saves.Count -ne 1) { throw "RED: $label must have exactly one provider cache save, found $($saves.Count)" } + $save = $saves[0] + if ($requireGate -and $gate -lt 0) { throw "RED: $label has no downstream windows-shell gate" } + if (-not ($restore -lt $bootstrap -and $bootstrap -lt $verify -and $verify -lt $build -and $build -lt $save)) { + throw "RED: $label does not save only after restore, bootstrap, pin verification, and build-only compile" + } + if ($requireGate -and $save -gt $gate) { + throw "RED: $label saves the provider cache after the gate, so a failed or cancelled gate discards a cold build" + } + $saveText = $steps[$save] + $condition = [regex]::Match($saveText, '(?m)^ if:\s*(.+?)\s*$') + if (-not $condition.Success) { throw "RED: $label provider cache save has no explicit condition" } + $conditionText = $condition.Groups[1].Value + if ($conditionText -match '(?> "%FAKE_ZIG_LOG%"', + 'if "%FAKE_ZIG_FAIL%"=="1" exit /b 7', + 'if "%FAKE_ZIG_SKIP_ARTIFACT%"=="1" exit /b 0', + 'echo %* | findstr /c:"-Demit-win32-host=true" >nul && (mkdir zig-out\lib 2>nul & type nul > zig-out\lib\winghostty-win32-host.lib)', + 'echo %* | findstr /c:"-Dtarget=x86_64-windows-gnu" >nul && (mkdir zig-out\bin 2>nul & type nul > zig-out\bin\zmx.exe)', + 'exit /b 0') + $pins = [ordered]@{ schemaVersion = 1 } + foreach ($name in @("winghostty", "zmx")) { + $root = Join-Path $scratch $name + New-Item -ItemType Directory -Force $root | Out-Null + git -C $root init -q 2>$null + "zig-out/`n" | Set-Content -LiteralPath (Join-Path $root ".gitignore") -NoNewline + git -C $root add .gitignore + git -C $root -c user.name=t -c user.email=t@example.invalid commit -q -m pin 2>$null + $pins[$name] = [ordered]@{ sha = (git -C $root rev-parse HEAD) } + } + $pinsPath = Join-Path $scratch "provider-pins.json" + $pins | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $pinsPath + function Invoke-ProviderBuildCase([hashtable] $environment) { + foreach ($name in @("winghostty", "zmx")) { + Remove-Item -LiteralPath (Join-Path $scratch "$name\zig-out") -Recurse -Force -ErrorAction SilentlyContinue + } + $log = Join-Path $scratch "zig-$([guid]::NewGuid().ToString('N')).log" + $saved = @{} + $all = @{ FAKE_ZIG_LOG = $log; FAKE_ZIG_FAIL = $null; FAKE_ZIG_SKIP_ARTIFACT = $null } + foreach ($entry in $environment.GetEnumerator()) { $all[$entry.Key] = $entry.Value } + foreach ($entry in $all.GetEnumerator()) { + $saved[$entry.Key] = [Environment]::GetEnvironmentVariable($entry.Key) + [Environment]::SetEnvironmentVariable($entry.Key, $entry.Value) + } + try { + $output = @(& $pwsh -NoProfile -File $script ` + -WinghosttyRoot (Join-Path $scratch "winghostty") -ZmxRoot (Join-Path $scratch "zmx") ` + -Zig0152 $fakeZig -Zig0160 $fakeZig -PinsPath $pinsPath -ZmxAttempts 1 2>&1 | ForEach-Object { "$_" }) + $exit = $LASTEXITCODE + } finally { + foreach ($entry in $saved.GetEnumerator()) { [Environment]::SetEnvironmentVariable($entry.Key, $entry.Value) } + } + $invocations = if (Test-Path -LiteralPath $log) { @(Get-Content -LiteralPath $log) } else { @() } + [pscustomobject]@{ ExitCode = $exit; Output = $output; Invocations = $invocations } + } + $pass = Invoke-ProviderBuildCase @{} + $text = $pass.Output -join "`n" + $executed = [regex]::Match($text, '(?m)^PROVIDER_BUILD_EXECUTED=(\d+)\s*$') + $stepLines = @($pass.Output | Where-Object { $_ -match '^PROVIDER_BUILD_STEP=' }) + if ($pass.ExitCode -ne 0 -or -not $executed.Success -or [int]$executed.Groups[1].Value -ne 2 -or + $stepLines.Count -ne 2 -or $pass.Invocations.Count -ne 2) { + throw "RED: provider-build.ps1 did not execute exactly two pinned provider builds (exit=$($pass.ExitCode)): $text" + } + if ($pass.Invocations[0] -notmatch '^FAKE_ZIG build -Demit-win32-host=true\s*$' -or + $pass.Invocations[1] -notmatch '^FAKE_ZIG build -Dtarget=x86_64-windows-gnu\s*$') { + throw "RED: provider-build.ps1 changed the canonical provider build flags: $($pass.Invocations -join '; ')" + } + if ($text -match '(?i)terminal gate|smoke|TerminalGate\.Tests|zmx send|uia') { + throw "RED: provider-build.ps1 ran terminal tests or smoke: $text" + } + foreach ($case in @( + @{ Name = "missing artifact"; Environment = @{ FAKE_ZIG_SKIP_ARTIFACT = "1" } }, + @{ Name = "failed compiler"; Environment = @{ FAKE_ZIG_FAIL = "1" } })) { + $result = Invoke-ProviderBuildCase $case.Environment + if ($result.ExitCode -eq 0 -or ($result.Output -join "`n") -match '(?m)^PROVIDER_BUILD_EXECUTED=') { + throw "RED: provider-build.ps1 accepted a partial build ($($case.Name))" + } + } + "untracked" | Set-Content -LiteralPath (Join-Path $scratch "zmx\dirty.txt") + $dirty = Invoke-ProviderBuildCase @{} + Remove-Item -LiteralPath (Join-Path $scratch "zmx\dirty.txt") -Force + if ($dirty.ExitCode -eq 0 -or $dirty.Invocations.Count -ne 0) { + throw "RED: provider-build.ps1 built from a dirty provider worktree" + } + $wrongPins = [ordered]@{ schemaVersion = 1; winghostty = $pins.winghostty; zmx = [ordered]@{ sha = ("0" * 40) } } + $wrongPins | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $pinsPath + $wrong = Invoke-ProviderBuildCase @{} + if ($wrong.ExitCode -eq 0 -or $wrong.Invocations.Count -ne 0) { + throw "RED: provider-build.ps1 built an unpinned provider" + } + } finally { + Remove-Item -LiteralPath $scratch -Recurse -Force -ErrorAction SilentlyContinue + } +} + # The Windows shell Zig sections are sharded across runners. A section passes # only with a positive test count per `zig test` (a filter that matches nothing # still exits 0), the shard plan is a complete disjoint partition, and the @@ -2518,392 +2539,392 @@ function Test-ShellSectionGate([string] $repoRoot, [string] $pwsh) { Remove-Item -LiteralPath $scratch -Recurse -Force -ErrorAction SilentlyContinue } } -function Test-ZigResolverDiagnostics([string] $source) { - $tokens = $null - $errors = $null - $ast = [Management.Automation.Language.Parser]::ParseInput($source, [ref]$tokens, [ref]$errors) - if ($errors.Count -ne 0) { throw "Resolver source does not parse" } - foreach ($name in @("Invoke-ZigResolverProbe", "Write-ZigResolverDiagnostic", "Resolve-ZigVersion")) { - $definition = $ast.Find({ - param($node) - $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq $name - }, $true) - if ($null -eq $definition) { throw "Missing actual resolver helper: $name" } - . ([scriptblock]::Create($definition.Extent.Text)) - } - function Assert-Resolver([bool] $condition, [string] $message) { - if (-not $condition) { throw "Zig diagnostic contract: $message" } - } - $environmentName = "GRAPHCODE_ZIG_RESOLVER_TEST" - $priorEnvironment = [Environment]::GetEnvironmentVariable($environmentName) - $priorExit = Get-Variable LASTEXITCODE -Scope Global -ErrorAction SilentlyContinue - $priorExitValue = if ($null -ne $priorExit) { $priorExit.Value } else { $null } - try { - function InMemoryZigProbe([string] $operation) { - if ($operation -eq "throw") { throw [ComponentModel.Win32Exception]::new(5, "ghp_PRIVATE_CANARY") } - $global:LASTEXITCODE = -1073741502 - Write-Output "0.15.2" - Write-Error "ghp_PRIVATE_CANARY" -ErrorAction Continue - } - $probe = Invoke-ZigResolverProbe "InMemoryZigProbe" "version" - Assert-Resolver ($probe.Output -ceq "0.15.2" -and $probe.ExitCode -eq -1073741502) "actual probe lost stdout or native exit" - Assert-Resolver ($probe.ErrorText -match "ghp_PRIVATE_CANARY" -and $null -eq $probe.Failure) "actual probe did not separate stderr" - $probe = Invoke-ZigResolverProbe "InMemoryZigProbe" "throw" - Assert-Resolver ($null -eq $probe.ExitCode -and $null -ne $probe.Failure) "launch failure reused stale LASTEXITCODE" - - $a = "C:\fixture\configured\zig.exe" - $b = "C:\fixture\path\zig.exe" - $c = "C:\fixture\discovered\zig.exe" - $repoRoot = "C:\fixture\repo" - $state = @{ - PathCandidate = $b; Discovered = @($c); Exists = @{}; Probes = @{} - Calls = [Collections.Generic.List[string]]::new() - Diagnostics = [Collections.Generic.List[string]]::new() - Warnings = [Collections.Generic.List[string]]::new() - WriterFails = $false - WarningFails = $false - } - function Get-Command($Name, $ErrorAction) { [pscustomobject]@{ Source = $state.PathCandidate } } - function Get-ChildItem($Path, [switch] $Recurse, $Filter, [switch] $File, $ErrorAction) { - foreach ($entry in $state.Discovered) { [pscustomobject]@{ FullName = $entry } } - } - function Test-Path($LiteralPath, $PathType, $ErrorAction) { $state.Exists[$LiteralPath] -eq $true } - function Resolve-Path($LiteralPath) { [pscustomobject]@{ Path = $LiteralPath } } - function Invoke-ZigResolverProbe([string] $candidate, [string] $operation) { - $key = "$candidate|$operation" - $state.Calls.Add($key) - if (-not $state.Probes.ContainsKey($key)) { throw "Unplanned in-memory probe" } - $state.Probes[$key] - } - function Write-Host($Object) { - if ($state.WriterFails) { throw "ghp_PRIVATE_CANARY" } - $state.Diagnostics.Add([string]$Object) - } - function Write-Warning($Message, $WarningAction) { - $state.Warnings.Add([string]$Message) - if ($state.WarningFails) { throw "ghp_PRIVATE_CANARY" } - } - function New-Probe($output, $exitCode = 0, $stderr = "") { - [pscustomobject]@{ Output = $output; ExitCode = $exitCode; ErrorText = $stderr; Failure = $null } - } - function Reset-ResolverCase { - $state.Calls.Clear(); $state.Diagnostics.Clear(); $state.Warnings.Clear() - $state.WriterFails = $false - $state.WarningFails = $false - $state.PathCandidate = $b - $state.Discovered = @($a, $c) - $state.Exists = @{ $a = $true; $b = $true; $c = $true } - $state.Probes = @{} - foreach ($candidate in @($a, $b, $c)) { - $state.Probes["$candidate|version"] = New-Probe "0.15.2" - $state.Probes["$candidate|env"] = New-Probe '{"version":"0.15.2","lib_dir":"C:\\fixture\\lib"}' - } - [Environment]::SetEnvironmentVariable($environmentName, $a) - } - function Invoke-ResolverCase { - $result = @() - $failure = $null - try { $result = @(Resolve-ZigVersion "0.15.2" $environmentName) } catch { $failure = $_ } - [pscustomobject]@{ Result = $result; Failure = $failure } - } - function Read-Diagnostic([int] $index = 0) { - $line = $state.Diagnostics[$index] - Assert-Resolver ($line.StartsWith("ZIG_RESOLVER_DIAGNOSTIC ") -and $line.Length -lt 2500) "diagnostic tag or bound" - Assert-Resolver ($line -notmatch "ghp_PRIVATE_CANARY|userinfo|GITHUB_TOKEN") "secret canary escaped diagnostic" - $line.Substring("ZIG_RESOLVER_DIAGNOSTIC ".Length) | ConvertFrom-Json - } - - Reset-ResolverCase - $case = Invoke-ResolverCase - Assert-Resolver ($case.Result.Count -eq 1 -and $case.Result[0] -ceq $a -and $null -eq $case.Failure) "success return changed" - Assert-Resolver (($state.Calls -join ",") -ceq "$a|version,$a|env" -and $state.Diagnostics.Count -eq 0) "success probes repeated or diagnosed" - Reset-ResolverCase - $state.Exists[$a] = $false - $state.Probes["$b|version"] = New-Probe "0.16.0" - $case = Invoke-ResolverCase - Assert-Resolver ($case.Result.Count -eq 1 -and $case.Result[0] -ceq $c) "fallback selection changed" - Assert-Resolver (($state.Calls -join ",") -ceq "$b|version,$c|version,$c|env") "fallback order/deduplication/env skipping changed" - $missing = Read-Diagnostic - $mismatch = Read-Diagnostic 1 - Assert-Resolver (-not $missing.exists -and $null -eq $missing.version -and $missing.source -eq "configured") "missing candidate fabricated probe" - Assert-Resolver ($mismatch.source -eq "PATH" -and $mismatch.version.observedVersion -eq "0.16.0" -and $null -eq $mismatch.env) "mismatch evidence wrong" - - Reset-ResolverCase - $state.Probes["$a|version"] = New-Probe "ghp_PRIVATE_CANARY" -1073741502 "ghp_PRIVATE_CANARY" - $case = Invoke-ResolverCase - $diagnostic = Read-Diagnostic - Assert-Resolver ($case.Result[0] -ceq $b -and $diagnostic.version.exitCodeHex -eq "0xC0000142") "nonzero exit/fallback changed" - Assert-Resolver ($null -eq $diagnostic.version.observedVersion -and $diagnostic.version.stderr.reason -eq "unclassified") "unsafe version or stderr surfaced" - foreach ($envText in @("invalid ghp_PRIVATE_CANARY", '{"version":"0.15.2","lib_dir":"C:\\fixture\\absent","env":{"GITHUB_TOKEN":"ghp_PRIVATE_CANARY"}}')) { - Reset-ResolverCase - $state.Probes["$a|env"] = New-Probe $envText 9 "error: unable to find zig installation directory ghp_PRIVATE_CANARY" - $case = Invoke-ResolverCase - $diagnostic = Read-Diagnostic - Assert-Resolver ($case.Result[0] -ceq $b -and $diagnostic.reason -eq "env-exit" -and $diagnostic.env.exitCode -eq 9) "env failure selection changed" - Assert-Resolver ($diagnostic.env.stderr.reason -eq "unable to find zig installation directory") "safe known reason missing" - if ($envText.StartsWith("{")) { - Assert-Resolver ($diagnostic.env.parse -eq "parsed" -and $diagnostic.env.libDirectoryPresent -eq $false) "library metadata missing" - } else { - Assert-Resolver ($diagnostic.env.parse -eq "metadata-unavailable") "parse metadata missing" - } - $state.Probes["$a|env"] = New-Probe $envText - $state.Diagnostics.Clear() - $case = Invoke-ResolverCase - Assert-Resolver ($case.Result[0] -ceq $a -and $state.Diagnostics.Count -eq 0) "new JSON/lib gate was introduced" - } - - Reset-ResolverCase - $failure = [Management.Automation.ErrorRecord]::new( - [Management.Automation.RuntimeException]::new("ghp_PRIVATE_CANARY", - [ComponentModel.Win32Exception]::new(5, "ghp_PRIVATE_CANARY")), - "Launch", [Management.Automation.ErrorCategory]::OpenError, $null) - $state.Probes["$a|version"] = [pscustomobject]@{ Output = $null; ExitCode = $null; ErrorText = ""; Failure = $failure } - $case = Invoke-ResolverCase - $diagnostic = Read-Diagnostic - Assert-Resolver ($null -ne $case.Failure -and $state.Calls.Count -eq 1 -and $case.Failure.ToString() -notmatch "ghp_PRIVATE_CANARY") "exception changed stop behavior or exposed secret" - Assert-Resolver ($null -eq $diagnostic.version.exitCode -and $diagnostic.version.nativeErrorCode -eq 5) "exception fabricated exit" - Reset-ResolverCase - $state.Probes["$a|env"] = [pscustomobject]@{ Output = $null; ExitCode = $null; ErrorText = ""; Failure = $failure } - $case = Invoke-ResolverCase - $diagnostic = Read-Diagnostic - Assert-Resolver ($null -ne $case.Failure -and $state.Calls.Count -eq 2 -and $diagnostic.reason -eq "env-exception") "env exception did not stop after one probe" - Assert-Resolver ($null -eq $diagnostic.env.exitCode -and $diagnostic.env.nativeErrorCode -eq 5) "env exception lost nullable exit/native code" - Reset-ResolverCase - $state.Probes["$a|version"] = New-Probe "0.16.0" - $state.WriterFails = $true - $case = Invoke-ResolverCase - Assert-Resolver ($case.Result[0] -ceq $b -and $state.Warnings.Count -eq 1) "writer failure changed fallback" - $state.WarningFails = $true - $state.Calls.Clear() - $case = Invoke-ResolverCase - Assert-Resolver ($case.Result.Count -eq 1 -and $case.Result[0] -ceq $b -and $null -eq $case.Failure) "both writer failures changed fallback" - Assert-Resolver (($state.Calls -join ",") -ceq "$a|version,$b|version,$b|env") "both writer failures changed probe order" - foreach ($candidate in @($a, $b, $c)) { $state.Exists[$candidate] = $false } - $case = Invoke-ResolverCase - Assert-Resolver ($case.Result.Count -eq 0 -and $case.Failure.ToString() -eq "Zig 0.15.2 is required for the pinned Windows provider; set $environmentName.") "both writer failures masked original guard" - - Reset-ResolverCase - $nonAsciiVersion = ([string][char]0x0661) + ".2.3" - $overLimitVersion = "1.2.3+" + ("a" * 65) - foreach ($unsafeVersion in @($nonAsciiVersion, $overLimitVersion)) { - foreach ($probeName in @("version", "env")) { - $state.Diagnostics.Clear() - $text = if ($probeName -eq "version") { $unsafeVersion } else { @{ version = $unsafeVersion } | ConvertTo-Json -Compress } - $probe = New-Probe $text 1 - if ($probeName -eq "version") { - Write-ZigResolverDiagnostic $a "configured" "0.15.2" $true "version-exit" $probe $null - } else { - Write-ZigResolverDiagnostic $a "configured" "0.15.2" $true "env-exit" (New-Probe "0.15.2") $probe - } - $diagnostic = Read-Diagnostic - $summary = $diagnostic.$probeName - Assert-Resolver ($null -eq $summary.observedVersion -and - $summary.stdout.bytes -eq [Text.Encoding]::UTF8.GetByteCount($text) -and - $summary.stdout.sha256.Length -eq 64) "non-ASCII or over-limit version was not reduced to hash/length" - Assert-Resolver (-not $state.Diagnostics[0].Contains($unsafeVersion)) "unsafe version appeared literally" - } - } - Assert-Resolver ([Text.Encoding]::UTF8.GetByteCount($overLimitVersion) -gt 64) "version size control is not over limit" - $state.WriterFails = $false - foreach ($candidate in @("https://userinfo@github.com/zig", "C:\ghp_PRIVATE_CANARY\zig.exe", ("C:\" + ("x" * 520)))) { - $state.Diagnostics.Clear() - Write-ZigResolverDiagnostic $candidate "configured" "0.15.2" $true "version-exit" (New-Probe ("ghp_PRIVATE_CANARY" * 2000) 1) $null - $diagnostic = Read-Diagnostic - Assert-Resolver ($diagnostic.candidate -eq "[omitted]" -and $diagnostic.version.stdout.bytes -gt 16384) "candidate/output cap or redaction failed" - } - } finally { - [Environment]::SetEnvironmentVariable($environmentName, $priorEnvironment) - if ($null -eq $priorExit) { Remove-Variable LASTEXITCODE -Scope Global -ErrorAction SilentlyContinue } - else { $global:LASTEXITCODE = $priorExitValue } - } -} - -$runner = Join-Path $PSScriptRoot "..\validate.ps1" -if (-not (Test-Path $runner)) { - throw "RED: validation runner does not exist at $runner" -} - -$tasks = & $runner -List -$expected = @( - "swift-portable", - "swift-contracts", - "swift-production", - "swift-paths", - "swift-process", - "swift-named-pipe", - "remote-bridge", - "remote-e2e", - "swift-format", - "visual-baseline", - "tdd-evidence", - "privacy", - "terminal-gate", - "windows-shell", - "packaging", - "hardening" -) -foreach ($task in $expected) { - if ($tasks -notcontains $task) { - throw "Validation task '$task' is missing" - } -} - -$dryRun = & $runner -Task swift-paths -DryRun -if ($LASTEXITCODE -ne 0) { - throw "Dry run failed with exit code $LASTEXITCODE" -} -if (($dryRun -join "`n") -notmatch "swift-paths") { - throw "Dry run did not name the selected task" -} - -# provider-build is the build-only entry point for provider cache seeding: it is -# selectable on its own but never part of -Task all (terminal-gate reuses it). -if ($tasks -notcontains "provider-build") { - throw "RED: validation runner has no build-only provider-build task" -} -$providerDryRun = @(& $runner -Task provider-build -DryRun) -if ($LASTEXITCODE -ne 0 -or $providerDryRun.Count -ne 1 -or $providerDryRun[0] -cne "task=provider-build") { - throw "RED: -Task provider-build does not select exactly the build-only task: $($providerDryRun -join ', ')" -} -$allDryRun = @(& $runner -Task all -DryRun) -if ($allDryRun.Count -lt 10 -or $allDryRun -contains "task=provider-build" -or $allDryRun -notcontains "task=terminal-gate") { - throw "RED: -Task all must keep terminal-gate and exclude the build-only provider-build task: $($allDryRun -join ', ')" -} -$providerRunnerSource = Get-Content $runner -Raw -$providerClause = [regex]::Match($providerRunnerSource, '(?s)\n "provider-build" \{(.*?)\n \}') -if (-not $providerClause.Success -or $providerClause.Groups[1].Value -notmatch 'Invoke-ProviderBuild' -or - $providerClause.Groups[1].Value -match '(?i)terminal-gate\.ps1|TerminalGate\.Tests|windows-shell\.ps1|uia|Stress') { - throw "RED: provider-build task does not run only the shared build-only provider compile" -} -$terminalClause = [regex]::Match($providerRunnerSource, '(?s)\n "terminal-gate" \{(.*?)\n \}') -if (-not $terminalClause.Success -or - $terminalClause.Groups[1].Value -notmatch '(?s)Invoke-ProviderBuild.*?terminal-gate\.ps1.*?-SkipProviderBuild.*?-Stress') { - throw "RED: terminal-gate does not reuse the build-only provider compile before its full gate" -} -$providerScriptSource = Get-Content (Join-Path $PSScriptRoot "..\provider-build.ps1") -Raw -ErrorAction SilentlyContinue -foreach ($consumer in @("terminal-gate.ps1", "windows-shell.ps1")) { - $consumerSource = Get-Content (Join-Path $PSScriptRoot "..\$consumer") -Raw - if ($consumerSource -notmatch 'provider-build\.ps1' -or - $consumerSource -match '-Demit-win32-host=true' -or - $consumerSource -match '-Dtarget=x86_64-windows-gnu') { - throw "RED: $consumer duplicates the pinned provider build instead of calling provider-build.ps1" - } -} -if ($providerScriptSource -notmatch '-Demit-win32-host=true' -or $providerScriptSource -notmatch '-Dtarget=x86_64-windows-gnu') { - throw "RED: provider-build.ps1 does not own the canonical provider build flags" -} - -$pwsh = (Get-Process -Id $PID).Path -& $pwsh -NoProfile -File $runner -Task not-a-task *> $null -if ($LASTEXITCODE -eq 0) { - throw "An unknown validation task succeeded" -} - -$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..\..\..") -$untrackedDirectory = Join-Path $repoRoot "investigation\spikes\validation-runner-untracked" -New-Item -ItemType Directory -Force $untrackedDirectory | Out-Null -try { - "let value=1" | Set-Content (Join-Path $untrackedDirectory "Unformatted.swift") - & $pwsh -NoProfile -File $runner -Task swift-format *> $null - if ($LASTEXITCODE -eq 0) { - throw "An unformatted untracked Swift source was ignored" - } -} finally { - Remove-Item -LiteralPath $untrackedDirectory -Recurse -Force -} - -$foreignJunction = Join-Path $repoRoot ` - "investigation\spikes\swift-contracts\Sources\GraphcodeWindowsContracts\OwnershipSentinel" -New-Item -ItemType Directory -Force $foreignJunction | Out-Null -try { - & $runner -Task swift-format -DryRun *> $null - if (-not (Test-Path $foreignJunction)) { - throw "A validation task removed resources owned by another task" - } - - $windowsWorkflow = Get-Content (Join-Path $repoRoot ".github\workflows\windows-hardening.yml") -Raw - if ($windowsWorkflow -notmatch "(?s)full-pinned:.*bootstrap\.ps1.*validate\.ps1 -Task all.*Hardening\.Tests\.ps1 -Environment") { - throw "RED: full-pinned Windows CI does not run real hardening after provider setup" - } - if ($windowsWorkflow -notmatch "GRAPHCODE_HARDENING_TARGET") { - throw "RED: full-pinned Windows CI does not provide an owned environment harness" - } - $hardeningSource = Get-Content (Join-Path $PSScriptRoot "Hardening.Tests.ps1") -Raw - foreach ($stage in @("real zmx/ConPTY terminal matrix", "real GraphCode shell matrix")) { - if ($hardeningSource -notmatch ([regex]::Escape($stage) + ' failed \(exit=\$LASTEXITCODE; hex=')) { - throw "Hardening stage failure omits the native exit code: $stage" - } - } - & { - $tokens = $null - $errors = $null - $ast = [Management.Automation.Language.Parser]::ParseInput( - $hardeningSource, [ref]$tokens, [ref]$errors) - foreach ($name in @("Find-Bytes", "Get-HighOutputDiagnostics", "Get-HighOutputPayloadText")) { - $function = $ast.Find({ - param($node) - $node -is [Management.Automation.Language.FunctionDefinitionAst] -and - $node.Name -eq $name - }, $true) - if (-not $function) { throw "RED: high-output diagnostics helper is missing: $name" } - . ([scriptblock]::Create($function.Extent.Text)) - } - $bytes = [Text.Encoding]::ASCII.GetBytes("START" + ("A" * 1024) + "END") - $diagnostics = Get-HighOutputDiagnostics $bytes "START" "END" - if ($diagnostics.capturedBytes -ne $bytes.Length -or - $diagnostics.startOffset -ne 0 -or $diagnostics.endOffset -ne 1029 -or - $diagnostics.prefix.Length -ne 512 -or $diagnostics.suffix.Length -ne 512) { - throw "High-output diagnostics lost marker positions or exceeded transcript bounds" - } - $partial = Get-HighOutputDiagnostics ([Text.Encoding]::ASCII.GetBytes("END")) "START" "END" - if ($partial.startOffset -ne -1 -or $partial.endOffset -ne 0) { - throw "High-output diagnostics hide an end marker when the start marker is missing" - } - $empty = Get-HighOutputDiagnostics ([byte[]]::new(0)) "START" "END" - if ($empty.capturedBytes -ne 0 -or $empty.startOffset -ne -1 -or - $empty.endOffset -ne -1 -or $empty.prefix -ne "" -or $empty.suffix -ne "") { - throw "High-output diagnostics cannot report an empty capture" - } - $escape = [string][char]27 - $captures = @( - "STARTAAAAEND", - "ST${escape}[0mARTAA${escape}[31mAAEN${escape}[0mD", - "STA`r`nRTAAAAE`r`nND", - "START${escape}]0;END$([char]7)AAAAEND", - "ST${escape}]0;title${escape}\ARTAAAAEND", - "${escape}]0;before${escape}\STARTAAAAEND${escape}]0;after${escape}\" - ) - foreach ($capture in $captures) { - $payload = Get-HighOutputPayloadText ([Text.Encoding]::ASCII.GetBytes($capture)) "START" "END" - if ($payload -cne "AAAA") { - throw "RED: high-output completion must survive terminal framing inside markers" - } - } - foreach ($capture in @("", "STARTAAAA", "AAAAEND", "ENDSTARTAAAA", - "${escape}]0;STARTAAAAEND")) { - $payload = Get-HighOutputPayloadText ([Text.Encoding]::ASCII.GetBytes($capture)) "START" "END" - if ($null -ne $payload) { throw "Incomplete or reversed output markers were accepted" } - } - $emptyPayload = Get-HighOutputPayloadText ([Text.Encoding]::ASCII.GetBytes("STARTEND")) "START" "END" - if ($null -eq $emptyPayload -or $emptyPayload -cne "") { - throw "Empty completed output must reach the length/hash checks, not look pending" - } - } - if ($hardeningSource -notmatch - '(?s)if \(-not \$completed\).*?Get-HighOutputDiagnostics.*?HARDENING_OUTPUT_DIAGNOSTICS_JSON=.*?Assert-True \$completed') { - throw "RED: real high-output failure omits bounded transcript diagnostics" - } - if ($hardeningSource -notmatch - '(?s)if \(\$LASTEXITCODE -ne 0\) \{\s*\$output \| Write-Output\s*throw "hardening repeated run') { - throw "RED: failed repeated hardening discards its child diagnostics" - } - if ($hardeningSource -notmatch - '(?s)\$shellVersion\s*=\s*\(& \$shell --version.*?-Version \$shellVersion') { - throw "RED: post-release hardening does not preserve the built shell version" - } - $windowsShellWorkflow = Get-Content (Join-Path $repoRoot ".github\workflows\windows-shell.yml") -Raw - $windowsPortWorkflow = Get-Content ` - (Join-Path $repoRoot ".github\workflows\windows-port-validation.yml") -Raw +function Test-ZigResolverDiagnostics([string] $source) { + $tokens = $null + $errors = $null + $ast = [Management.Automation.Language.Parser]::ParseInput($source, [ref]$tokens, [ref]$errors) + if ($errors.Count -ne 0) { throw "Resolver source does not parse" } + foreach ($name in @("Invoke-ZigResolverProbe", "Write-ZigResolverDiagnostic", "Resolve-ZigVersion")) { + $definition = $ast.Find({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq $name + }, $true) + if ($null -eq $definition) { throw "Missing actual resolver helper: $name" } + . ([scriptblock]::Create($definition.Extent.Text)) + } + function Assert-Resolver([bool] $condition, [string] $message) { + if (-not $condition) { throw "Zig diagnostic contract: $message" } + } + $environmentName = "GRAPHCODE_ZIG_RESOLVER_TEST" + $priorEnvironment = [Environment]::GetEnvironmentVariable($environmentName) + $priorExit = Get-Variable LASTEXITCODE -Scope Global -ErrorAction SilentlyContinue + $priorExitValue = if ($null -ne $priorExit) { $priorExit.Value } else { $null } + try { + function InMemoryZigProbe([string] $operation) { + if ($operation -eq "throw") { throw [ComponentModel.Win32Exception]::new(5, "ghp_PRIVATE_CANARY") } + $global:LASTEXITCODE = -1073741502 + Write-Output "0.15.2" + Write-Error "ghp_PRIVATE_CANARY" -ErrorAction Continue + } + $probe = Invoke-ZigResolverProbe "InMemoryZigProbe" "version" + Assert-Resolver ($probe.Output -ceq "0.15.2" -and $probe.ExitCode -eq -1073741502) "actual probe lost stdout or native exit" + Assert-Resolver ($probe.ErrorText -match "ghp_PRIVATE_CANARY" -and $null -eq $probe.Failure) "actual probe did not separate stderr" + $probe = Invoke-ZigResolverProbe "InMemoryZigProbe" "throw" + Assert-Resolver ($null -eq $probe.ExitCode -and $null -ne $probe.Failure) "launch failure reused stale LASTEXITCODE" + + $a = "C:\fixture\configured\zig.exe" + $b = "C:\fixture\path\zig.exe" + $c = "C:\fixture\discovered\zig.exe" + $repoRoot = "C:\fixture\repo" + $state = @{ + PathCandidate = $b; Discovered = @($c); Exists = @{}; Probes = @{} + Calls = [Collections.Generic.List[string]]::new() + Diagnostics = [Collections.Generic.List[string]]::new() + Warnings = [Collections.Generic.List[string]]::new() + WriterFails = $false + WarningFails = $false + } + function Get-Command($Name, $ErrorAction) { [pscustomobject]@{ Source = $state.PathCandidate } } + function Get-ChildItem($Path, [switch] $Recurse, $Filter, [switch] $File, $ErrorAction) { + foreach ($entry in $state.Discovered) { [pscustomobject]@{ FullName = $entry } } + } + function Test-Path($LiteralPath, $PathType, $ErrorAction) { $state.Exists[$LiteralPath] -eq $true } + function Resolve-Path($LiteralPath) { [pscustomobject]@{ Path = $LiteralPath } } + function Invoke-ZigResolverProbe([string] $candidate, [string] $operation) { + $key = "$candidate|$operation" + $state.Calls.Add($key) + if (-not $state.Probes.ContainsKey($key)) { throw "Unplanned in-memory probe" } + $state.Probes[$key] + } + function Write-Host($Object) { + if ($state.WriterFails) { throw "ghp_PRIVATE_CANARY" } + $state.Diagnostics.Add([string]$Object) + } + function Write-Warning($Message, $WarningAction) { + $state.Warnings.Add([string]$Message) + if ($state.WarningFails) { throw "ghp_PRIVATE_CANARY" } + } + function New-Probe($output, $exitCode = 0, $stderr = "") { + [pscustomobject]@{ Output = $output; ExitCode = $exitCode; ErrorText = $stderr; Failure = $null } + } + function Reset-ResolverCase { + $state.Calls.Clear(); $state.Diagnostics.Clear(); $state.Warnings.Clear() + $state.WriterFails = $false + $state.WarningFails = $false + $state.PathCandidate = $b + $state.Discovered = @($a, $c) + $state.Exists = @{ $a = $true; $b = $true; $c = $true } + $state.Probes = @{} + foreach ($candidate in @($a, $b, $c)) { + $state.Probes["$candidate|version"] = New-Probe "0.15.2" + $state.Probes["$candidate|env"] = New-Probe '{"version":"0.15.2","lib_dir":"C:\\fixture\\lib"}' + } + [Environment]::SetEnvironmentVariable($environmentName, $a) + } + function Invoke-ResolverCase { + $result = @() + $failure = $null + try { $result = @(Resolve-ZigVersion "0.15.2" $environmentName) } catch { $failure = $_ } + [pscustomobject]@{ Result = $result; Failure = $failure } + } + function Read-Diagnostic([int] $index = 0) { + $line = $state.Diagnostics[$index] + Assert-Resolver ($line.StartsWith("ZIG_RESOLVER_DIAGNOSTIC ") -and $line.Length -lt 2500) "diagnostic tag or bound" + Assert-Resolver ($line -notmatch "ghp_PRIVATE_CANARY|userinfo|GITHUB_TOKEN") "secret canary escaped diagnostic" + $line.Substring("ZIG_RESOLVER_DIAGNOSTIC ".Length) | ConvertFrom-Json + } + + Reset-ResolverCase + $case = Invoke-ResolverCase + Assert-Resolver ($case.Result.Count -eq 1 -and $case.Result[0] -ceq $a -and $null -eq $case.Failure) "success return changed" + Assert-Resolver (($state.Calls -join ",") -ceq "$a|version,$a|env" -and $state.Diagnostics.Count -eq 0) "success probes repeated or diagnosed" + Reset-ResolverCase + $state.Exists[$a] = $false + $state.Probes["$b|version"] = New-Probe "0.16.0" + $case = Invoke-ResolverCase + Assert-Resolver ($case.Result.Count -eq 1 -and $case.Result[0] -ceq $c) "fallback selection changed" + Assert-Resolver (($state.Calls -join ",") -ceq "$b|version,$c|version,$c|env") "fallback order/deduplication/env skipping changed" + $missing = Read-Diagnostic + $mismatch = Read-Diagnostic 1 + Assert-Resolver (-not $missing.exists -and $null -eq $missing.version -and $missing.source -eq "configured") "missing candidate fabricated probe" + Assert-Resolver ($mismatch.source -eq "PATH" -and $mismatch.version.observedVersion -eq "0.16.0" -and $null -eq $mismatch.env) "mismatch evidence wrong" + + Reset-ResolverCase + $state.Probes["$a|version"] = New-Probe "ghp_PRIVATE_CANARY" -1073741502 "ghp_PRIVATE_CANARY" + $case = Invoke-ResolverCase + $diagnostic = Read-Diagnostic + Assert-Resolver ($case.Result[0] -ceq $b -and $diagnostic.version.exitCodeHex -eq "0xC0000142") "nonzero exit/fallback changed" + Assert-Resolver ($null -eq $diagnostic.version.observedVersion -and $diagnostic.version.stderr.reason -eq "unclassified") "unsafe version or stderr surfaced" + foreach ($envText in @("invalid ghp_PRIVATE_CANARY", '{"version":"0.15.2","lib_dir":"C:\\fixture\\absent","env":{"GITHUB_TOKEN":"ghp_PRIVATE_CANARY"}}')) { + Reset-ResolverCase + $state.Probes["$a|env"] = New-Probe $envText 9 "error: unable to find zig installation directory ghp_PRIVATE_CANARY" + $case = Invoke-ResolverCase + $diagnostic = Read-Diagnostic + Assert-Resolver ($case.Result[0] -ceq $b -and $diagnostic.reason -eq "env-exit" -and $diagnostic.env.exitCode -eq 9) "env failure selection changed" + Assert-Resolver ($diagnostic.env.stderr.reason -eq "unable to find zig installation directory") "safe known reason missing" + if ($envText.StartsWith("{")) { + Assert-Resolver ($diagnostic.env.parse -eq "parsed" -and $diagnostic.env.libDirectoryPresent -eq $false) "library metadata missing" + } else { + Assert-Resolver ($diagnostic.env.parse -eq "metadata-unavailable") "parse metadata missing" + } + $state.Probes["$a|env"] = New-Probe $envText + $state.Diagnostics.Clear() + $case = Invoke-ResolverCase + Assert-Resolver ($case.Result[0] -ceq $a -and $state.Diagnostics.Count -eq 0) "new JSON/lib gate was introduced" + } + + Reset-ResolverCase + $failure = [Management.Automation.ErrorRecord]::new( + [Management.Automation.RuntimeException]::new("ghp_PRIVATE_CANARY", + [ComponentModel.Win32Exception]::new(5, "ghp_PRIVATE_CANARY")), + "Launch", [Management.Automation.ErrorCategory]::OpenError, $null) + $state.Probes["$a|version"] = [pscustomobject]@{ Output = $null; ExitCode = $null; ErrorText = ""; Failure = $failure } + $case = Invoke-ResolverCase + $diagnostic = Read-Diagnostic + Assert-Resolver ($null -ne $case.Failure -and $state.Calls.Count -eq 1 -and $case.Failure.ToString() -notmatch "ghp_PRIVATE_CANARY") "exception changed stop behavior or exposed secret" + Assert-Resolver ($null -eq $diagnostic.version.exitCode -and $diagnostic.version.nativeErrorCode -eq 5) "exception fabricated exit" + Reset-ResolverCase + $state.Probes["$a|env"] = [pscustomobject]@{ Output = $null; ExitCode = $null; ErrorText = ""; Failure = $failure } + $case = Invoke-ResolverCase + $diagnostic = Read-Diagnostic + Assert-Resolver ($null -ne $case.Failure -and $state.Calls.Count -eq 2 -and $diagnostic.reason -eq "env-exception") "env exception did not stop after one probe" + Assert-Resolver ($null -eq $diagnostic.env.exitCode -and $diagnostic.env.nativeErrorCode -eq 5) "env exception lost nullable exit/native code" + Reset-ResolverCase + $state.Probes["$a|version"] = New-Probe "0.16.0" + $state.WriterFails = $true + $case = Invoke-ResolverCase + Assert-Resolver ($case.Result[0] -ceq $b -and $state.Warnings.Count -eq 1) "writer failure changed fallback" + $state.WarningFails = $true + $state.Calls.Clear() + $case = Invoke-ResolverCase + Assert-Resolver ($case.Result.Count -eq 1 -and $case.Result[0] -ceq $b -and $null -eq $case.Failure) "both writer failures changed fallback" + Assert-Resolver (($state.Calls -join ",") -ceq "$a|version,$b|version,$b|env") "both writer failures changed probe order" + foreach ($candidate in @($a, $b, $c)) { $state.Exists[$candidate] = $false } + $case = Invoke-ResolverCase + Assert-Resolver ($case.Result.Count -eq 0 -and $case.Failure.ToString() -eq "Zig 0.15.2 is required for the pinned Windows provider; set $environmentName.") "both writer failures masked original guard" + + Reset-ResolverCase + $nonAsciiVersion = ([string][char]0x0661) + ".2.3" + $overLimitVersion = "1.2.3+" + ("a" * 65) + foreach ($unsafeVersion in @($nonAsciiVersion, $overLimitVersion)) { + foreach ($probeName in @("version", "env")) { + $state.Diagnostics.Clear() + $text = if ($probeName -eq "version") { $unsafeVersion } else { @{ version = $unsafeVersion } | ConvertTo-Json -Compress } + $probe = New-Probe $text 1 + if ($probeName -eq "version") { + Write-ZigResolverDiagnostic $a "configured" "0.15.2" $true "version-exit" $probe $null + } else { + Write-ZigResolverDiagnostic $a "configured" "0.15.2" $true "env-exit" (New-Probe "0.15.2") $probe + } + $diagnostic = Read-Diagnostic + $summary = $diagnostic.$probeName + Assert-Resolver ($null -eq $summary.observedVersion -and + $summary.stdout.bytes -eq [Text.Encoding]::UTF8.GetByteCount($text) -and + $summary.stdout.sha256.Length -eq 64) "non-ASCII or over-limit version was not reduced to hash/length" + Assert-Resolver (-not $state.Diagnostics[0].Contains($unsafeVersion)) "unsafe version appeared literally" + } + } + Assert-Resolver ([Text.Encoding]::UTF8.GetByteCount($overLimitVersion) -gt 64) "version size control is not over limit" + $state.WriterFails = $false + foreach ($candidate in @("https://userinfo@github.com/zig", "C:\ghp_PRIVATE_CANARY\zig.exe", ("C:\" + ("x" * 520)))) { + $state.Diagnostics.Clear() + Write-ZigResolverDiagnostic $candidate "configured" "0.15.2" $true "version-exit" (New-Probe ("ghp_PRIVATE_CANARY" * 2000) 1) $null + $diagnostic = Read-Diagnostic + Assert-Resolver ($diagnostic.candidate -eq "[omitted]" -and $diagnostic.version.stdout.bytes -gt 16384) "candidate/output cap or redaction failed" + } + } finally { + [Environment]::SetEnvironmentVariable($environmentName, $priorEnvironment) + if ($null -eq $priorExit) { Remove-Variable LASTEXITCODE -Scope Global -ErrorAction SilentlyContinue } + else { $global:LASTEXITCODE = $priorExitValue } + } +} + +$runner = Join-Path $PSScriptRoot "..\validate.ps1" +if (-not (Test-Path $runner)) { + throw "RED: validation runner does not exist at $runner" +} + +$tasks = & $runner -List +$expected = @( + "swift-portable", + "swift-contracts", + "swift-production", + "swift-paths", + "swift-process", + "swift-named-pipe", + "remote-bridge", + "remote-e2e", + "swift-format", + "visual-baseline", + "tdd-evidence", + "privacy", + "terminal-gate", + "windows-shell", + "packaging", + "hardening" +) +foreach ($task in $expected) { + if ($tasks -notcontains $task) { + throw "Validation task '$task' is missing" + } +} + +$dryRun = & $runner -Task swift-paths -DryRun +if ($LASTEXITCODE -ne 0) { + throw "Dry run failed with exit code $LASTEXITCODE" +} +if (($dryRun -join "`n") -notmatch "swift-paths") { + throw "Dry run did not name the selected task" +} + +# provider-build is the build-only entry point for provider cache seeding: it is +# selectable on its own but never part of -Task all (terminal-gate reuses it). +if ($tasks -notcontains "provider-build") { + throw "RED: validation runner has no build-only provider-build task" +} +$providerDryRun = @(& $runner -Task provider-build -DryRun) +if ($LASTEXITCODE -ne 0 -or $providerDryRun.Count -ne 1 -or $providerDryRun[0] -cne "task=provider-build") { + throw "RED: -Task provider-build does not select exactly the build-only task: $($providerDryRun -join ', ')" +} +$allDryRun = @(& $runner -Task all -DryRun) +if ($allDryRun.Count -lt 10 -or $allDryRun -contains "task=provider-build" -or $allDryRun -notcontains "task=terminal-gate") { + throw "RED: -Task all must keep terminal-gate and exclude the build-only provider-build task: $($allDryRun -join ', ')" +} +$providerRunnerSource = Get-Content $runner -Raw +$providerClause = [regex]::Match($providerRunnerSource, '(?s)\n "provider-build" \{(.*?)\n \}') +if (-not $providerClause.Success -or $providerClause.Groups[1].Value -notmatch 'Invoke-ProviderBuild' -or + $providerClause.Groups[1].Value -match '(?i)terminal-gate\.ps1|TerminalGate\.Tests|windows-shell\.ps1|uia|Stress') { + throw "RED: provider-build task does not run only the shared build-only provider compile" +} +$terminalClause = [regex]::Match($providerRunnerSource, '(?s)\n "terminal-gate" \{(.*?)\n \}') +if (-not $terminalClause.Success -or + $terminalClause.Groups[1].Value -notmatch '(?s)Invoke-ProviderBuild.*?terminal-gate\.ps1.*?-SkipProviderBuild.*?-Stress') { + throw "RED: terminal-gate does not reuse the build-only provider compile before its full gate" +} +$providerScriptSource = Get-Content (Join-Path $PSScriptRoot "..\provider-build.ps1") -Raw -ErrorAction SilentlyContinue +foreach ($consumer in @("terminal-gate.ps1", "windows-shell.ps1")) { + $consumerSource = Get-Content (Join-Path $PSScriptRoot "..\$consumer") -Raw + if ($consumerSource -notmatch 'provider-build\.ps1' -or + $consumerSource -match '-Demit-win32-host=true' -or + $consumerSource -match '-Dtarget=x86_64-windows-gnu') { + throw "RED: $consumer duplicates the pinned provider build instead of calling provider-build.ps1" + } +} +if ($providerScriptSource -notmatch '-Demit-win32-host=true' -or $providerScriptSource -notmatch '-Dtarget=x86_64-windows-gnu') { + throw "RED: provider-build.ps1 does not own the canonical provider build flags" +} + +$pwsh = (Get-Process -Id $PID).Path +& $pwsh -NoProfile -File $runner -Task not-a-task *> $null +if ($LASTEXITCODE -eq 0) { + throw "An unknown validation task succeeded" +} + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..\..\..") +$untrackedDirectory = Join-Path $repoRoot "investigation\spikes\validation-runner-untracked" +New-Item -ItemType Directory -Force $untrackedDirectory | Out-Null +try { + "let value=1" | Set-Content (Join-Path $untrackedDirectory "Unformatted.swift") + & $pwsh -NoProfile -File $runner -Task swift-format *> $null + if ($LASTEXITCODE -eq 0) { + throw "An unformatted untracked Swift source was ignored" + } +} finally { + Remove-Item -LiteralPath $untrackedDirectory -Recurse -Force +} + +$foreignJunction = Join-Path $repoRoot ` + "investigation\spikes\swift-contracts\Sources\GraphcodeWindowsContracts\OwnershipSentinel" +New-Item -ItemType Directory -Force $foreignJunction | Out-Null +try { + & $runner -Task swift-format -DryRun *> $null + if (-not (Test-Path $foreignJunction)) { + throw "A validation task removed resources owned by another task" + } + + $windowsWorkflow = Get-Content (Join-Path $repoRoot ".github\workflows\windows-hardening.yml") -Raw + if ($windowsWorkflow -notmatch "(?s)full-pinned:.*bootstrap\.ps1.*validate\.ps1 -Task all.*Hardening\.Tests\.ps1 -Environment") { + throw "RED: full-pinned Windows CI does not run real hardening after provider setup" + } + if ($windowsWorkflow -notmatch "GRAPHCODE_HARDENING_TARGET") { + throw "RED: full-pinned Windows CI does not provide an owned environment harness" + } + $hardeningSource = Get-Content (Join-Path $PSScriptRoot "Hardening.Tests.ps1") -Raw + foreach ($stage in @("real zmx/ConPTY terminal matrix", "real GraphCode shell matrix")) { + if ($hardeningSource -notmatch ([regex]::Escape($stage) + ' failed \(exit=\$LASTEXITCODE; hex=')) { + throw "Hardening stage failure omits the native exit code: $stage" + } + } + & { + $tokens = $null + $errors = $null + $ast = [Management.Automation.Language.Parser]::ParseInput( + $hardeningSource, [ref]$tokens, [ref]$errors) + foreach ($name in @("Find-Bytes", "Get-HighOutputDiagnostics", "Get-HighOutputPayloadText")) { + $function = $ast.Find({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -eq $name + }, $true) + if (-not $function) { throw "RED: high-output diagnostics helper is missing: $name" } + . ([scriptblock]::Create($function.Extent.Text)) + } + $bytes = [Text.Encoding]::ASCII.GetBytes("START" + ("A" * 1024) + "END") + $diagnostics = Get-HighOutputDiagnostics $bytes "START" "END" + if ($diagnostics.capturedBytes -ne $bytes.Length -or + $diagnostics.startOffset -ne 0 -or $diagnostics.endOffset -ne 1029 -or + $diagnostics.prefix.Length -ne 512 -or $diagnostics.suffix.Length -ne 512) { + throw "High-output diagnostics lost marker positions or exceeded transcript bounds" + } + $partial = Get-HighOutputDiagnostics ([Text.Encoding]::ASCII.GetBytes("END")) "START" "END" + if ($partial.startOffset -ne -1 -or $partial.endOffset -ne 0) { + throw "High-output diagnostics hide an end marker when the start marker is missing" + } + $empty = Get-HighOutputDiagnostics ([byte[]]::new(0)) "START" "END" + if ($empty.capturedBytes -ne 0 -or $empty.startOffset -ne -1 -or + $empty.endOffset -ne -1 -or $empty.prefix -ne "" -or $empty.suffix -ne "") { + throw "High-output diagnostics cannot report an empty capture" + } + $escape = [string][char]27 + $captures = @( + "STARTAAAAEND", + "ST${escape}[0mARTAA${escape}[31mAAEN${escape}[0mD", + "STA`r`nRTAAAAE`r`nND", + "START${escape}]0;END$([char]7)AAAAEND", + "ST${escape}]0;title${escape}\ARTAAAAEND", + "${escape}]0;before${escape}\STARTAAAAEND${escape}]0;after${escape}\" + ) + foreach ($capture in $captures) { + $payload = Get-HighOutputPayloadText ([Text.Encoding]::ASCII.GetBytes($capture)) "START" "END" + if ($payload -cne "AAAA") { + throw "RED: high-output completion must survive terminal framing inside markers" + } + } + foreach ($capture in @("", "STARTAAAA", "AAAAEND", "ENDSTARTAAAA", + "${escape}]0;STARTAAAAEND")) { + $payload = Get-HighOutputPayloadText ([Text.Encoding]::ASCII.GetBytes($capture)) "START" "END" + if ($null -ne $payload) { throw "Incomplete or reversed output markers were accepted" } + } + $emptyPayload = Get-HighOutputPayloadText ([Text.Encoding]::ASCII.GetBytes("STARTEND")) "START" "END" + if ($null -eq $emptyPayload -or $emptyPayload -cne "") { + throw "Empty completed output must reach the length/hash checks, not look pending" + } + } + if ($hardeningSource -notmatch + '(?s)if \(-not \$completed\).*?Get-HighOutputDiagnostics.*?HARDENING_OUTPUT_DIAGNOSTICS_JSON=.*?Assert-True \$completed') { + throw "RED: real high-output failure omits bounded transcript diagnostics" + } + if ($hardeningSource -notmatch + '(?s)if \(\$LASTEXITCODE -ne 0\) \{\s*\$output \| Write-Output\s*throw "hardening repeated run') { + throw "RED: failed repeated hardening discards its child diagnostics" + } + if ($hardeningSource -notmatch + '(?s)\$shellVersion\s*=\s*\(& \$shell --version.*?-Version \$shellVersion') { + throw "RED: post-release hardening does not preserve the built shell version" + } + $windowsShellWorkflow = Get-Content (Join-Path $repoRoot ".github\workflows\windows-shell.yml") -Raw + $windowsPortWorkflow = Get-Content ` + (Join-Path $repoRoot ".github\workflows\windows-port-validation.yml") -Raw $windowsCacheWarmerPath = Join-Path $repoRoot ".github\workflows\windows-cache-warmer.yml" if (-not (Test-Path -LiteralPath $windowsCacheWarmerPath -PathType Leaf)) { throw "RED: Windows CI has no main-scoped cache warmer" @@ -2933,17 +2954,17 @@ try { throw "RED: Windows cache warmer does not write the exact production key: $key" } } - if ($windowsCacheWarmerWorkflow -notmatch '(?m)^ push:\s*$' -or - $windowsCacheWarmerWorkflow -notmatch '(?m)^ branches: \[main\]\s*$' -or - $windowsCacheWarmerWorkflow -notmatch '(?m)^ schedule:\s*$' -or - $windowsCacheWarmerWorkflow -notmatch '(?m)^ - cron: "23 4 \* \* 1"\s*$' -or - $windowsCacheWarmerWorkflow -notmatch '(?m)^ workflow_dispatch:\s*$' -or - $windowsCacheWarmerWorkflow -notmatch '(?s)validate\.ps1 -Task provider-build.*?actions/cache/save@' -or - $windowsCacheWarmerWorkflow -match 'validate\.ps1 -Task terminal-gate') { - throw "RED: Windows cache warmer does not cover main, weekly, manual, and build-only canonical provider builds" - } - Test-ProviderCacheSeeding $windowsShellWorkflow $windowsCacheWarmerWorkflow - Test-ProviderCacheSeedingMutations $windowsShellWorkflow $windowsCacheWarmerWorkflow + if ($windowsCacheWarmerWorkflow -notmatch '(?m)^ push:\s*$' -or + $windowsCacheWarmerWorkflow -notmatch '(?m)^ branches: \[main\]\s*$' -or + $windowsCacheWarmerWorkflow -notmatch '(?m)^ schedule:\s*$' -or + $windowsCacheWarmerWorkflow -notmatch '(?m)^ - cron: "23 4 \* \* 1"\s*$' -or + $windowsCacheWarmerWorkflow -notmatch '(?m)^ workflow_dispatch:\s*$' -or + $windowsCacheWarmerWorkflow -notmatch '(?s)validate\.ps1 -Task provider-build.*?actions/cache/save@' -or + $windowsCacheWarmerWorkflow -match 'validate\.ps1 -Task terminal-gate') { + throw "RED: Windows cache warmer does not cover main, weekly, manual, and build-only canonical provider builds" + } + Test-ProviderCacheSeeding $windowsShellWorkflow $windowsCacheWarmerWorkflow + Test-ProviderCacheSeedingMutations $windowsShellWorkflow $windowsCacheWarmerWorkflow Test-ProviderBuildScript $repoRoot $pwsh foreach ($workflow in @($windowsShellWorkflow, $windowsPortWorkflow)) { if ($workflow -notmatch @@ -2951,80 +2972,80 @@ try { throw "RED: Windows CI does not retain failed UIA sandbox diagnostics as an artifact" } } - foreach ($workflow in @($windowsWorkflow, $windowsShellWorkflow, $windowsPortWorkflow)) { - if ($workflow -notmatch - "compnerd/gha-setup-swift@397094e75494a93fa8d81db0268dbc8f5d6cf7c6" -or - $workflow -notmatch "swift-version: swift-6\.3\.3-release" -or - $workflow -notmatch "swift-build: 6\.3\.3-RELEASE") { - throw "RED: pinned Windows CI does not install Swift 6.3.3 without WinGet" - } - } - if ($windowsShellWorkflow -notmatch "bootstrap\.ps1") { - throw "RED: Windows shell CI does not bootstrap exact dependencies" - } - if ($windowsShellWorkflow -notmatch "validate\.ps1 -Task windows-shell -SkipTrayLive" -or - $windowsPortWorkflow -notmatch "validate\.ps1 -Task all -SkipTrayLive -SkipWslRemoteE2E" -or - $windowsWorkflow -notmatch "validate\.ps1 -Task all -SkipTrayLive -SkipWslRemoteE2E" -or - $windowsWorkflow -notmatch "Hardening\.Tests\.ps1 -Environment -SkipTrayLive") { - throw "RED: hosted Windows CI does not explicitly declare unsupported interactive or WSL fixtures" - } - if ($windowsShellWorkflow -notmatch '(?m)^\s*run:\s*\./Tools/windows/validate\.ps1 -Task windows-shell\b') { - throw "RED: Windows shell CI does not invoke the shell task containing live UI Automation" - } - $runnerSource = Get-Content $runner -Raw + foreach ($workflow in @($windowsWorkflow, $windowsShellWorkflow, $windowsPortWorkflow)) { + if ($workflow -notmatch + "compnerd/gha-setup-swift@397094e75494a93fa8d81db0268dbc8f5d6cf7c6" -or + $workflow -notmatch "swift-version: swift-6\.3\.3-release" -or + $workflow -notmatch "swift-build: 6\.3\.3-RELEASE") { + throw "RED: pinned Windows CI does not install Swift 6.3.3 without WinGet" + } + } + if ($windowsShellWorkflow -notmatch "bootstrap\.ps1") { + throw "RED: Windows shell CI does not bootstrap exact dependencies" + } + if ($windowsShellWorkflow -notmatch "validate\.ps1 -Task windows-shell -SkipTrayLive" -or + $windowsPortWorkflow -notmatch "validate\.ps1 -Task all -SkipTrayLive -SkipWslRemoteE2E" -or + $windowsWorkflow -notmatch "validate\.ps1 -Task all -SkipTrayLive -SkipWslRemoteE2E" -or + $windowsWorkflow -notmatch "Hardening\.Tests\.ps1 -Environment -SkipTrayLive") { + throw "RED: hosted Windows CI does not explicitly declare unsupported interactive or WSL fixtures" + } + if ($windowsShellWorkflow -notmatch '(?m)^\s*run:\s*\./Tools/windows/validate\.ps1 -Task windows-shell\b') { + throw "RED: Windows shell CI does not invoke the shell task containing live UI Automation" + } + $runnerSource = Get-Content $runner -Raw if ($runnerSource -notmatch '(?s)WINDOWS_SHELL_PRE_UIA_PROCESS_SNAPSHOT=.*?Stop-Process -Id.*?WINDOWS_SHELL_PRE_UIA_CLEANUP=verified.*?Native UI Automation live gate') { throw "RED: Windows shell validation does not snapshot and reap run-owned product processes before UIA" } - Test-ZigResolverDiagnostics $runnerSource - Assert-ShellHostPrerequisite $runnerSource - $contractCall = [regex]::Match($runnerSource, - '(?s)& \(Join-Path \$repoRoot "Tools\\windows\\Tests\\WindowsShell\.Tests\.ps1"\)\s*`\s*-ZigExecutable \$zig0152').Value - if (-not $contractCall) { throw "Cannot construct the host prerequisite ordering control" } - $earlyContracts = $runnerSource.Replace($contractCall, "").Replace( - 'Invoke-Native "Pinned Winghostty host build for App contracts"', - $contractCall + "`n " + 'Invoke-Native "Pinned Winghostty host build for App contracts"') - foreach ($mutation in @( - $runnerSource.Replace('& $zig0152 build -Demit-win32-host=true', 'Write-Output "deliberately skipped build"'), - $runnerSource.Replace('"Pinned Winghostty host build for App contracts"', '"deliberately removed prerequisite"'), - $earlyContracts - )) { - $rejected = $false - try { Assert-ShellHostPrerequisite $mutation } catch { $rejected = $true } - if (-not $rejected) { throw "Host prerequisite contract accepted a deliberate missing-build control" } - } - if ($runnerSource -notmatch '(?s)"packaging" \{\s*if \(\$PackagingPart -ne "real"\) \{\s*& .*?Packaging\.Signing\.Tests\.ps1.*?Packaging\.Tests\.ps1') { - throw "RED: packaging validation does not run signed catalog integrity contracts" - } - if ($runnerSource -notmatch '(?s)"packaging" \{\s*if \(\$PackagingPart -ne "real"\) \{\s*& .*?Packaging\.Rollback\.Tests\.ps1.*?Packaging\.Tests\.ps1') { - throw "RED: packaging validation does not run rollback preservation contracts" - } - if ($runnerSource -notmatch '(?s)"packaging" \{\s*if \(\$PackagingPart -ne "real"\) \{\s*& .*?Packaging\.Standalone\.Tests\.ps1.*?Packaging\.Tests\.ps1') { - throw "RED: packaging validation does not run standalone setup contracts" - } - foreach ($contract in @("Packaging.ScriptSigning.Tests.ps1", "Packaging.Scheduler.Tests.ps1")) { - if ($runnerSource -notmatch ('(?s)"packaging" \{\s*if \(\$PackagingPart -ne "real"\) \{\s*& .*?' + [regex]::Escape($contract) + '.*?Packaging\.Tests\.ps1')) { - throw "RED: packaging validation does not run $contract" - } - } + Test-ZigResolverDiagnostics $runnerSource + Assert-ShellHostPrerequisite $runnerSource + $contractCall = [regex]::Match($runnerSource, + '(?s)& \(Join-Path \$repoRoot "Tools\\windows\\Tests\\WindowsShell\.Tests\.ps1"\)\s*`\s*-ZigExecutable \$zig0152').Value + if (-not $contractCall) { throw "Cannot construct the host prerequisite ordering control" } + $earlyContracts = $runnerSource.Replace($contractCall, "").Replace( + 'Invoke-Native "Pinned Winghostty host build for App contracts"', + $contractCall + "`n " + 'Invoke-Native "Pinned Winghostty host build for App contracts"') + foreach ($mutation in @( + $runnerSource.Replace('& $zig0152 build -Demit-win32-host=true', 'Write-Output "deliberately skipped build"'), + $runnerSource.Replace('"Pinned Winghostty host build for App contracts"', '"deliberately removed prerequisite"'), + $earlyContracts + )) { + $rejected = $false + try { Assert-ShellHostPrerequisite $mutation } catch { $rejected = $true } + if (-not $rejected) { throw "Host prerequisite contract accepted a deliberate missing-build control" } + } + if ($runnerSource -notmatch '(?s)"packaging" \{\s*if \(\$PackagingPart -ne "real"\) \{\s*& .*?Packaging\.Signing\.Tests\.ps1.*?Packaging\.Tests\.ps1') { + throw "RED: packaging validation does not run signed catalog integrity contracts" + } + if ($runnerSource -notmatch '(?s)"packaging" \{\s*if \(\$PackagingPart -ne "real"\) \{\s*& .*?Packaging\.Rollback\.Tests\.ps1.*?Packaging\.Tests\.ps1') { + throw "RED: packaging validation does not run rollback preservation contracts" + } + if ($runnerSource -notmatch '(?s)"packaging" \{\s*if \(\$PackagingPart -ne "real"\) \{\s*& .*?Packaging\.Standalone\.Tests\.ps1.*?Packaging\.Tests\.ps1') { + throw "RED: packaging validation does not run standalone setup contracts" + } + foreach ($contract in @("Packaging.ScriptSigning.Tests.ps1", "Packaging.Scheduler.Tests.ps1")) { + if ($runnerSource -notmatch ('(?s)"packaging" \{\s*if \(\$PackagingPart -ne "real"\) \{\s*& .*?' + [regex]::Escape($contract) + '.*?Packaging\.Tests\.ps1')) { + throw "RED: packaging validation does not run $contract" + } + } if ($runnerSource -notmatch '(?s)if \(\$PackagingPart -ne "contracts"\) \{\s*Initialize-PackagingInputs\s*& \(Join-Path \$repoRoot "Tools\\windows\\Tests\\Packaging\.Tests\.ps1"\)') { throw "RED: real packaging does not rebuild its own inputs before Packaging.Tests.ps1" } Test-CiPartitionCoverage $runner $pwsh $repoRoot Test-ShellSectionGate $repoRoot $pwsh Test-CiAggregateGates $repoRoot $pwsh - if ($runnerSource -notmatch '(?s)"terminal-gate" \{\s*& .*?ProviderPins\.Tests\.ps1.*?TerminalGate\.Tests\.ps1') { - throw "RED: terminal validation does not run provider pin no-divergence contracts" - } - foreach ($source in @($runnerSource, $hardeningSource)) { - if ($source -notmatch '-StubResponseDelayMilliseconds 150') { - throw "RED: shell validation does not exercise delayed correlated responses" - } - } - if ($runnerSource -notmatch '(?s)Pinned GraphCode Windows shell build and smoke.*?Native UI Automation live gate.*?uia-live-gate\.ps1') { - throw "RED: Windows shell validation does not execute the UI Automation live gate" - } - $uiaLiveGateSource = Get-Content (Join-Path $repoRoot "Tools\windows\uia-live-gate.ps1") -Raw + if ($runnerSource -notmatch '(?s)"terminal-gate" \{\s*& .*?ProviderPins\.Tests\.ps1.*?TerminalGate\.Tests\.ps1') { + throw "RED: terminal validation does not run provider pin no-divergence contracts" + } + foreach ($source in @($runnerSource, $hardeningSource)) { + if ($source -notmatch '-StubResponseDelayMilliseconds 150') { + throw "RED: shell validation does not exercise delayed correlated responses" + } + } + if ($runnerSource -notmatch '(?s)Pinned GraphCode Windows shell build and smoke.*?Native UI Automation live gate.*?uia-live-gate\.ps1') { + throw "RED: Windows shell validation does not execute the UI Automation live gate" + } + $uiaLiveGateSource = Get-Content (Join-Path $repoRoot "Tools\windows\uia-live-gate.ps1") -Raw if ($uiaLiveGateSource -notmatch 'function Get-UiaStartupFileDiagnostic' -or $uiaLiveGateSource -notmatch 'RedirectStandardOutput' -or $uiaLiveGateSource -notmatch 'function Get-UiaPrelaunchDiagnostics' -or @@ -3332,83 +3353,83 @@ Start-Sleep -Seconds 60 $uiaLiveGateSource -notmatch 'AttachThreadInput\(currentThread, targetThread, true\).*?Marshal.GetLastWin32Error\(\)') { throw "RED: UIA foreground failure hides native return values and last-error diagnostics" } - if ($uiaLiveGateSource -notmatch 'AttachThreadInput' -or - $uiaLiveGateSource -notmatch 'keybd_event\(0x12, 0, 0, UIntPtr\.Zero\)' -or - $uiaLiveGateSource -notmatch 'SetActiveWindow\(window\)' -or - $uiaLiveGateSource -notmatch 'PostMessage\(window, 0x0101, \(UIntPtr\)key, IntPtr\.Zero\)' -or - $uiaLiveGateSource -notmatch '\[DllImport\("kernel32\.dll"\)\]\s*private static extern uint GetCurrentThreadId' -or - $uiaLiveGateSource -notmatch 'IsForegroundWindow\(\$window\)' -or - $uiaLiveGateSource -notmatch 'UIA_FOCUS_DIAGNOSTICS' -or - $uiaLiveGateSource -notmatch '(?s)function Hide-TestProviderZmxWindows.*?\[string\]\$_\.ExecutablePath\)\s+-eq\s+\$providerZmx.*?HideProcessWindows.*?HideWindow\(\$foreground\)' -or - $uiaLiveGateSource -notmatch 'function Retain-FocusWithRetry' -or - $uiaLiveGateSource -notmatch 'Test-FocusedElementIdentity \$candidate \$element \$expectedAutomationId' -or - $uiaLiveGateSource -notmatch '\[GraphCodeUiaGateState\]::ActivateWindow\(\$window\)' -or - $uiaLiveGateSource -notmatch '\$element\.SetFocus\(\)' -or - $uiaLiveGateSource -notmatch 'Retain-FocusWithRetry \$shellWindow \$safeFocusRow \$safeRowId "before-retention"' -or - $uiaLiveGateSource -notmatch '\$backendFocus = Retain-FocusWithRetry' -or - $uiaLiveGateSource -notmatch 'Product Settings backend control could not retain foreground focus' -or - $uiaLiveGateSource -notmatch '\$cancelFocus = Retain-FocusWithRetry' -or - $uiaLiveGateSource -notmatch 'Product Settings model control could not retain foreground focus') { - throw "RED: UIA live gate does not prove foreground ownership before accepting row focus" - } - if ($uiaLiveGateSource -notmatch 'function Wait-ForDesktopElement' -or - $uiaLiveGateSource -notmatch 'function Wait-ForDesktopElementGone' -or - $uiaLiveGateSource -notmatch 'UIA_WAIT_DIAGNOSTICS' -or - $uiaLiveGateSource -notmatch 'empty global New Loop node form' -or - $uiaLiveGateSource -notmatch 'empty project New Loop node form' -or - $uiaLiveGateSource -notmatch 'project-row New Loop node form' -or - $uiaLiveGateSource -notmatch 'Open Folder picker close') { - throw "RED: UIA live gate does not wait deterministically for asynchronous modal windows" - } - if ($uiaLiveGateSource -match '(?s)New Loop command was rejected.*?for \(\$index = 0; \$index -lt 40 -and \$null -eq \$.*NodeForm' -or - $uiaLiveGateSource -match '(?s)Open Folder command was rejected.*?Start-Sleep -Milliseconds 200\s*[\r\n]+\s*Require \(\[GraphCodeUiaGateState\]::PostCommand\(\$shellWindow, 4602\)\)') { - throw "RED: UIA live gate reintroduced short fixed polling around New Loop modal commands" - } - if ($uiaLiveGateSource -notmatch 'function Ensure-ShellForeground' -or - $uiaLiveGateSource -notmatch '\[GraphCodeUiaGateState\]::ActivateWindow\(\$window\)\s*[\r\n]+\s*\$acquired = \$activated -and \[GraphCodeUiaGateState\]::IsForegroundWindow\(\$window\)' -or - $uiaLiveGateSource -notmatch 'UIA_FOREGROUND_DIAGNOSTICS phase=\$label \$\(Get-FocusDiagnostics \$window\)' -or - $uiaLiveGateSource -notmatch '(?s)function Ensure-ShellForeground\(.*?if \(\[GraphCodeUiaGateState\]::IsForegroundWindow\(\$window\)\) \{\s*[\r\n]+\s*return \$true\s*[\r\n]+\s*\}') { - throw "RED: UIA live gate does not reacquire GraphCode shell foreground within a bounded deadline before command paths, or performs the invasive Alt-tap activation even when already foreground" - } - if ($uiaLiveGateSource -notmatch '(?s)function Wait-ForDesktopElement\(.*?\[switch\] \$RecoverForeground.*?\$remainingMilliseconds = \[Math\]::Max\(.*?\$recoveryTimeout = \[Math\]::Min\(1000, \$remainingMilliseconds\).*?UIA_WAIT_FOREGROUND_RECOVERY label=\$label.*?Ensure-ShellForeground.*?-TimeoutMilliseconds \$recoveryTimeout' -or - $uiaLiveGateSource -notmatch 'UIA_WAIT_DIAGNOSTICS label=\$label foregroundRecoveries=\$foregroundRecoveries' -or - $uiaLiveGateSource -notmatch '(?s)-label "project-row New Loop node form".*?-RecoverForeground' -or - $uiaLiveGateSource -notmatch '(?s)-label "empty global New Loop node form".*?-RecoverForeground' -or - $uiaLiveGateSource -notmatch '(?s)-label "empty project New Loop node form".*?-RecoverForeground') { - throw "RED: UIA modal waits do not boundedly reacquire foreground after a post-command foreground loss" - } - if ($uiaLiveGateSource -notmatch '(?s)Require \(\$null -ne \$projectNewLoop\) "project row omitted New Loop"\s*[\r\n]+\s*Require \(Ensure-ShellForeground \$shellWindow "project-row New Loop"\)\s*`\s*[\r\n]+\s*"GraphCode shell did not reacquire foreground before invoking project-row New Loop"\s*[\r\n]+\s*\$projectNewLoop\.GetCurrentPattern' -or - $uiaLiveGateSource -notmatch '(?s)Require \(Ensure-ShellForeground \$shellWindow "empty global New Loop"\)\s*`\s*[\r\n]+\s*"GraphCode shell did not reacquire foreground before empty global New Loop command"\s*[\r\n]+\s*Require \(\[GraphCodeUiaGateState\]::PostCommand\(\$shellWindow, 4602\)\)\s*`\s*[\r\n]+\s*"empty global New Loop command was rejected"' -or - $uiaLiveGateSource -notmatch '(?s)Require \(Ensure-ShellForeground \$shellWindow "empty project New Loop"\)\s*`\s*[\r\n]+\s*"GraphCode shell did not reacquire foreground before empty project New Loop command"\s*[\r\n]+\s*Require \(\[GraphCodeUiaGateState\]::PostCommand\(\$shellWindow, 4602\)\)\s*`\s*[\r\n]+\s*"empty project New Loop command was rejected"') { - throw "RED: UIA live gate New Loop invocation is not preceded by verified foreground recovery at every site" - } - $shellTests = Get-Content (Join-Path $PSScriptRoot "WindowsShell.Tests.ps1") -Raw - if ($uiaLiveGateSource -notmatch 'function Wait-ForPopupMenu' -or - $uiaLiveGateSource -notmatch 'function Get-PopupMenuItems' -or - $uiaLiveGateSource -notmatch 'function Close-PopupMenu' -or - $uiaLiveGateSource -notmatch 'FindPopupMenuWindow' -or - $uiaLiveGateSource -notmatch 'SendMessage\(popup, 0x01E1, UIntPtr\.Zero, IntPtr\.Zero\)' -or - $uiaLiveGateSource -notmatch 'PostMessage\(window, 0x802C, \(UIntPtr\)target, IntPtr\.Zero\)') { - throw "RED: UIA live gate cannot open, read, or dismiss a native TrackPopupMenu popup" - } - if ($uiaLiveGateSource -notmatch '\$moveProjectMenuText = "Move Project\.\.\. \(unavailable: daemon support required\)"' -or - $uiaLiveGateSource -notmatch '(?s)PostContextMenu\(\$shellWindow, 1\).*?Wait-ForPopupMenu \$process \$shellWindow "project"' -or - $uiaLiveGateSource -notmatch 'Require \(-not \$moveProjectItem\.Enabled\)' -or - $uiaLiveGateSource -notmatch '\$moveProjectItem\.Text -eq \$moveProjectMenuText' -or - $uiaLiveGateSource -notmatch '(?s)PostContextMenu\(\$shellWindow, 2\).*?\$_\.Id -in @\(5149, 5151, 5144\)' -or - $uiaLiveGateSource -notmatch 'project context menu did not dismiss, leaving the shell blocked in its modal loop') { - throw "RED: UIA live gate does not assert the live project context menu's disabled Move item and deterministic dismissal" - } - if ($shellTests -notmatch '(?s)Context menu and gate fixture message executable tests.*?zig test src\\GraphContextMenu\.zig' -or - $shellTests -notmatch '(?s)Context menu and gate fixture message executable tests.*?zig test src\\MainWindow\.zig') { - throw "RED: Windows shell validation does not run the context menu and gate fixture message tests" - } - $appSource = Get-Content (Join-Path $repoRoot "graphcode-windows\src\App.zig") -Raw - if ($appSource -notmatch 'fn showUiaContextMenu' -or - $appSource -notmatch 'MainWindow\.wm_uia_context_menu => \{' -or - $appSource -notmatch '(?s)wparam == MainWindow\.menu_watchdog_timer_id.*?c\.EndMenu\(\)') { - throw "RED: the shell cannot open a gate-requested context menu, or an abandoned popup can block its message loop forever" - } + if ($uiaLiveGateSource -notmatch 'AttachThreadInput' -or + $uiaLiveGateSource -notmatch 'keybd_event\(0x12, 0, 0, UIntPtr\.Zero\)' -or + $uiaLiveGateSource -notmatch 'SetActiveWindow\(window\)' -or + $uiaLiveGateSource -notmatch 'PostMessage\(window, 0x0101, \(UIntPtr\)key, IntPtr\.Zero\)' -or + $uiaLiveGateSource -notmatch '\[DllImport\("kernel32\.dll"\)\]\s*private static extern uint GetCurrentThreadId' -or + $uiaLiveGateSource -notmatch 'IsForegroundWindow\(\$window\)' -or + $uiaLiveGateSource -notmatch 'UIA_FOCUS_DIAGNOSTICS' -or + $uiaLiveGateSource -notmatch '(?s)function Hide-TestProviderZmxWindows.*?\[string\]\$_\.ExecutablePath\)\s+-eq\s+\$providerZmx.*?HideProcessWindows.*?HideWindow\(\$foreground\)' -or + $uiaLiveGateSource -notmatch 'function Retain-FocusWithRetry' -or + $uiaLiveGateSource -notmatch 'Test-FocusedElementIdentity \$candidate \$element \$expectedAutomationId' -or + $uiaLiveGateSource -notmatch '\[GraphCodeUiaGateState\]::ActivateWindow\(\$window\)' -or + $uiaLiveGateSource -notmatch '\$element\.SetFocus\(\)' -or + $uiaLiveGateSource -notmatch 'Retain-FocusWithRetry \$shellWindow \$safeFocusRow \$safeRowId "before-retention"' -or + $uiaLiveGateSource -notmatch '\$backendFocus = Retain-FocusWithRetry' -or + $uiaLiveGateSource -notmatch 'Product Settings backend control could not retain foreground focus' -or + $uiaLiveGateSource -notmatch '\$cancelFocus = Retain-FocusWithRetry' -or + $uiaLiveGateSource -notmatch 'Product Settings model control could not retain foreground focus') { + throw "RED: UIA live gate does not prove foreground ownership before accepting row focus" + } + if ($uiaLiveGateSource -notmatch 'function Wait-ForDesktopElement' -or + $uiaLiveGateSource -notmatch 'function Wait-ForDesktopElementGone' -or + $uiaLiveGateSource -notmatch 'UIA_WAIT_DIAGNOSTICS' -or + $uiaLiveGateSource -notmatch 'empty global New Loop node form' -or + $uiaLiveGateSource -notmatch 'empty project New Loop node form' -or + $uiaLiveGateSource -notmatch 'project-row New Loop node form' -or + $uiaLiveGateSource -notmatch 'Open Folder picker close') { + throw "RED: UIA live gate does not wait deterministically for asynchronous modal windows" + } + if ($uiaLiveGateSource -match '(?s)New Loop command was rejected.*?for \(\$index = 0; \$index -lt 40 -and \$null -eq \$.*NodeForm' -or + $uiaLiveGateSource -match '(?s)Open Folder command was rejected.*?Start-Sleep -Milliseconds 200\s*[\r\n]+\s*Require \(\[GraphCodeUiaGateState\]::PostCommand\(\$shellWindow, 4602\)\)') { + throw "RED: UIA live gate reintroduced short fixed polling around New Loop modal commands" + } + if ($uiaLiveGateSource -notmatch 'function Ensure-ShellForeground' -or + $uiaLiveGateSource -notmatch '\[GraphCodeUiaGateState\]::ActivateWindow\(\$window\)\s*[\r\n]+\s*\$acquired = \$activated -and \[GraphCodeUiaGateState\]::IsForegroundWindow\(\$window\)' -or + $uiaLiveGateSource -notmatch 'UIA_FOREGROUND_DIAGNOSTICS phase=\$label \$\(Get-FocusDiagnostics \$window\)' -or + $uiaLiveGateSource -notmatch '(?s)function Ensure-ShellForeground\(.*?if \(\[GraphCodeUiaGateState\]::IsForegroundWindow\(\$window\)\) \{\s*[\r\n]+\s*return \$true\s*[\r\n]+\s*\}') { + throw "RED: UIA live gate does not reacquire GraphCode shell foreground within a bounded deadline before command paths, or performs the invasive Alt-tap activation even when already foreground" + } + if ($uiaLiveGateSource -notmatch '(?s)function Wait-ForDesktopElement\(.*?\[switch\] \$RecoverForeground.*?\$remainingMilliseconds = \[Math\]::Max\(.*?\$recoveryTimeout = \[Math\]::Min\(1000, \$remainingMilliseconds\).*?UIA_WAIT_FOREGROUND_RECOVERY label=\$label.*?Ensure-ShellForeground.*?-TimeoutMilliseconds \$recoveryTimeout' -or + $uiaLiveGateSource -notmatch 'UIA_WAIT_DIAGNOSTICS label=\$label foregroundRecoveries=\$foregroundRecoveries' -or + $uiaLiveGateSource -notmatch '(?s)-label "project-row New Loop node form".*?-RecoverForeground' -or + $uiaLiveGateSource -notmatch '(?s)-label "empty global New Loop node form".*?-RecoverForeground' -or + $uiaLiveGateSource -notmatch '(?s)-label "empty project New Loop node form".*?-RecoverForeground') { + throw "RED: UIA modal waits do not boundedly reacquire foreground after a post-command foreground loss" + } + if ($uiaLiveGateSource -notmatch '(?s)Require \(\$null -ne \$projectNewLoop\) "project row omitted New Loop"\s*[\r\n]+\s*Require \(Ensure-ShellForeground \$shellWindow "project-row New Loop"\)\s*`\s*[\r\n]+\s*"GraphCode shell did not reacquire foreground before invoking project-row New Loop"\s*[\r\n]+\s*\$projectNewLoop\.GetCurrentPattern' -or + $uiaLiveGateSource -notmatch '(?s)Require \(Ensure-ShellForeground \$shellWindow "empty global New Loop"\)\s*`\s*[\r\n]+\s*"GraphCode shell did not reacquire foreground before empty global New Loop command"\s*[\r\n]+\s*Require \(\[GraphCodeUiaGateState\]::PostCommand\(\$shellWindow, 4602\)\)\s*`\s*[\r\n]+\s*"empty global New Loop command was rejected"' -or + $uiaLiveGateSource -notmatch '(?s)Require \(Ensure-ShellForeground \$shellWindow "empty project New Loop"\)\s*`\s*[\r\n]+\s*"GraphCode shell did not reacquire foreground before empty project New Loop command"\s*[\r\n]+\s*Require \(\[GraphCodeUiaGateState\]::PostCommand\(\$shellWindow, 4602\)\)\s*`\s*[\r\n]+\s*"empty project New Loop command was rejected"') { + throw "RED: UIA live gate New Loop invocation is not preceded by verified foreground recovery at every site" + } + $shellTests = Get-Content (Join-Path $PSScriptRoot "WindowsShell.Tests.ps1") -Raw + if ($uiaLiveGateSource -notmatch 'function Wait-ForPopupMenu' -or + $uiaLiveGateSource -notmatch 'function Get-PopupMenuItems' -or + $uiaLiveGateSource -notmatch 'function Close-PopupMenu' -or + $uiaLiveGateSource -notmatch 'FindPopupMenuWindow' -or + $uiaLiveGateSource -notmatch 'SendMessage\(popup, 0x01E1, UIntPtr\.Zero, IntPtr\.Zero\)' -or + $uiaLiveGateSource -notmatch 'PostMessage\(window, 0x802C, \(UIntPtr\)target, IntPtr\.Zero\)') { + throw "RED: UIA live gate cannot open, read, or dismiss a native TrackPopupMenu popup" + } + if ($uiaLiveGateSource -notmatch '\$moveProjectMenuText = "Move Project\.\.\. \(unavailable: daemon support required\)"' -or + $uiaLiveGateSource -notmatch '(?s)PostContextMenu\(\$shellWindow, 1\).*?Wait-ForPopupMenu \$process \$shellWindow "project"' -or + $uiaLiveGateSource -notmatch 'Require \(-not \$moveProjectItem\.Enabled\)' -or + $uiaLiveGateSource -notmatch '\$moveProjectItem\.Text -eq \$moveProjectMenuText' -or + $uiaLiveGateSource -notmatch '(?s)PostContextMenu\(\$shellWindow, 2\).*?\$_\.Id -in @\(5149, 5151, 5144\)' -or + $uiaLiveGateSource -notmatch 'project context menu did not dismiss, leaving the shell blocked in its modal loop') { + throw "RED: UIA live gate does not assert the live project context menu's disabled Move item and deterministic dismissal" + } + if ($shellTests -notmatch '(?s)Context menu and gate fixture message executable tests.*?zig test src\\GraphContextMenu\.zig' -or + $shellTests -notmatch '(?s)Context menu and gate fixture message executable tests.*?zig test src\\MainWindow\.zig') { + throw "RED: Windows shell validation does not run the context menu and gate fixture message tests" + } + $appSource = Get-Content (Join-Path $repoRoot "graphcode-windows\src\App.zig") -Raw + if ($appSource -notmatch 'fn showUiaContextMenu' -or + $appSource -notmatch 'MainWindow\.wm_uia_context_menu => \{' -or + $appSource -notmatch '(?s)wparam == MainWindow\.menu_watchdog_timer_id.*?c\.EndMenu\(\)') { + throw "RED: the shell cannot open a gate-requested context menu, or an abandoned popup can block its message loop forever" + } if ($uiaLiveGateSource -notmatch 'UIA_CANVAS_CONTEXT_MENU_EVIDENCE' -or $uiaLiveGateSource -notmatch '\$canvasContextMenuEvidence' -or $uiaLiveGateSource -notmatch 'canvasContextMenu = \$canvasContextMenuEvidence' -or @@ -3910,74 +3931,74 @@ Start-Sleep -Seconds 60 ($twoRemainders[0] -join ',') -ne '715,721,763,728') { throw "RED: node sheet rectangle subtraction does not handle more than one covering control" } - if ($shellTests -notmatch '(?s)Windows update feed executable tests.*?zig test src\\WindowsUpdates\.zig.*?-lwinhttp') { - throw "RED: Windows shell validation does not run the native updater tests" - } - if ($runnerSource -notmatch '\$SkipWslRemoteE2E' -or - $runnerSource -notmatch '"--skip-local-wsl"') { - throw "RED: hosted validation cannot explicitly isolate unavailable local WSL fixtures" - } - $privacyRaceSource = Get-Content ` - (Join-Path $repoRoot "Tools\windows\Tests\RemoteBridgePrivacyRace.Tests.ps1") -Raw - if ($privacyRaceSource -notmatch '\$AvailableProcessorCount = \[Environment\]::ProcessorCount' -or - $privacyRaceSource -notmatch '\$remoteProcessCount = 1' -or - $privacyRaceSource -notmatch '\[Math\]::Min\(24, \[Math\]::Max\(4, \$processorCount \* 2\)\)' -or - $privacyRaceSource -notmatch 'GRAPHCODE_REMOTE_BRIDGE_TEST_TIMEOUT_MULTIPLIER = "3"') { - throw "RED: remote bridge privacy race does not scale bounded concurrency to runner capacity" - } - if ($windowsWorkflow -notmatch "(?s)environment:.*Hardening\.Tests\.ps1 -Environment -SchemaOnly") { - throw "RED: environment CI does not invoke the exact schema-only hardening contract" - } - $macWorkflow = Get-Content (Join-Path $repoRoot ".github\workflows\macos-shared-regression.yml") -Raw - if ($macWorkflow -notmatch "brew install mise" -or - $macWorkflow -notmatch "mise install" -or - $macWorkflow -notmatch "mise exec -- make test") { - throw "RED: macOS CI does not install and execute pinned mise.toml tools" - } - $requiredWorkflows = [ordered]@{ - "macos-shared-regression.yml" = $macWorkflow - "windows-shell.yml" = $windowsShellWorkflow - "windows-port-validation.yml" = $windowsPortWorkflow - "windows-hardening.yml" = $windowsWorkflow - } - foreach ($entry in $requiredWorkflows.GetEnumerator()) { - $trigger = [regex]::Match( - $entry.Value, - '(?ms)^ pull_request:(?.*?)(?=^[^\s#]|^ [A-Za-z_][A-Za-z0-9_-]*:|\z)') - $settings = [regex]::Replace($trigger.Groups["settings"].Value, '(?m)#.*$', '').Trim() - if (-not $trigger.Success -or $settings -notin @("", "{}")) { - throw "RED: $($entry.Key) must report required checks for every PR, including documentation-only changes" - } - } -} finally { - Remove-Item -LiteralPath $foreignJunction -Recurse -Force -ErrorAction SilentlyContinue -} - -$oldWinghosttyRoot = [Environment]::GetEnvironmentVariable( - "GRAPHCODE_WINGHOSTTY_ROOT" -) -$oldZmxRoot = [Environment]::GetEnvironmentVariable("GRAPHCODE_ZMX_ROOT") -try { - $env:GRAPHCODE_WINGHOSTTY_ROOT = Join-Path $repoRoot ` - "investigation\spikes\missing-winghostty-provider" - $env:GRAPHCODE_ZMX_ROOT = Join-Path $repoRoot ` - "investigation\spikes\missing-zmx-provider" - & $pwsh -NoProfile -File $runner -Task terminal-gate *> $null - if ($LASTEXITCODE -eq 0) { - throw "terminal-gate passed without its pinned providers" - } -} finally { - if ($null -eq $oldWinghosttyRoot) { - Remove-Item Env:GRAPHCODE_WINGHOSTTY_ROOT -ErrorAction SilentlyContinue - } else { - $env:GRAPHCODE_WINGHOSTTY_ROOT = $oldWinghosttyRoot - } - if ($null -eq $oldZmxRoot) { - Remove-Item Env:GRAPHCODE_ZMX_ROOT -ErrorAction SilentlyContinue - } else { - $env:GRAPHCODE_ZMX_ROOT = $oldZmxRoot - } -} - -Write-Host "ValidationRunner.Tests.ps1: PASS" -exit 0 + if ($shellTests -notmatch '(?s)Windows update feed executable tests.*?zig test src\\WindowsUpdates\.zig.*?-lwinhttp') { + throw "RED: Windows shell validation does not run the native updater tests" + } + if ($runnerSource -notmatch '\$SkipWslRemoteE2E' -or + $runnerSource -notmatch '"--skip-local-wsl"') { + throw "RED: hosted validation cannot explicitly isolate unavailable local WSL fixtures" + } + $privacyRaceSource = Get-Content ` + (Join-Path $repoRoot "Tools\windows\Tests\RemoteBridgePrivacyRace.Tests.ps1") -Raw + if ($privacyRaceSource -notmatch '\$AvailableProcessorCount = \[Environment\]::ProcessorCount' -or + $privacyRaceSource -notmatch '\$remoteProcessCount = 1' -or + $privacyRaceSource -notmatch '\[Math\]::Min\(24, \[Math\]::Max\(4, \$processorCount \* 2\)\)' -or + $privacyRaceSource -notmatch 'GRAPHCODE_REMOTE_BRIDGE_TEST_TIMEOUT_MULTIPLIER = "3"') { + throw "RED: remote bridge privacy race does not scale bounded concurrency to runner capacity" + } + if ($windowsWorkflow -notmatch "(?s)environment:.*Hardening\.Tests\.ps1 -Environment -SchemaOnly") { + throw "RED: environment CI does not invoke the exact schema-only hardening contract" + } + $macWorkflow = Get-Content (Join-Path $repoRoot ".github\workflows\macos-shared-regression.yml") -Raw + if ($macWorkflow -notmatch "brew install mise" -or + $macWorkflow -notmatch "mise install" -or + $macWorkflow -notmatch "mise exec -- make test") { + throw "RED: macOS CI does not install and execute pinned mise.toml tools" + } + $requiredWorkflows = [ordered]@{ + "macos-shared-regression.yml" = $macWorkflow + "windows-shell.yml" = $windowsShellWorkflow + "windows-port-validation.yml" = $windowsPortWorkflow + "windows-hardening.yml" = $windowsWorkflow + } + foreach ($entry in $requiredWorkflows.GetEnumerator()) { + $trigger = [regex]::Match( + $entry.Value, + '(?ms)^ pull_request:(?.*?)(?=^[^\s#]|^ [A-Za-z_][A-Za-z0-9_-]*:|\z)') + $settings = [regex]::Replace($trigger.Groups["settings"].Value, '(?m)#.*$', '').Trim() + if (-not $trigger.Success -or $settings -notin @("", "{}")) { + throw "RED: $($entry.Key) must report required checks for every PR, including documentation-only changes" + } + } +} finally { + Remove-Item -LiteralPath $foreignJunction -Recurse -Force -ErrorAction SilentlyContinue +} + +$oldWinghosttyRoot = [Environment]::GetEnvironmentVariable( + "GRAPHCODE_WINGHOSTTY_ROOT" +) +$oldZmxRoot = [Environment]::GetEnvironmentVariable("GRAPHCODE_ZMX_ROOT") +try { + $env:GRAPHCODE_WINGHOSTTY_ROOT = Join-Path $repoRoot ` + "investigation\spikes\missing-winghostty-provider" + $env:GRAPHCODE_ZMX_ROOT = Join-Path $repoRoot ` + "investigation\spikes\missing-zmx-provider" + & $pwsh -NoProfile -File $runner -Task terminal-gate *> $null + if ($LASTEXITCODE -eq 0) { + throw "terminal-gate passed without its pinned providers" + } +} finally { + if ($null -eq $oldWinghosttyRoot) { + Remove-Item Env:GRAPHCODE_WINGHOSTTY_ROOT -ErrorAction SilentlyContinue + } else { + $env:GRAPHCODE_WINGHOSTTY_ROOT = $oldWinghosttyRoot + } + if ($null -eq $oldZmxRoot) { + Remove-Item Env:GRAPHCODE_ZMX_ROOT -ErrorAction SilentlyContinue + } else { + $env:GRAPHCODE_ZMX_ROOT = $oldZmxRoot + } +} + +Write-Host "ValidationRunner.Tests.ps1: PASS" +exit 0 From 8413ab3be7396ae5392143510fcb0f218cca0888 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Tue, 6 Oct 2026 16:06:40 -0700 Subject: [PATCH 07/11] Preserve existing line endings around protocol regression tests Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../windows/Tests/ValidationRunner.Tests.ps1 | 1814 ++++++++--------- 1 file changed, 907 insertions(+), 907 deletions(-) diff --git a/Tools/windows/Tests/ValidationRunner.Tests.ps1 b/Tools/windows/Tests/ValidationRunner.Tests.ps1 index a4bf2f81..b59c55f7 100644 --- a/Tools/windows/Tests/ValidationRunner.Tests.ps1 +++ b/Tools/windows/Tests/ValidationRunner.Tests.ps1 @@ -1,4 +1,4 @@ -$ErrorActionPreference = "Stop" +$ErrorActionPreference = "Stop" function Test-MultiProjectProtocolContracts([string] $stubSource, [string] $gateSource, [string] $stubPath, [string] $pwsh) { foreach ($source in @($stubSource, $gateSource)) { @@ -2023,55 +2023,55 @@ function Assert-MultiProjectFreshCaptureContract([string] $source) { throw "RED: observer bypasses fresh/cached ownership or whole-observation/container verification" } } - -function Assert-ShellHostPrerequisite([string] $source) { - $tokens = $null - $errors = $null - $ast = [Management.Automation.Language.Parser]::ParseInput($source, [ref]$tokens, [ref]$errors) - if ($errors.Count -ne 0) { throw "Validation driver does not parse" } - $clauses = @($ast.FindAll({ - param($node) - $node -is [Management.Automation.Language.SwitchStatementAst] - }, $true).Clauses | Where-Object { $_.Item1.Value -eq "windows-shell" }) - if ($clauses.Count -ne 1) { throw "Expected one windows-shell validation branch" } - $body = $clauses[0].Item2 - $commands = @($body.FindAll({ - param($node) - $node -is [Management.Automation.Language.CommandAst] - }, $true)) - $build = @($commands | Where-Object { - $_.GetCommandName() -eq "Invoke-Native" -and - $_.CommandElements[1].Extent.Text -eq '"Pinned Winghostty host build for App contracts"' - }) - $contracts = @($commands | Where-Object { - $_.InvocationOperator -eq [Management.Automation.Language.TokenKind]::Ampersand -and - $_.CommandElements[0].Extent.Text -match 'Tools\\windows\\Tests\\WindowsShell\.Tests\.ps1' - }) - $smoke = @($commands | Where-Object { - $_.GetCommandName() -eq "Invoke-Native" -and - $_.CommandElements[1].Extent.Text -eq '"Pinned GraphCode Windows shell build and smoke"' - }) - $guards = @($body.FindAll({ - param($node) - $node -is [Management.Automation.Language.IfStatementAst] - }, $true)) - $pin = @($guards | Where-Object { $_.Extent.Text -match '\$actualWinghosttyPin -ne \$pins\.winghostty\.sha' }) - $clean = @($guards | Where-Object { $_.Extent.Text -match '\$providerStatus\.Count -ne 0' }) - $library = @($guards | Where-Object { $_.Extent.Text -match 'Test-Path -LiteralPath \$winghosttyLib -PathType Leaf' }) - foreach ($stage in @(@{ Values = $build }, @{ Values = $contracts }, @{ Values = $smoke }, - @{ Values = $pin }, @{ Values = $clean }, @{ Values = $library })) { - if ($stage.Values.Count -ne 1) { throw "Missing or repeated App host prerequisite stage" } - } - if ($clean[0].Extent.EndOffset -ge $build[0].Extent.StartOffset -or - $pin[0].Extent.EndOffset -ge $build[0].Extent.StartOffset -or - $build[0].Extent.EndOffset -ge $library[0].Extent.StartOffset -or - $library[0].Extent.EndOffset -ge $contracts[0].Extent.StartOffset -or - $contracts[0].Extent.EndOffset -ge $smoke[0].Extent.StartOffset -or - $build[0].Extent.Text -notmatch '(?s)Push-Location \$winghosttyRoot.*& \$zig0152 build -Demit-win32-host=true.*finally \{ Pop-Location \}') { - throw "Pinned host validation/build must precede App contracts, which must precede live smoke" - } -} - + +function Assert-ShellHostPrerequisite([string] $source) { + $tokens = $null + $errors = $null + $ast = [Management.Automation.Language.Parser]::ParseInput($source, [ref]$tokens, [ref]$errors) + if ($errors.Count -ne 0) { throw "Validation driver does not parse" } + $clauses = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.SwitchStatementAst] + }, $true).Clauses | Where-Object { $_.Item1.Value -eq "windows-shell" }) + if ($clauses.Count -ne 1) { throw "Expected one windows-shell validation branch" } + $body = $clauses[0].Item2 + $commands = @($body.FindAll({ + param($node) + $node -is [Management.Automation.Language.CommandAst] + }, $true)) + $build = @($commands | Where-Object { + $_.GetCommandName() -eq "Invoke-Native" -and + $_.CommandElements[1].Extent.Text -eq '"Pinned Winghostty host build for App contracts"' + }) + $contracts = @($commands | Where-Object { + $_.InvocationOperator -eq [Management.Automation.Language.TokenKind]::Ampersand -and + $_.CommandElements[0].Extent.Text -match 'Tools\\windows\\Tests\\WindowsShell\.Tests\.ps1' + }) + $smoke = @($commands | Where-Object { + $_.GetCommandName() -eq "Invoke-Native" -and + $_.CommandElements[1].Extent.Text -eq '"Pinned GraphCode Windows shell build and smoke"' + }) + $guards = @($body.FindAll({ + param($node) + $node -is [Management.Automation.Language.IfStatementAst] + }, $true)) + $pin = @($guards | Where-Object { $_.Extent.Text -match '\$actualWinghosttyPin -ne \$pins\.winghostty\.sha' }) + $clean = @($guards | Where-Object { $_.Extent.Text -match '\$providerStatus\.Count -ne 0' }) + $library = @($guards | Where-Object { $_.Extent.Text -match 'Test-Path -LiteralPath \$winghosttyLib -PathType Leaf' }) + foreach ($stage in @(@{ Values = $build }, @{ Values = $contracts }, @{ Values = $smoke }, + @{ Values = $pin }, @{ Values = $clean }, @{ Values = $library })) { + if ($stage.Values.Count -ne 1) { throw "Missing or repeated App host prerequisite stage" } + } + if ($clean[0].Extent.EndOffset -ge $build[0].Extent.StartOffset -or + $pin[0].Extent.EndOffset -ge $build[0].Extent.StartOffset -or + $build[0].Extent.EndOffset -ge $library[0].Extent.StartOffset -or + $library[0].Extent.EndOffset -ge $contracts[0].Extent.StartOffset -or + $contracts[0].Extent.EndOffset -ge $smoke[0].Extent.StartOffset -or + $build[0].Extent.Text -notmatch '(?s)Push-Location \$winghosttyRoot.*& \$zig0152 build -Demit-win32-host=true.*finally \{ Pop-Location \}') { + throw "Pinned host validation/build must precede App contracts, which must precede live smoke" + } +} + function Get-WorkflowJobs([string] $text) { # Windows checkouts may convert workflow files to CRLF. $text = $text.Replace("`r`n", "`n") @@ -2182,254 +2182,254 @@ function Test-CiAggregateGates([string] $repoRoot, [string] $pwsh) { } } -function Get-WorkflowSteps([string] $jobText) { - $jobText = $jobText.Replace("`r`n", "`n") - $heads = @([regex]::Matches($jobText, '(?m)^ - ')) - $steps = [Collections.Generic.List[string]]::new() - for ($index = 0; $index -lt $heads.Count; $index++) { - $end = if ($index + 1 -lt $heads.Count) { $heads[$index + 1].Index } else { $jobText.Length } - $steps.Add($jobText.Substring($heads[$index].Index, $end - $heads[$index].Index)) - } - , $steps.ToArray() -} - -# A cold provider cache miss must be able to seed its own immutable key even -# when a later gate fails or is cancelled, but never from a partial tree: the -# single save runs after a successful bootstrap, pin check, and build-only -# provider compile, and before any gate. Implicit success() gating (an `if:` -# without a status-check function) or an explicit success()/always() would -# reinstate whole-job gating or publish a failed build. -$script:ProviderCacheSaveCondition = "`${{ !cancelled() && steps.bootstrap.conclusion == 'success' && steps.verify-pins.conclusion == 'success' && steps.provider-build.conclusion == 'success' && steps.provider-cache.outputs.cache-hit != 'true' }}" -function Assert-ProviderCacheSeeding([string] $jobText, [string] $label, [bool] $requireGate) { - $steps = Get-WorkflowSteps $jobText - function Find-Step([scriptblock] $predicate) { - for ($index = 0; $index -lt $steps.Count; $index++) { - if (& $predicate $steps[$index]) { return $index } - } - return -1 - } - $restore = Find-Step { param($s) $s -match '(?m)^ id: provider-cache\s*$' -and $s -match 'actions/cache/restore@' } - $bootstrap = Find-Step { param($s) $s -match '(?m)^ id: bootstrap\s*$' -and $s -match '(?m)^ run: \./Tools/windows/bootstrap\.ps1 ' } - $verify = Find-Step { param($s) $s -match '(?m)^ id: verify-pins\s*$' -and $s -match '(?m)^ run: \./Tools/windows/Assert-ProviderCheckout\.ps1 -ProviderRoot \.ci-providers\s*$' } - $build = Find-Step { param($s) $s -match '(?m)^ id: provider-build\s*$' -and $s -match '(?m)^ run: \./Tools/windows/validate\.ps1 -Task provider-build\s*$' } - $saves = @(for ($index = 0; $index -lt $steps.Count; $index++) { if ($steps[$index] -match 'actions/cache/save@') { $index } }) - $gate = Find-Step { param($s) $s -match '(?m)^ run: \./Tools/windows/validate\.ps1 -Task windows-shell\b' } - foreach ($required in @( - @{ Index = $restore; Name = "provider cache restore (id provider-cache)" }, - @{ Index = $bootstrap; Name = "bootstrap (id bootstrap)" }, - @{ Index = $verify; Name = "pin verification (id verify-pins)" }, - @{ Index = $build; Name = "build-only provider compile (id provider-build, validate.ps1 -Task provider-build)" })) { - if ($required.Index -lt 0) { throw "RED: $label has no $($required.Name) before its provider cache save" } - } - if ($saves.Count -ne 1) { throw "RED: $label must have exactly one provider cache save, found $($saves.Count)" } - $save = $saves[0] - if ($requireGate -and $gate -lt 0) { throw "RED: $label has no downstream windows-shell gate" } - if (-not ($restore -lt $bootstrap -and $bootstrap -lt $verify -and $verify -lt $build -and $build -lt $save)) { - throw "RED: $label does not save only after restore, bootstrap, pin verification, and build-only compile" - } - if ($requireGate -and $save -gt $gate) { - throw "RED: $label saves the provider cache after the gate, so a failed or cancelled gate discards a cold build" - } - $saveText = $steps[$save] - $condition = [regex]::Match($saveText, '(?m)^ if:\s*(.+?)\s*$') - if (-not $condition.Success) { throw "RED: $label provider cache save has no explicit condition" } - $conditionText = $condition.Groups[1].Value - if ($conditionText -match '(?> "%FAKE_ZIG_LOG%"', - 'if "%FAKE_ZIG_FAIL%"=="1" exit /b 7', - 'if "%FAKE_ZIG_SKIP_ARTIFACT%"=="1" exit /b 0', - 'echo %* | findstr /c:"-Demit-win32-host=true" >nul && (mkdir zig-out\lib 2>nul & type nul > zig-out\lib\winghostty-win32-host.lib)', - 'echo %* | findstr /c:"-Dtarget=x86_64-windows-gnu" >nul && (mkdir zig-out\bin 2>nul & type nul > zig-out\bin\zmx.exe)', - 'exit /b 0') - $pins = [ordered]@{ schemaVersion = 1 } - foreach ($name in @("winghostty", "zmx")) { - $root = Join-Path $scratch $name - New-Item -ItemType Directory -Force $root | Out-Null - git -C $root init -q 2>$null - "zig-out/`n" | Set-Content -LiteralPath (Join-Path $root ".gitignore") -NoNewline - git -C $root add .gitignore - git -C $root -c user.name=t -c user.email=t@example.invalid commit -q -m pin 2>$null - $pins[$name] = [ordered]@{ sha = (git -C $root rev-parse HEAD) } - } - $pinsPath = Join-Path $scratch "provider-pins.json" - $pins | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $pinsPath - function Invoke-ProviderBuildCase([hashtable] $environment) { - foreach ($name in @("winghostty", "zmx")) { - Remove-Item -LiteralPath (Join-Path $scratch "$name\zig-out") -Recurse -Force -ErrorAction SilentlyContinue - } - $log = Join-Path $scratch "zig-$([guid]::NewGuid().ToString('N')).log" - $saved = @{} - $all = @{ FAKE_ZIG_LOG = $log; FAKE_ZIG_FAIL = $null; FAKE_ZIG_SKIP_ARTIFACT = $null } - foreach ($entry in $environment.GetEnumerator()) { $all[$entry.Key] = $entry.Value } - foreach ($entry in $all.GetEnumerator()) { - $saved[$entry.Key] = [Environment]::GetEnvironmentVariable($entry.Key) - [Environment]::SetEnvironmentVariable($entry.Key, $entry.Value) - } - try { - $output = @(& $pwsh -NoProfile -File $script ` - -WinghosttyRoot (Join-Path $scratch "winghostty") -ZmxRoot (Join-Path $scratch "zmx") ` - -Zig0152 $fakeZig -Zig0160 $fakeZig -PinsPath $pinsPath -ZmxAttempts 1 2>&1 | ForEach-Object { "$_" }) - $exit = $LASTEXITCODE - } finally { - foreach ($entry in $saved.GetEnumerator()) { [Environment]::SetEnvironmentVariable($entry.Key, $entry.Value) } - } - $invocations = if (Test-Path -LiteralPath $log) { @(Get-Content -LiteralPath $log) } else { @() } - [pscustomobject]@{ ExitCode = $exit; Output = $output; Invocations = $invocations } - } - $pass = Invoke-ProviderBuildCase @{} - $text = $pass.Output -join "`n" - $executed = [regex]::Match($text, '(?m)^PROVIDER_BUILD_EXECUTED=(\d+)\s*$') - $stepLines = @($pass.Output | Where-Object { $_ -match '^PROVIDER_BUILD_STEP=' }) - if ($pass.ExitCode -ne 0 -or -not $executed.Success -or [int]$executed.Groups[1].Value -ne 2 -or - $stepLines.Count -ne 2 -or $pass.Invocations.Count -ne 2) { - throw "RED: provider-build.ps1 did not execute exactly two pinned provider builds (exit=$($pass.ExitCode)): $text" - } - if ($pass.Invocations[0] -notmatch '^FAKE_ZIG build -Demit-win32-host=true\s*$' -or - $pass.Invocations[1] -notmatch '^FAKE_ZIG build -Dtarget=x86_64-windows-gnu\s*$') { - throw "RED: provider-build.ps1 changed the canonical provider build flags: $($pass.Invocations -join '; ')" - } - if ($text -match '(?i)terminal gate|smoke|TerminalGate\.Tests|zmx send|uia') { - throw "RED: provider-build.ps1 ran terminal tests or smoke: $text" - } - foreach ($case in @( - @{ Name = "missing artifact"; Environment = @{ FAKE_ZIG_SKIP_ARTIFACT = "1" } }, - @{ Name = "failed compiler"; Environment = @{ FAKE_ZIG_FAIL = "1" } })) { - $result = Invoke-ProviderBuildCase $case.Environment - if ($result.ExitCode -eq 0 -or ($result.Output -join "`n") -match '(?m)^PROVIDER_BUILD_EXECUTED=') { - throw "RED: provider-build.ps1 accepted a partial build ($($case.Name))" - } - } - "untracked" | Set-Content -LiteralPath (Join-Path $scratch "zmx\dirty.txt") - $dirty = Invoke-ProviderBuildCase @{} - Remove-Item -LiteralPath (Join-Path $scratch "zmx\dirty.txt") -Force - if ($dirty.ExitCode -eq 0 -or $dirty.Invocations.Count -ne 0) { - throw "RED: provider-build.ps1 built from a dirty provider worktree" - } - $wrongPins = [ordered]@{ schemaVersion = 1; winghostty = $pins.winghostty; zmx = [ordered]@{ sha = ("0" * 40) } } - $wrongPins | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $pinsPath - $wrong = Invoke-ProviderBuildCase @{} - if ($wrong.ExitCode -eq 0 -or $wrong.Invocations.Count -ne 0) { - throw "RED: provider-build.ps1 built an unpinned provider" - } - } finally { - Remove-Item -LiteralPath $scratch -Recurse -Force -ErrorAction SilentlyContinue - } -} - +function Get-WorkflowSteps([string] $jobText) { + $jobText = $jobText.Replace("`r`n", "`n") + $heads = @([regex]::Matches($jobText, '(?m)^ - ')) + $steps = [Collections.Generic.List[string]]::new() + for ($index = 0; $index -lt $heads.Count; $index++) { + $end = if ($index + 1 -lt $heads.Count) { $heads[$index + 1].Index } else { $jobText.Length } + $steps.Add($jobText.Substring($heads[$index].Index, $end - $heads[$index].Index)) + } + , $steps.ToArray() +} + +# A cold provider cache miss must be able to seed its own immutable key even +# when a later gate fails or is cancelled, but never from a partial tree: the +# single save runs after a successful bootstrap, pin check, and build-only +# provider compile, and before any gate. Implicit success() gating (an `if:` +# without a status-check function) or an explicit success()/always() would +# reinstate whole-job gating or publish a failed build. +$script:ProviderCacheSaveCondition = "`${{ !cancelled() && steps.bootstrap.conclusion == 'success' && steps.verify-pins.conclusion == 'success' && steps.provider-build.conclusion == 'success' && steps.provider-cache.outputs.cache-hit != 'true' }}" +function Assert-ProviderCacheSeeding([string] $jobText, [string] $label, [bool] $requireGate) { + $steps = Get-WorkflowSteps $jobText + function Find-Step([scriptblock] $predicate) { + for ($index = 0; $index -lt $steps.Count; $index++) { + if (& $predicate $steps[$index]) { return $index } + } + return -1 + } + $restore = Find-Step { param($s) $s -match '(?m)^ id: provider-cache\s*$' -and $s -match 'actions/cache/restore@' } + $bootstrap = Find-Step { param($s) $s -match '(?m)^ id: bootstrap\s*$' -and $s -match '(?m)^ run: \./Tools/windows/bootstrap\.ps1 ' } + $verify = Find-Step { param($s) $s -match '(?m)^ id: verify-pins\s*$' -and $s -match '(?m)^ run: \./Tools/windows/Assert-ProviderCheckout\.ps1 -ProviderRoot \.ci-providers\s*$' } + $build = Find-Step { param($s) $s -match '(?m)^ id: provider-build\s*$' -and $s -match '(?m)^ run: \./Tools/windows/validate\.ps1 -Task provider-build\s*$' } + $saves = @(for ($index = 0; $index -lt $steps.Count; $index++) { if ($steps[$index] -match 'actions/cache/save@') { $index } }) + $gate = Find-Step { param($s) $s -match '(?m)^ run: \./Tools/windows/validate\.ps1 -Task windows-shell\b' } + foreach ($required in @( + @{ Index = $restore; Name = "provider cache restore (id provider-cache)" }, + @{ Index = $bootstrap; Name = "bootstrap (id bootstrap)" }, + @{ Index = $verify; Name = "pin verification (id verify-pins)" }, + @{ Index = $build; Name = "build-only provider compile (id provider-build, validate.ps1 -Task provider-build)" })) { + if ($required.Index -lt 0) { throw "RED: $label has no $($required.Name) before its provider cache save" } + } + if ($saves.Count -ne 1) { throw "RED: $label must have exactly one provider cache save, found $($saves.Count)" } + $save = $saves[0] + if ($requireGate -and $gate -lt 0) { throw "RED: $label has no downstream windows-shell gate" } + if (-not ($restore -lt $bootstrap -and $bootstrap -lt $verify -and $verify -lt $build -and $build -lt $save)) { + throw "RED: $label does not save only after restore, bootstrap, pin verification, and build-only compile" + } + if ($requireGate -and $save -gt $gate) { + throw "RED: $label saves the provider cache after the gate, so a failed or cancelled gate discards a cold build" + } + $saveText = $steps[$save] + $condition = [regex]::Match($saveText, '(?m)^ if:\s*(.+?)\s*$') + if (-not $condition.Success) { throw "RED: $label provider cache save has no explicit condition" } + $conditionText = $condition.Groups[1].Value + if ($conditionText -match '(?> "%FAKE_ZIG_LOG%"', + 'if "%FAKE_ZIG_FAIL%"=="1" exit /b 7', + 'if "%FAKE_ZIG_SKIP_ARTIFACT%"=="1" exit /b 0', + 'echo %* | findstr /c:"-Demit-win32-host=true" >nul && (mkdir zig-out\lib 2>nul & type nul > zig-out\lib\winghostty-win32-host.lib)', + 'echo %* | findstr /c:"-Dtarget=x86_64-windows-gnu" >nul && (mkdir zig-out\bin 2>nul & type nul > zig-out\bin\zmx.exe)', + 'exit /b 0') + $pins = [ordered]@{ schemaVersion = 1 } + foreach ($name in @("winghostty", "zmx")) { + $root = Join-Path $scratch $name + New-Item -ItemType Directory -Force $root | Out-Null + git -C $root init -q 2>$null + "zig-out/`n" | Set-Content -LiteralPath (Join-Path $root ".gitignore") -NoNewline + git -C $root add .gitignore + git -C $root -c user.name=t -c user.email=t@example.invalid commit -q -m pin 2>$null + $pins[$name] = [ordered]@{ sha = (git -C $root rev-parse HEAD) } + } + $pinsPath = Join-Path $scratch "provider-pins.json" + $pins | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $pinsPath + function Invoke-ProviderBuildCase([hashtable] $environment) { + foreach ($name in @("winghostty", "zmx")) { + Remove-Item -LiteralPath (Join-Path $scratch "$name\zig-out") -Recurse -Force -ErrorAction SilentlyContinue + } + $log = Join-Path $scratch "zig-$([guid]::NewGuid().ToString('N')).log" + $saved = @{} + $all = @{ FAKE_ZIG_LOG = $log; FAKE_ZIG_FAIL = $null; FAKE_ZIG_SKIP_ARTIFACT = $null } + foreach ($entry in $environment.GetEnumerator()) { $all[$entry.Key] = $entry.Value } + foreach ($entry in $all.GetEnumerator()) { + $saved[$entry.Key] = [Environment]::GetEnvironmentVariable($entry.Key) + [Environment]::SetEnvironmentVariable($entry.Key, $entry.Value) + } + try { + $output = @(& $pwsh -NoProfile -File $script ` + -WinghosttyRoot (Join-Path $scratch "winghostty") -ZmxRoot (Join-Path $scratch "zmx") ` + -Zig0152 $fakeZig -Zig0160 $fakeZig -PinsPath $pinsPath -ZmxAttempts 1 2>&1 | ForEach-Object { "$_" }) + $exit = $LASTEXITCODE + } finally { + foreach ($entry in $saved.GetEnumerator()) { [Environment]::SetEnvironmentVariable($entry.Key, $entry.Value) } + } + $invocations = if (Test-Path -LiteralPath $log) { @(Get-Content -LiteralPath $log) } else { @() } + [pscustomobject]@{ ExitCode = $exit; Output = $output; Invocations = $invocations } + } + $pass = Invoke-ProviderBuildCase @{} + $text = $pass.Output -join "`n" + $executed = [regex]::Match($text, '(?m)^PROVIDER_BUILD_EXECUTED=(\d+)\s*$') + $stepLines = @($pass.Output | Where-Object { $_ -match '^PROVIDER_BUILD_STEP=' }) + if ($pass.ExitCode -ne 0 -or -not $executed.Success -or [int]$executed.Groups[1].Value -ne 2 -or + $stepLines.Count -ne 2 -or $pass.Invocations.Count -ne 2) { + throw "RED: provider-build.ps1 did not execute exactly two pinned provider builds (exit=$($pass.ExitCode)): $text" + } + if ($pass.Invocations[0] -notmatch '^FAKE_ZIG build -Demit-win32-host=true\s*$' -or + $pass.Invocations[1] -notmatch '^FAKE_ZIG build -Dtarget=x86_64-windows-gnu\s*$') { + throw "RED: provider-build.ps1 changed the canonical provider build flags: $($pass.Invocations -join '; ')" + } + if ($text -match '(?i)terminal gate|smoke|TerminalGate\.Tests|zmx send|uia') { + throw "RED: provider-build.ps1 ran terminal tests or smoke: $text" + } + foreach ($case in @( + @{ Name = "missing artifact"; Environment = @{ FAKE_ZIG_SKIP_ARTIFACT = "1" } }, + @{ Name = "failed compiler"; Environment = @{ FAKE_ZIG_FAIL = "1" } })) { + $result = Invoke-ProviderBuildCase $case.Environment + if ($result.ExitCode -eq 0 -or ($result.Output -join "`n") -match '(?m)^PROVIDER_BUILD_EXECUTED=') { + throw "RED: provider-build.ps1 accepted a partial build ($($case.Name))" + } + } + "untracked" | Set-Content -LiteralPath (Join-Path $scratch "zmx\dirty.txt") + $dirty = Invoke-ProviderBuildCase @{} + Remove-Item -LiteralPath (Join-Path $scratch "zmx\dirty.txt") -Force + if ($dirty.ExitCode -eq 0 -or $dirty.Invocations.Count -ne 0) { + throw "RED: provider-build.ps1 built from a dirty provider worktree" + } + $wrongPins = [ordered]@{ schemaVersion = 1; winghostty = $pins.winghostty; zmx = [ordered]@{ sha = ("0" * 40) } } + $wrongPins | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $pinsPath + $wrong = Invoke-ProviderBuildCase @{} + if ($wrong.ExitCode -eq 0 -or $wrong.Invocations.Count -ne 0) { + throw "RED: provider-build.ps1 built an unpinned provider" + } + } finally { + Remove-Item -LiteralPath $scratch -Recurse -Force -ErrorAction SilentlyContinue + } +} + # The Windows shell Zig sections are sharded across runners. A section passes # only with a positive test count per `zig test` (a filter that matches nothing # still exits 0), the shard plan is a complete disjoint partition, and the @@ -2539,392 +2539,392 @@ function Test-ShellSectionGate([string] $repoRoot, [string] $pwsh) { Remove-Item -LiteralPath $scratch -Recurse -Force -ErrorAction SilentlyContinue } } -function Test-ZigResolverDiagnostics([string] $source) { - $tokens = $null - $errors = $null - $ast = [Management.Automation.Language.Parser]::ParseInput($source, [ref]$tokens, [ref]$errors) - if ($errors.Count -ne 0) { throw "Resolver source does not parse" } - foreach ($name in @("Invoke-ZigResolverProbe", "Write-ZigResolverDiagnostic", "Resolve-ZigVersion")) { - $definition = $ast.Find({ - param($node) - $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq $name - }, $true) - if ($null -eq $definition) { throw "Missing actual resolver helper: $name" } - . ([scriptblock]::Create($definition.Extent.Text)) - } - function Assert-Resolver([bool] $condition, [string] $message) { - if (-not $condition) { throw "Zig diagnostic contract: $message" } - } - $environmentName = "GRAPHCODE_ZIG_RESOLVER_TEST" - $priorEnvironment = [Environment]::GetEnvironmentVariable($environmentName) - $priorExit = Get-Variable LASTEXITCODE -Scope Global -ErrorAction SilentlyContinue - $priorExitValue = if ($null -ne $priorExit) { $priorExit.Value } else { $null } - try { - function InMemoryZigProbe([string] $operation) { - if ($operation -eq "throw") { throw [ComponentModel.Win32Exception]::new(5, "ghp_PRIVATE_CANARY") } - $global:LASTEXITCODE = -1073741502 - Write-Output "0.15.2" - Write-Error "ghp_PRIVATE_CANARY" -ErrorAction Continue - } - $probe = Invoke-ZigResolverProbe "InMemoryZigProbe" "version" - Assert-Resolver ($probe.Output -ceq "0.15.2" -and $probe.ExitCode -eq -1073741502) "actual probe lost stdout or native exit" - Assert-Resolver ($probe.ErrorText -match "ghp_PRIVATE_CANARY" -and $null -eq $probe.Failure) "actual probe did not separate stderr" - $probe = Invoke-ZigResolverProbe "InMemoryZigProbe" "throw" - Assert-Resolver ($null -eq $probe.ExitCode -and $null -ne $probe.Failure) "launch failure reused stale LASTEXITCODE" - - $a = "C:\fixture\configured\zig.exe" - $b = "C:\fixture\path\zig.exe" - $c = "C:\fixture\discovered\zig.exe" - $repoRoot = "C:\fixture\repo" - $state = @{ - PathCandidate = $b; Discovered = @($c); Exists = @{}; Probes = @{} - Calls = [Collections.Generic.List[string]]::new() - Diagnostics = [Collections.Generic.List[string]]::new() - Warnings = [Collections.Generic.List[string]]::new() - WriterFails = $false - WarningFails = $false - } - function Get-Command($Name, $ErrorAction) { [pscustomobject]@{ Source = $state.PathCandidate } } - function Get-ChildItem($Path, [switch] $Recurse, $Filter, [switch] $File, $ErrorAction) { - foreach ($entry in $state.Discovered) { [pscustomobject]@{ FullName = $entry } } - } - function Test-Path($LiteralPath, $PathType, $ErrorAction) { $state.Exists[$LiteralPath] -eq $true } - function Resolve-Path($LiteralPath) { [pscustomobject]@{ Path = $LiteralPath } } - function Invoke-ZigResolverProbe([string] $candidate, [string] $operation) { - $key = "$candidate|$operation" - $state.Calls.Add($key) - if (-not $state.Probes.ContainsKey($key)) { throw "Unplanned in-memory probe" } - $state.Probes[$key] - } - function Write-Host($Object) { - if ($state.WriterFails) { throw "ghp_PRIVATE_CANARY" } - $state.Diagnostics.Add([string]$Object) - } - function Write-Warning($Message, $WarningAction) { - $state.Warnings.Add([string]$Message) - if ($state.WarningFails) { throw "ghp_PRIVATE_CANARY" } - } - function New-Probe($output, $exitCode = 0, $stderr = "") { - [pscustomobject]@{ Output = $output; ExitCode = $exitCode; ErrorText = $stderr; Failure = $null } - } - function Reset-ResolverCase { - $state.Calls.Clear(); $state.Diagnostics.Clear(); $state.Warnings.Clear() - $state.WriterFails = $false - $state.WarningFails = $false - $state.PathCandidate = $b - $state.Discovered = @($a, $c) - $state.Exists = @{ $a = $true; $b = $true; $c = $true } - $state.Probes = @{} - foreach ($candidate in @($a, $b, $c)) { - $state.Probes["$candidate|version"] = New-Probe "0.15.2" - $state.Probes["$candidate|env"] = New-Probe '{"version":"0.15.2","lib_dir":"C:\\fixture\\lib"}' - } - [Environment]::SetEnvironmentVariable($environmentName, $a) - } - function Invoke-ResolverCase { - $result = @() - $failure = $null - try { $result = @(Resolve-ZigVersion "0.15.2" $environmentName) } catch { $failure = $_ } - [pscustomobject]@{ Result = $result; Failure = $failure } - } - function Read-Diagnostic([int] $index = 0) { - $line = $state.Diagnostics[$index] - Assert-Resolver ($line.StartsWith("ZIG_RESOLVER_DIAGNOSTIC ") -and $line.Length -lt 2500) "diagnostic tag or bound" - Assert-Resolver ($line -notmatch "ghp_PRIVATE_CANARY|userinfo|GITHUB_TOKEN") "secret canary escaped diagnostic" - $line.Substring("ZIG_RESOLVER_DIAGNOSTIC ".Length) | ConvertFrom-Json - } - - Reset-ResolverCase - $case = Invoke-ResolverCase - Assert-Resolver ($case.Result.Count -eq 1 -and $case.Result[0] -ceq $a -and $null -eq $case.Failure) "success return changed" - Assert-Resolver (($state.Calls -join ",") -ceq "$a|version,$a|env" -and $state.Diagnostics.Count -eq 0) "success probes repeated or diagnosed" - Reset-ResolverCase - $state.Exists[$a] = $false - $state.Probes["$b|version"] = New-Probe "0.16.0" - $case = Invoke-ResolverCase - Assert-Resolver ($case.Result.Count -eq 1 -and $case.Result[0] -ceq $c) "fallback selection changed" - Assert-Resolver (($state.Calls -join ",") -ceq "$b|version,$c|version,$c|env") "fallback order/deduplication/env skipping changed" - $missing = Read-Diagnostic - $mismatch = Read-Diagnostic 1 - Assert-Resolver (-not $missing.exists -and $null -eq $missing.version -and $missing.source -eq "configured") "missing candidate fabricated probe" - Assert-Resolver ($mismatch.source -eq "PATH" -and $mismatch.version.observedVersion -eq "0.16.0" -and $null -eq $mismatch.env) "mismatch evidence wrong" - - Reset-ResolverCase - $state.Probes["$a|version"] = New-Probe "ghp_PRIVATE_CANARY" -1073741502 "ghp_PRIVATE_CANARY" - $case = Invoke-ResolverCase - $diagnostic = Read-Diagnostic - Assert-Resolver ($case.Result[0] -ceq $b -and $diagnostic.version.exitCodeHex -eq "0xC0000142") "nonzero exit/fallback changed" - Assert-Resolver ($null -eq $diagnostic.version.observedVersion -and $diagnostic.version.stderr.reason -eq "unclassified") "unsafe version or stderr surfaced" - foreach ($envText in @("invalid ghp_PRIVATE_CANARY", '{"version":"0.15.2","lib_dir":"C:\\fixture\\absent","env":{"GITHUB_TOKEN":"ghp_PRIVATE_CANARY"}}')) { - Reset-ResolverCase - $state.Probes["$a|env"] = New-Probe $envText 9 "error: unable to find zig installation directory ghp_PRIVATE_CANARY" - $case = Invoke-ResolverCase - $diagnostic = Read-Diagnostic - Assert-Resolver ($case.Result[0] -ceq $b -and $diagnostic.reason -eq "env-exit" -and $diagnostic.env.exitCode -eq 9) "env failure selection changed" - Assert-Resolver ($diagnostic.env.stderr.reason -eq "unable to find zig installation directory") "safe known reason missing" - if ($envText.StartsWith("{")) { - Assert-Resolver ($diagnostic.env.parse -eq "parsed" -and $diagnostic.env.libDirectoryPresent -eq $false) "library metadata missing" - } else { - Assert-Resolver ($diagnostic.env.parse -eq "metadata-unavailable") "parse metadata missing" - } - $state.Probes["$a|env"] = New-Probe $envText - $state.Diagnostics.Clear() - $case = Invoke-ResolverCase - Assert-Resolver ($case.Result[0] -ceq $a -and $state.Diagnostics.Count -eq 0) "new JSON/lib gate was introduced" - } - - Reset-ResolverCase - $failure = [Management.Automation.ErrorRecord]::new( - [Management.Automation.RuntimeException]::new("ghp_PRIVATE_CANARY", - [ComponentModel.Win32Exception]::new(5, "ghp_PRIVATE_CANARY")), - "Launch", [Management.Automation.ErrorCategory]::OpenError, $null) - $state.Probes["$a|version"] = [pscustomobject]@{ Output = $null; ExitCode = $null; ErrorText = ""; Failure = $failure } - $case = Invoke-ResolverCase - $diagnostic = Read-Diagnostic - Assert-Resolver ($null -ne $case.Failure -and $state.Calls.Count -eq 1 -and $case.Failure.ToString() -notmatch "ghp_PRIVATE_CANARY") "exception changed stop behavior or exposed secret" - Assert-Resolver ($null -eq $diagnostic.version.exitCode -and $diagnostic.version.nativeErrorCode -eq 5) "exception fabricated exit" - Reset-ResolverCase - $state.Probes["$a|env"] = [pscustomobject]@{ Output = $null; ExitCode = $null; ErrorText = ""; Failure = $failure } - $case = Invoke-ResolverCase - $diagnostic = Read-Diagnostic - Assert-Resolver ($null -ne $case.Failure -and $state.Calls.Count -eq 2 -and $diagnostic.reason -eq "env-exception") "env exception did not stop after one probe" - Assert-Resolver ($null -eq $diagnostic.env.exitCode -and $diagnostic.env.nativeErrorCode -eq 5) "env exception lost nullable exit/native code" - Reset-ResolverCase - $state.Probes["$a|version"] = New-Probe "0.16.0" - $state.WriterFails = $true - $case = Invoke-ResolverCase - Assert-Resolver ($case.Result[0] -ceq $b -and $state.Warnings.Count -eq 1) "writer failure changed fallback" - $state.WarningFails = $true - $state.Calls.Clear() - $case = Invoke-ResolverCase - Assert-Resolver ($case.Result.Count -eq 1 -and $case.Result[0] -ceq $b -and $null -eq $case.Failure) "both writer failures changed fallback" - Assert-Resolver (($state.Calls -join ",") -ceq "$a|version,$b|version,$b|env") "both writer failures changed probe order" - foreach ($candidate in @($a, $b, $c)) { $state.Exists[$candidate] = $false } - $case = Invoke-ResolverCase - Assert-Resolver ($case.Result.Count -eq 0 -and $case.Failure.ToString() -eq "Zig 0.15.2 is required for the pinned Windows provider; set $environmentName.") "both writer failures masked original guard" - - Reset-ResolverCase - $nonAsciiVersion = ([string][char]0x0661) + ".2.3" - $overLimitVersion = "1.2.3+" + ("a" * 65) - foreach ($unsafeVersion in @($nonAsciiVersion, $overLimitVersion)) { - foreach ($probeName in @("version", "env")) { - $state.Diagnostics.Clear() - $text = if ($probeName -eq "version") { $unsafeVersion } else { @{ version = $unsafeVersion } | ConvertTo-Json -Compress } - $probe = New-Probe $text 1 - if ($probeName -eq "version") { - Write-ZigResolverDiagnostic $a "configured" "0.15.2" $true "version-exit" $probe $null - } else { - Write-ZigResolverDiagnostic $a "configured" "0.15.2" $true "env-exit" (New-Probe "0.15.2") $probe - } - $diagnostic = Read-Diagnostic - $summary = $diagnostic.$probeName - Assert-Resolver ($null -eq $summary.observedVersion -and - $summary.stdout.bytes -eq [Text.Encoding]::UTF8.GetByteCount($text) -and - $summary.stdout.sha256.Length -eq 64) "non-ASCII or over-limit version was not reduced to hash/length" - Assert-Resolver (-not $state.Diagnostics[0].Contains($unsafeVersion)) "unsafe version appeared literally" - } - } - Assert-Resolver ([Text.Encoding]::UTF8.GetByteCount($overLimitVersion) -gt 64) "version size control is not over limit" - $state.WriterFails = $false - foreach ($candidate in @("https://userinfo@github.com/zig", "C:\ghp_PRIVATE_CANARY\zig.exe", ("C:\" + ("x" * 520)))) { - $state.Diagnostics.Clear() - Write-ZigResolverDiagnostic $candidate "configured" "0.15.2" $true "version-exit" (New-Probe ("ghp_PRIVATE_CANARY" * 2000) 1) $null - $diagnostic = Read-Diagnostic - Assert-Resolver ($diagnostic.candidate -eq "[omitted]" -and $diagnostic.version.stdout.bytes -gt 16384) "candidate/output cap or redaction failed" - } - } finally { - [Environment]::SetEnvironmentVariable($environmentName, $priorEnvironment) - if ($null -eq $priorExit) { Remove-Variable LASTEXITCODE -Scope Global -ErrorAction SilentlyContinue } - else { $global:LASTEXITCODE = $priorExitValue } - } -} - -$runner = Join-Path $PSScriptRoot "..\validate.ps1" -if (-not (Test-Path $runner)) { - throw "RED: validation runner does not exist at $runner" -} - -$tasks = & $runner -List -$expected = @( - "swift-portable", - "swift-contracts", - "swift-production", - "swift-paths", - "swift-process", - "swift-named-pipe", - "remote-bridge", - "remote-e2e", - "swift-format", - "visual-baseline", - "tdd-evidence", - "privacy", - "terminal-gate", - "windows-shell", - "packaging", - "hardening" -) -foreach ($task in $expected) { - if ($tasks -notcontains $task) { - throw "Validation task '$task' is missing" - } -} - -$dryRun = & $runner -Task swift-paths -DryRun -if ($LASTEXITCODE -ne 0) { - throw "Dry run failed with exit code $LASTEXITCODE" -} -if (($dryRun -join "`n") -notmatch "swift-paths") { - throw "Dry run did not name the selected task" -} - -# provider-build is the build-only entry point for provider cache seeding: it is -# selectable on its own but never part of -Task all (terminal-gate reuses it). -if ($tasks -notcontains "provider-build") { - throw "RED: validation runner has no build-only provider-build task" -} -$providerDryRun = @(& $runner -Task provider-build -DryRun) -if ($LASTEXITCODE -ne 0 -or $providerDryRun.Count -ne 1 -or $providerDryRun[0] -cne "task=provider-build") { - throw "RED: -Task provider-build does not select exactly the build-only task: $($providerDryRun -join ', ')" -} -$allDryRun = @(& $runner -Task all -DryRun) -if ($allDryRun.Count -lt 10 -or $allDryRun -contains "task=provider-build" -or $allDryRun -notcontains "task=terminal-gate") { - throw "RED: -Task all must keep terminal-gate and exclude the build-only provider-build task: $($allDryRun -join ', ')" -} -$providerRunnerSource = Get-Content $runner -Raw -$providerClause = [regex]::Match($providerRunnerSource, '(?s)\n "provider-build" \{(.*?)\n \}') -if (-not $providerClause.Success -or $providerClause.Groups[1].Value -notmatch 'Invoke-ProviderBuild' -or - $providerClause.Groups[1].Value -match '(?i)terminal-gate\.ps1|TerminalGate\.Tests|windows-shell\.ps1|uia|Stress') { - throw "RED: provider-build task does not run only the shared build-only provider compile" -} -$terminalClause = [regex]::Match($providerRunnerSource, '(?s)\n "terminal-gate" \{(.*?)\n \}') -if (-not $terminalClause.Success -or - $terminalClause.Groups[1].Value -notmatch '(?s)Invoke-ProviderBuild.*?terminal-gate\.ps1.*?-SkipProviderBuild.*?-Stress') { - throw "RED: terminal-gate does not reuse the build-only provider compile before its full gate" -} -$providerScriptSource = Get-Content (Join-Path $PSScriptRoot "..\provider-build.ps1") -Raw -ErrorAction SilentlyContinue -foreach ($consumer in @("terminal-gate.ps1", "windows-shell.ps1")) { - $consumerSource = Get-Content (Join-Path $PSScriptRoot "..\$consumer") -Raw - if ($consumerSource -notmatch 'provider-build\.ps1' -or - $consumerSource -match '-Demit-win32-host=true' -or - $consumerSource -match '-Dtarget=x86_64-windows-gnu') { - throw "RED: $consumer duplicates the pinned provider build instead of calling provider-build.ps1" - } -} -if ($providerScriptSource -notmatch '-Demit-win32-host=true' -or $providerScriptSource -notmatch '-Dtarget=x86_64-windows-gnu') { - throw "RED: provider-build.ps1 does not own the canonical provider build flags" -} - -$pwsh = (Get-Process -Id $PID).Path -& $pwsh -NoProfile -File $runner -Task not-a-task *> $null -if ($LASTEXITCODE -eq 0) { - throw "An unknown validation task succeeded" -} - -$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..\..\..") -$untrackedDirectory = Join-Path $repoRoot "investigation\spikes\validation-runner-untracked" -New-Item -ItemType Directory -Force $untrackedDirectory | Out-Null -try { - "let value=1" | Set-Content (Join-Path $untrackedDirectory "Unformatted.swift") - & $pwsh -NoProfile -File $runner -Task swift-format *> $null - if ($LASTEXITCODE -eq 0) { - throw "An unformatted untracked Swift source was ignored" - } -} finally { - Remove-Item -LiteralPath $untrackedDirectory -Recurse -Force -} - -$foreignJunction = Join-Path $repoRoot ` - "investigation\spikes\swift-contracts\Sources\GraphcodeWindowsContracts\OwnershipSentinel" -New-Item -ItemType Directory -Force $foreignJunction | Out-Null -try { - & $runner -Task swift-format -DryRun *> $null - if (-not (Test-Path $foreignJunction)) { - throw "A validation task removed resources owned by another task" - } - - $windowsWorkflow = Get-Content (Join-Path $repoRoot ".github\workflows\windows-hardening.yml") -Raw - if ($windowsWorkflow -notmatch "(?s)full-pinned:.*bootstrap\.ps1.*validate\.ps1 -Task all.*Hardening\.Tests\.ps1 -Environment") { - throw "RED: full-pinned Windows CI does not run real hardening after provider setup" - } - if ($windowsWorkflow -notmatch "GRAPHCODE_HARDENING_TARGET") { - throw "RED: full-pinned Windows CI does not provide an owned environment harness" - } - $hardeningSource = Get-Content (Join-Path $PSScriptRoot "Hardening.Tests.ps1") -Raw - foreach ($stage in @("real zmx/ConPTY terminal matrix", "real GraphCode shell matrix")) { - if ($hardeningSource -notmatch ([regex]::Escape($stage) + ' failed \(exit=\$LASTEXITCODE; hex=')) { - throw "Hardening stage failure omits the native exit code: $stage" - } - } - & { - $tokens = $null - $errors = $null - $ast = [Management.Automation.Language.Parser]::ParseInput( - $hardeningSource, [ref]$tokens, [ref]$errors) - foreach ($name in @("Find-Bytes", "Get-HighOutputDiagnostics", "Get-HighOutputPayloadText")) { - $function = $ast.Find({ - param($node) - $node -is [Management.Automation.Language.FunctionDefinitionAst] -and - $node.Name -eq $name - }, $true) - if (-not $function) { throw "RED: high-output diagnostics helper is missing: $name" } - . ([scriptblock]::Create($function.Extent.Text)) - } - $bytes = [Text.Encoding]::ASCII.GetBytes("START" + ("A" * 1024) + "END") - $diagnostics = Get-HighOutputDiagnostics $bytes "START" "END" - if ($diagnostics.capturedBytes -ne $bytes.Length -or - $diagnostics.startOffset -ne 0 -or $diagnostics.endOffset -ne 1029 -or - $diagnostics.prefix.Length -ne 512 -or $diagnostics.suffix.Length -ne 512) { - throw "High-output diagnostics lost marker positions or exceeded transcript bounds" - } - $partial = Get-HighOutputDiagnostics ([Text.Encoding]::ASCII.GetBytes("END")) "START" "END" - if ($partial.startOffset -ne -1 -or $partial.endOffset -ne 0) { - throw "High-output diagnostics hide an end marker when the start marker is missing" - } - $empty = Get-HighOutputDiagnostics ([byte[]]::new(0)) "START" "END" - if ($empty.capturedBytes -ne 0 -or $empty.startOffset -ne -1 -or - $empty.endOffset -ne -1 -or $empty.prefix -ne "" -or $empty.suffix -ne "") { - throw "High-output diagnostics cannot report an empty capture" - } - $escape = [string][char]27 - $captures = @( - "STARTAAAAEND", - "ST${escape}[0mARTAA${escape}[31mAAEN${escape}[0mD", - "STA`r`nRTAAAAE`r`nND", - "START${escape}]0;END$([char]7)AAAAEND", - "ST${escape}]0;title${escape}\ARTAAAAEND", - "${escape}]0;before${escape}\STARTAAAAEND${escape}]0;after${escape}\" - ) - foreach ($capture in $captures) { - $payload = Get-HighOutputPayloadText ([Text.Encoding]::ASCII.GetBytes($capture)) "START" "END" - if ($payload -cne "AAAA") { - throw "RED: high-output completion must survive terminal framing inside markers" - } - } - foreach ($capture in @("", "STARTAAAA", "AAAAEND", "ENDSTARTAAAA", - "${escape}]0;STARTAAAAEND")) { - $payload = Get-HighOutputPayloadText ([Text.Encoding]::ASCII.GetBytes($capture)) "START" "END" - if ($null -ne $payload) { throw "Incomplete or reversed output markers were accepted" } - } - $emptyPayload = Get-HighOutputPayloadText ([Text.Encoding]::ASCII.GetBytes("STARTEND")) "START" "END" - if ($null -eq $emptyPayload -or $emptyPayload -cne "") { - throw "Empty completed output must reach the length/hash checks, not look pending" - } - } - if ($hardeningSource -notmatch - '(?s)if \(-not \$completed\).*?Get-HighOutputDiagnostics.*?HARDENING_OUTPUT_DIAGNOSTICS_JSON=.*?Assert-True \$completed') { - throw "RED: real high-output failure omits bounded transcript diagnostics" - } - if ($hardeningSource -notmatch - '(?s)if \(\$LASTEXITCODE -ne 0\) \{\s*\$output \| Write-Output\s*throw "hardening repeated run') { - throw "RED: failed repeated hardening discards its child diagnostics" - } - if ($hardeningSource -notmatch - '(?s)\$shellVersion\s*=\s*\(& \$shell --version.*?-Version \$shellVersion') { - throw "RED: post-release hardening does not preserve the built shell version" - } - $windowsShellWorkflow = Get-Content (Join-Path $repoRoot ".github\workflows\windows-shell.yml") -Raw - $windowsPortWorkflow = Get-Content ` - (Join-Path $repoRoot ".github\workflows\windows-port-validation.yml") -Raw +function Test-ZigResolverDiagnostics([string] $source) { + $tokens = $null + $errors = $null + $ast = [Management.Automation.Language.Parser]::ParseInput($source, [ref]$tokens, [ref]$errors) + if ($errors.Count -ne 0) { throw "Resolver source does not parse" } + foreach ($name in @("Invoke-ZigResolverProbe", "Write-ZigResolverDiagnostic", "Resolve-ZigVersion")) { + $definition = $ast.Find({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq $name + }, $true) + if ($null -eq $definition) { throw "Missing actual resolver helper: $name" } + . ([scriptblock]::Create($definition.Extent.Text)) + } + function Assert-Resolver([bool] $condition, [string] $message) { + if (-not $condition) { throw "Zig diagnostic contract: $message" } + } + $environmentName = "GRAPHCODE_ZIG_RESOLVER_TEST" + $priorEnvironment = [Environment]::GetEnvironmentVariable($environmentName) + $priorExit = Get-Variable LASTEXITCODE -Scope Global -ErrorAction SilentlyContinue + $priorExitValue = if ($null -ne $priorExit) { $priorExit.Value } else { $null } + try { + function InMemoryZigProbe([string] $operation) { + if ($operation -eq "throw") { throw [ComponentModel.Win32Exception]::new(5, "ghp_PRIVATE_CANARY") } + $global:LASTEXITCODE = -1073741502 + Write-Output "0.15.2" + Write-Error "ghp_PRIVATE_CANARY" -ErrorAction Continue + } + $probe = Invoke-ZigResolverProbe "InMemoryZigProbe" "version" + Assert-Resolver ($probe.Output -ceq "0.15.2" -and $probe.ExitCode -eq -1073741502) "actual probe lost stdout or native exit" + Assert-Resolver ($probe.ErrorText -match "ghp_PRIVATE_CANARY" -and $null -eq $probe.Failure) "actual probe did not separate stderr" + $probe = Invoke-ZigResolverProbe "InMemoryZigProbe" "throw" + Assert-Resolver ($null -eq $probe.ExitCode -and $null -ne $probe.Failure) "launch failure reused stale LASTEXITCODE" + + $a = "C:\fixture\configured\zig.exe" + $b = "C:\fixture\path\zig.exe" + $c = "C:\fixture\discovered\zig.exe" + $repoRoot = "C:\fixture\repo" + $state = @{ + PathCandidate = $b; Discovered = @($c); Exists = @{}; Probes = @{} + Calls = [Collections.Generic.List[string]]::new() + Diagnostics = [Collections.Generic.List[string]]::new() + Warnings = [Collections.Generic.List[string]]::new() + WriterFails = $false + WarningFails = $false + } + function Get-Command($Name, $ErrorAction) { [pscustomobject]@{ Source = $state.PathCandidate } } + function Get-ChildItem($Path, [switch] $Recurse, $Filter, [switch] $File, $ErrorAction) { + foreach ($entry in $state.Discovered) { [pscustomobject]@{ FullName = $entry } } + } + function Test-Path($LiteralPath, $PathType, $ErrorAction) { $state.Exists[$LiteralPath] -eq $true } + function Resolve-Path($LiteralPath) { [pscustomobject]@{ Path = $LiteralPath } } + function Invoke-ZigResolverProbe([string] $candidate, [string] $operation) { + $key = "$candidate|$operation" + $state.Calls.Add($key) + if (-not $state.Probes.ContainsKey($key)) { throw "Unplanned in-memory probe" } + $state.Probes[$key] + } + function Write-Host($Object) { + if ($state.WriterFails) { throw "ghp_PRIVATE_CANARY" } + $state.Diagnostics.Add([string]$Object) + } + function Write-Warning($Message, $WarningAction) { + $state.Warnings.Add([string]$Message) + if ($state.WarningFails) { throw "ghp_PRIVATE_CANARY" } + } + function New-Probe($output, $exitCode = 0, $stderr = "") { + [pscustomobject]@{ Output = $output; ExitCode = $exitCode; ErrorText = $stderr; Failure = $null } + } + function Reset-ResolverCase { + $state.Calls.Clear(); $state.Diagnostics.Clear(); $state.Warnings.Clear() + $state.WriterFails = $false + $state.WarningFails = $false + $state.PathCandidate = $b + $state.Discovered = @($a, $c) + $state.Exists = @{ $a = $true; $b = $true; $c = $true } + $state.Probes = @{} + foreach ($candidate in @($a, $b, $c)) { + $state.Probes["$candidate|version"] = New-Probe "0.15.2" + $state.Probes["$candidate|env"] = New-Probe '{"version":"0.15.2","lib_dir":"C:\\fixture\\lib"}' + } + [Environment]::SetEnvironmentVariable($environmentName, $a) + } + function Invoke-ResolverCase { + $result = @() + $failure = $null + try { $result = @(Resolve-ZigVersion "0.15.2" $environmentName) } catch { $failure = $_ } + [pscustomobject]@{ Result = $result; Failure = $failure } + } + function Read-Diagnostic([int] $index = 0) { + $line = $state.Diagnostics[$index] + Assert-Resolver ($line.StartsWith("ZIG_RESOLVER_DIAGNOSTIC ") -and $line.Length -lt 2500) "diagnostic tag or bound" + Assert-Resolver ($line -notmatch "ghp_PRIVATE_CANARY|userinfo|GITHUB_TOKEN") "secret canary escaped diagnostic" + $line.Substring("ZIG_RESOLVER_DIAGNOSTIC ".Length) | ConvertFrom-Json + } + + Reset-ResolverCase + $case = Invoke-ResolverCase + Assert-Resolver ($case.Result.Count -eq 1 -and $case.Result[0] -ceq $a -and $null -eq $case.Failure) "success return changed" + Assert-Resolver (($state.Calls -join ",") -ceq "$a|version,$a|env" -and $state.Diagnostics.Count -eq 0) "success probes repeated or diagnosed" + Reset-ResolverCase + $state.Exists[$a] = $false + $state.Probes["$b|version"] = New-Probe "0.16.0" + $case = Invoke-ResolverCase + Assert-Resolver ($case.Result.Count -eq 1 -and $case.Result[0] -ceq $c) "fallback selection changed" + Assert-Resolver (($state.Calls -join ",") -ceq "$b|version,$c|version,$c|env") "fallback order/deduplication/env skipping changed" + $missing = Read-Diagnostic + $mismatch = Read-Diagnostic 1 + Assert-Resolver (-not $missing.exists -and $null -eq $missing.version -and $missing.source -eq "configured") "missing candidate fabricated probe" + Assert-Resolver ($mismatch.source -eq "PATH" -and $mismatch.version.observedVersion -eq "0.16.0" -and $null -eq $mismatch.env) "mismatch evidence wrong" + + Reset-ResolverCase + $state.Probes["$a|version"] = New-Probe "ghp_PRIVATE_CANARY" -1073741502 "ghp_PRIVATE_CANARY" + $case = Invoke-ResolverCase + $diagnostic = Read-Diagnostic + Assert-Resolver ($case.Result[0] -ceq $b -and $diagnostic.version.exitCodeHex -eq "0xC0000142") "nonzero exit/fallback changed" + Assert-Resolver ($null -eq $diagnostic.version.observedVersion -and $diagnostic.version.stderr.reason -eq "unclassified") "unsafe version or stderr surfaced" + foreach ($envText in @("invalid ghp_PRIVATE_CANARY", '{"version":"0.15.2","lib_dir":"C:\\fixture\\absent","env":{"GITHUB_TOKEN":"ghp_PRIVATE_CANARY"}}')) { + Reset-ResolverCase + $state.Probes["$a|env"] = New-Probe $envText 9 "error: unable to find zig installation directory ghp_PRIVATE_CANARY" + $case = Invoke-ResolverCase + $diagnostic = Read-Diagnostic + Assert-Resolver ($case.Result[0] -ceq $b -and $diagnostic.reason -eq "env-exit" -and $diagnostic.env.exitCode -eq 9) "env failure selection changed" + Assert-Resolver ($diagnostic.env.stderr.reason -eq "unable to find zig installation directory") "safe known reason missing" + if ($envText.StartsWith("{")) { + Assert-Resolver ($diagnostic.env.parse -eq "parsed" -and $diagnostic.env.libDirectoryPresent -eq $false) "library metadata missing" + } else { + Assert-Resolver ($diagnostic.env.parse -eq "metadata-unavailable") "parse metadata missing" + } + $state.Probes["$a|env"] = New-Probe $envText + $state.Diagnostics.Clear() + $case = Invoke-ResolverCase + Assert-Resolver ($case.Result[0] -ceq $a -and $state.Diagnostics.Count -eq 0) "new JSON/lib gate was introduced" + } + + Reset-ResolverCase + $failure = [Management.Automation.ErrorRecord]::new( + [Management.Automation.RuntimeException]::new("ghp_PRIVATE_CANARY", + [ComponentModel.Win32Exception]::new(5, "ghp_PRIVATE_CANARY")), + "Launch", [Management.Automation.ErrorCategory]::OpenError, $null) + $state.Probes["$a|version"] = [pscustomobject]@{ Output = $null; ExitCode = $null; ErrorText = ""; Failure = $failure } + $case = Invoke-ResolverCase + $diagnostic = Read-Diagnostic + Assert-Resolver ($null -ne $case.Failure -and $state.Calls.Count -eq 1 -and $case.Failure.ToString() -notmatch "ghp_PRIVATE_CANARY") "exception changed stop behavior or exposed secret" + Assert-Resolver ($null -eq $diagnostic.version.exitCode -and $diagnostic.version.nativeErrorCode -eq 5) "exception fabricated exit" + Reset-ResolverCase + $state.Probes["$a|env"] = [pscustomobject]@{ Output = $null; ExitCode = $null; ErrorText = ""; Failure = $failure } + $case = Invoke-ResolverCase + $diagnostic = Read-Diagnostic + Assert-Resolver ($null -ne $case.Failure -and $state.Calls.Count -eq 2 -and $diagnostic.reason -eq "env-exception") "env exception did not stop after one probe" + Assert-Resolver ($null -eq $diagnostic.env.exitCode -and $diagnostic.env.nativeErrorCode -eq 5) "env exception lost nullable exit/native code" + Reset-ResolverCase + $state.Probes["$a|version"] = New-Probe "0.16.0" + $state.WriterFails = $true + $case = Invoke-ResolverCase + Assert-Resolver ($case.Result[0] -ceq $b -and $state.Warnings.Count -eq 1) "writer failure changed fallback" + $state.WarningFails = $true + $state.Calls.Clear() + $case = Invoke-ResolverCase + Assert-Resolver ($case.Result.Count -eq 1 -and $case.Result[0] -ceq $b -and $null -eq $case.Failure) "both writer failures changed fallback" + Assert-Resolver (($state.Calls -join ",") -ceq "$a|version,$b|version,$b|env") "both writer failures changed probe order" + foreach ($candidate in @($a, $b, $c)) { $state.Exists[$candidate] = $false } + $case = Invoke-ResolverCase + Assert-Resolver ($case.Result.Count -eq 0 -and $case.Failure.ToString() -eq "Zig 0.15.2 is required for the pinned Windows provider; set $environmentName.") "both writer failures masked original guard" + + Reset-ResolverCase + $nonAsciiVersion = ([string][char]0x0661) + ".2.3" + $overLimitVersion = "1.2.3+" + ("a" * 65) + foreach ($unsafeVersion in @($nonAsciiVersion, $overLimitVersion)) { + foreach ($probeName in @("version", "env")) { + $state.Diagnostics.Clear() + $text = if ($probeName -eq "version") { $unsafeVersion } else { @{ version = $unsafeVersion } | ConvertTo-Json -Compress } + $probe = New-Probe $text 1 + if ($probeName -eq "version") { + Write-ZigResolverDiagnostic $a "configured" "0.15.2" $true "version-exit" $probe $null + } else { + Write-ZigResolverDiagnostic $a "configured" "0.15.2" $true "env-exit" (New-Probe "0.15.2") $probe + } + $diagnostic = Read-Diagnostic + $summary = $diagnostic.$probeName + Assert-Resolver ($null -eq $summary.observedVersion -and + $summary.stdout.bytes -eq [Text.Encoding]::UTF8.GetByteCount($text) -and + $summary.stdout.sha256.Length -eq 64) "non-ASCII or over-limit version was not reduced to hash/length" + Assert-Resolver (-not $state.Diagnostics[0].Contains($unsafeVersion)) "unsafe version appeared literally" + } + } + Assert-Resolver ([Text.Encoding]::UTF8.GetByteCount($overLimitVersion) -gt 64) "version size control is not over limit" + $state.WriterFails = $false + foreach ($candidate in @("https://userinfo@github.com/zig", "C:\ghp_PRIVATE_CANARY\zig.exe", ("C:\" + ("x" * 520)))) { + $state.Diagnostics.Clear() + Write-ZigResolverDiagnostic $candidate "configured" "0.15.2" $true "version-exit" (New-Probe ("ghp_PRIVATE_CANARY" * 2000) 1) $null + $diagnostic = Read-Diagnostic + Assert-Resolver ($diagnostic.candidate -eq "[omitted]" -and $diagnostic.version.stdout.bytes -gt 16384) "candidate/output cap or redaction failed" + } + } finally { + [Environment]::SetEnvironmentVariable($environmentName, $priorEnvironment) + if ($null -eq $priorExit) { Remove-Variable LASTEXITCODE -Scope Global -ErrorAction SilentlyContinue } + else { $global:LASTEXITCODE = $priorExitValue } + } +} + +$runner = Join-Path $PSScriptRoot "..\validate.ps1" +if (-not (Test-Path $runner)) { + throw "RED: validation runner does not exist at $runner" +} + +$tasks = & $runner -List +$expected = @( + "swift-portable", + "swift-contracts", + "swift-production", + "swift-paths", + "swift-process", + "swift-named-pipe", + "remote-bridge", + "remote-e2e", + "swift-format", + "visual-baseline", + "tdd-evidence", + "privacy", + "terminal-gate", + "windows-shell", + "packaging", + "hardening" +) +foreach ($task in $expected) { + if ($tasks -notcontains $task) { + throw "Validation task '$task' is missing" + } +} + +$dryRun = & $runner -Task swift-paths -DryRun +if ($LASTEXITCODE -ne 0) { + throw "Dry run failed with exit code $LASTEXITCODE" +} +if (($dryRun -join "`n") -notmatch "swift-paths") { + throw "Dry run did not name the selected task" +} + +# provider-build is the build-only entry point for provider cache seeding: it is +# selectable on its own but never part of -Task all (terminal-gate reuses it). +if ($tasks -notcontains "provider-build") { + throw "RED: validation runner has no build-only provider-build task" +} +$providerDryRun = @(& $runner -Task provider-build -DryRun) +if ($LASTEXITCODE -ne 0 -or $providerDryRun.Count -ne 1 -or $providerDryRun[0] -cne "task=provider-build") { + throw "RED: -Task provider-build does not select exactly the build-only task: $($providerDryRun -join ', ')" +} +$allDryRun = @(& $runner -Task all -DryRun) +if ($allDryRun.Count -lt 10 -or $allDryRun -contains "task=provider-build" -or $allDryRun -notcontains "task=terminal-gate") { + throw "RED: -Task all must keep terminal-gate and exclude the build-only provider-build task: $($allDryRun -join ', ')" +} +$providerRunnerSource = Get-Content $runner -Raw +$providerClause = [regex]::Match($providerRunnerSource, '(?s)\n "provider-build" \{(.*?)\n \}') +if (-not $providerClause.Success -or $providerClause.Groups[1].Value -notmatch 'Invoke-ProviderBuild' -or + $providerClause.Groups[1].Value -match '(?i)terminal-gate\.ps1|TerminalGate\.Tests|windows-shell\.ps1|uia|Stress') { + throw "RED: provider-build task does not run only the shared build-only provider compile" +} +$terminalClause = [regex]::Match($providerRunnerSource, '(?s)\n "terminal-gate" \{(.*?)\n \}') +if (-not $terminalClause.Success -or + $terminalClause.Groups[1].Value -notmatch '(?s)Invoke-ProviderBuild.*?terminal-gate\.ps1.*?-SkipProviderBuild.*?-Stress') { + throw "RED: terminal-gate does not reuse the build-only provider compile before its full gate" +} +$providerScriptSource = Get-Content (Join-Path $PSScriptRoot "..\provider-build.ps1") -Raw -ErrorAction SilentlyContinue +foreach ($consumer in @("terminal-gate.ps1", "windows-shell.ps1")) { + $consumerSource = Get-Content (Join-Path $PSScriptRoot "..\$consumer") -Raw + if ($consumerSource -notmatch 'provider-build\.ps1' -or + $consumerSource -match '-Demit-win32-host=true' -or + $consumerSource -match '-Dtarget=x86_64-windows-gnu') { + throw "RED: $consumer duplicates the pinned provider build instead of calling provider-build.ps1" + } +} +if ($providerScriptSource -notmatch '-Demit-win32-host=true' -or $providerScriptSource -notmatch '-Dtarget=x86_64-windows-gnu') { + throw "RED: provider-build.ps1 does not own the canonical provider build flags" +} + +$pwsh = (Get-Process -Id $PID).Path +& $pwsh -NoProfile -File $runner -Task not-a-task *> $null +if ($LASTEXITCODE -eq 0) { + throw "An unknown validation task succeeded" +} + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..\..\..") +$untrackedDirectory = Join-Path $repoRoot "investigation\spikes\validation-runner-untracked" +New-Item -ItemType Directory -Force $untrackedDirectory | Out-Null +try { + "let value=1" | Set-Content (Join-Path $untrackedDirectory "Unformatted.swift") + & $pwsh -NoProfile -File $runner -Task swift-format *> $null + if ($LASTEXITCODE -eq 0) { + throw "An unformatted untracked Swift source was ignored" + } +} finally { + Remove-Item -LiteralPath $untrackedDirectory -Recurse -Force +} + +$foreignJunction = Join-Path $repoRoot ` + "investigation\spikes\swift-contracts\Sources\GraphcodeWindowsContracts\OwnershipSentinel" +New-Item -ItemType Directory -Force $foreignJunction | Out-Null +try { + & $runner -Task swift-format -DryRun *> $null + if (-not (Test-Path $foreignJunction)) { + throw "A validation task removed resources owned by another task" + } + + $windowsWorkflow = Get-Content (Join-Path $repoRoot ".github\workflows\windows-hardening.yml") -Raw + if ($windowsWorkflow -notmatch "(?s)full-pinned:.*bootstrap\.ps1.*validate\.ps1 -Task all.*Hardening\.Tests\.ps1 -Environment") { + throw "RED: full-pinned Windows CI does not run real hardening after provider setup" + } + if ($windowsWorkflow -notmatch "GRAPHCODE_HARDENING_TARGET") { + throw "RED: full-pinned Windows CI does not provide an owned environment harness" + } + $hardeningSource = Get-Content (Join-Path $PSScriptRoot "Hardening.Tests.ps1") -Raw + foreach ($stage in @("real zmx/ConPTY terminal matrix", "real GraphCode shell matrix")) { + if ($hardeningSource -notmatch ([regex]::Escape($stage) + ' failed \(exit=\$LASTEXITCODE; hex=')) { + throw "Hardening stage failure omits the native exit code: $stage" + } + } + & { + $tokens = $null + $errors = $null + $ast = [Management.Automation.Language.Parser]::ParseInput( + $hardeningSource, [ref]$tokens, [ref]$errors) + foreach ($name in @("Find-Bytes", "Get-HighOutputDiagnostics", "Get-HighOutputPayloadText")) { + $function = $ast.Find({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -eq $name + }, $true) + if (-not $function) { throw "RED: high-output diagnostics helper is missing: $name" } + . ([scriptblock]::Create($function.Extent.Text)) + } + $bytes = [Text.Encoding]::ASCII.GetBytes("START" + ("A" * 1024) + "END") + $diagnostics = Get-HighOutputDiagnostics $bytes "START" "END" + if ($diagnostics.capturedBytes -ne $bytes.Length -or + $diagnostics.startOffset -ne 0 -or $diagnostics.endOffset -ne 1029 -or + $diagnostics.prefix.Length -ne 512 -or $diagnostics.suffix.Length -ne 512) { + throw "High-output diagnostics lost marker positions or exceeded transcript bounds" + } + $partial = Get-HighOutputDiagnostics ([Text.Encoding]::ASCII.GetBytes("END")) "START" "END" + if ($partial.startOffset -ne -1 -or $partial.endOffset -ne 0) { + throw "High-output diagnostics hide an end marker when the start marker is missing" + } + $empty = Get-HighOutputDiagnostics ([byte[]]::new(0)) "START" "END" + if ($empty.capturedBytes -ne 0 -or $empty.startOffset -ne -1 -or + $empty.endOffset -ne -1 -or $empty.prefix -ne "" -or $empty.suffix -ne "") { + throw "High-output diagnostics cannot report an empty capture" + } + $escape = [string][char]27 + $captures = @( + "STARTAAAAEND", + "ST${escape}[0mARTAA${escape}[31mAAEN${escape}[0mD", + "STA`r`nRTAAAAE`r`nND", + "START${escape}]0;END$([char]7)AAAAEND", + "ST${escape}]0;title${escape}\ARTAAAAEND", + "${escape}]0;before${escape}\STARTAAAAEND${escape}]0;after${escape}\" + ) + foreach ($capture in $captures) { + $payload = Get-HighOutputPayloadText ([Text.Encoding]::ASCII.GetBytes($capture)) "START" "END" + if ($payload -cne "AAAA") { + throw "RED: high-output completion must survive terminal framing inside markers" + } + } + foreach ($capture in @("", "STARTAAAA", "AAAAEND", "ENDSTARTAAAA", + "${escape}]0;STARTAAAAEND")) { + $payload = Get-HighOutputPayloadText ([Text.Encoding]::ASCII.GetBytes($capture)) "START" "END" + if ($null -ne $payload) { throw "Incomplete or reversed output markers were accepted" } + } + $emptyPayload = Get-HighOutputPayloadText ([Text.Encoding]::ASCII.GetBytes("STARTEND")) "START" "END" + if ($null -eq $emptyPayload -or $emptyPayload -cne "") { + throw "Empty completed output must reach the length/hash checks, not look pending" + } + } + if ($hardeningSource -notmatch + '(?s)if \(-not \$completed\).*?Get-HighOutputDiagnostics.*?HARDENING_OUTPUT_DIAGNOSTICS_JSON=.*?Assert-True \$completed') { + throw "RED: real high-output failure omits bounded transcript diagnostics" + } + if ($hardeningSource -notmatch + '(?s)if \(\$LASTEXITCODE -ne 0\) \{\s*\$output \| Write-Output\s*throw "hardening repeated run') { + throw "RED: failed repeated hardening discards its child diagnostics" + } + if ($hardeningSource -notmatch + '(?s)\$shellVersion\s*=\s*\(& \$shell --version.*?-Version \$shellVersion') { + throw "RED: post-release hardening does not preserve the built shell version" + } + $windowsShellWorkflow = Get-Content (Join-Path $repoRoot ".github\workflows\windows-shell.yml") -Raw + $windowsPortWorkflow = Get-Content ` + (Join-Path $repoRoot ".github\workflows\windows-port-validation.yml") -Raw $windowsCacheWarmerPath = Join-Path $repoRoot ".github\workflows\windows-cache-warmer.yml" if (-not (Test-Path -LiteralPath $windowsCacheWarmerPath -PathType Leaf)) { throw "RED: Windows CI has no main-scoped cache warmer" @@ -2954,17 +2954,17 @@ try { throw "RED: Windows cache warmer does not write the exact production key: $key" } } - if ($windowsCacheWarmerWorkflow -notmatch '(?m)^ push:\s*$' -or - $windowsCacheWarmerWorkflow -notmatch '(?m)^ branches: \[main\]\s*$' -or - $windowsCacheWarmerWorkflow -notmatch '(?m)^ schedule:\s*$' -or - $windowsCacheWarmerWorkflow -notmatch '(?m)^ - cron: "23 4 \* \* 1"\s*$' -or - $windowsCacheWarmerWorkflow -notmatch '(?m)^ workflow_dispatch:\s*$' -or - $windowsCacheWarmerWorkflow -notmatch '(?s)validate\.ps1 -Task provider-build.*?actions/cache/save@' -or - $windowsCacheWarmerWorkflow -match 'validate\.ps1 -Task terminal-gate') { - throw "RED: Windows cache warmer does not cover main, weekly, manual, and build-only canonical provider builds" - } - Test-ProviderCacheSeeding $windowsShellWorkflow $windowsCacheWarmerWorkflow - Test-ProviderCacheSeedingMutations $windowsShellWorkflow $windowsCacheWarmerWorkflow + if ($windowsCacheWarmerWorkflow -notmatch '(?m)^ push:\s*$' -or + $windowsCacheWarmerWorkflow -notmatch '(?m)^ branches: \[main\]\s*$' -or + $windowsCacheWarmerWorkflow -notmatch '(?m)^ schedule:\s*$' -or + $windowsCacheWarmerWorkflow -notmatch '(?m)^ - cron: "23 4 \* \* 1"\s*$' -or + $windowsCacheWarmerWorkflow -notmatch '(?m)^ workflow_dispatch:\s*$' -or + $windowsCacheWarmerWorkflow -notmatch '(?s)validate\.ps1 -Task provider-build.*?actions/cache/save@' -or + $windowsCacheWarmerWorkflow -match 'validate\.ps1 -Task terminal-gate') { + throw "RED: Windows cache warmer does not cover main, weekly, manual, and build-only canonical provider builds" + } + Test-ProviderCacheSeeding $windowsShellWorkflow $windowsCacheWarmerWorkflow + Test-ProviderCacheSeedingMutations $windowsShellWorkflow $windowsCacheWarmerWorkflow Test-ProviderBuildScript $repoRoot $pwsh foreach ($workflow in @($windowsShellWorkflow, $windowsPortWorkflow)) { if ($workflow -notmatch @@ -2972,80 +2972,80 @@ try { throw "RED: Windows CI does not retain failed UIA sandbox diagnostics as an artifact" } } - foreach ($workflow in @($windowsWorkflow, $windowsShellWorkflow, $windowsPortWorkflow)) { - if ($workflow -notmatch - "compnerd/gha-setup-swift@397094e75494a93fa8d81db0268dbc8f5d6cf7c6" -or - $workflow -notmatch "swift-version: swift-6\.3\.3-release" -or - $workflow -notmatch "swift-build: 6\.3\.3-RELEASE") { - throw "RED: pinned Windows CI does not install Swift 6.3.3 without WinGet" - } - } - if ($windowsShellWorkflow -notmatch "bootstrap\.ps1") { - throw "RED: Windows shell CI does not bootstrap exact dependencies" - } - if ($windowsShellWorkflow -notmatch "validate\.ps1 -Task windows-shell -SkipTrayLive" -or - $windowsPortWorkflow -notmatch "validate\.ps1 -Task all -SkipTrayLive -SkipWslRemoteE2E" -or - $windowsWorkflow -notmatch "validate\.ps1 -Task all -SkipTrayLive -SkipWslRemoteE2E" -or - $windowsWorkflow -notmatch "Hardening\.Tests\.ps1 -Environment -SkipTrayLive") { - throw "RED: hosted Windows CI does not explicitly declare unsupported interactive or WSL fixtures" - } - if ($windowsShellWorkflow -notmatch '(?m)^\s*run:\s*\./Tools/windows/validate\.ps1 -Task windows-shell\b') { - throw "RED: Windows shell CI does not invoke the shell task containing live UI Automation" - } - $runnerSource = Get-Content $runner -Raw + foreach ($workflow in @($windowsWorkflow, $windowsShellWorkflow, $windowsPortWorkflow)) { + if ($workflow -notmatch + "compnerd/gha-setup-swift@397094e75494a93fa8d81db0268dbc8f5d6cf7c6" -or + $workflow -notmatch "swift-version: swift-6\.3\.3-release" -or + $workflow -notmatch "swift-build: 6\.3\.3-RELEASE") { + throw "RED: pinned Windows CI does not install Swift 6.3.3 without WinGet" + } + } + if ($windowsShellWorkflow -notmatch "bootstrap\.ps1") { + throw "RED: Windows shell CI does not bootstrap exact dependencies" + } + if ($windowsShellWorkflow -notmatch "validate\.ps1 -Task windows-shell -SkipTrayLive" -or + $windowsPortWorkflow -notmatch "validate\.ps1 -Task all -SkipTrayLive -SkipWslRemoteE2E" -or + $windowsWorkflow -notmatch "validate\.ps1 -Task all -SkipTrayLive -SkipWslRemoteE2E" -or + $windowsWorkflow -notmatch "Hardening\.Tests\.ps1 -Environment -SkipTrayLive") { + throw "RED: hosted Windows CI does not explicitly declare unsupported interactive or WSL fixtures" + } + if ($windowsShellWorkflow -notmatch '(?m)^\s*run:\s*\./Tools/windows/validate\.ps1 -Task windows-shell\b') { + throw "RED: Windows shell CI does not invoke the shell task containing live UI Automation" + } + $runnerSource = Get-Content $runner -Raw if ($runnerSource -notmatch '(?s)WINDOWS_SHELL_PRE_UIA_PROCESS_SNAPSHOT=.*?Stop-Process -Id.*?WINDOWS_SHELL_PRE_UIA_CLEANUP=verified.*?Native UI Automation live gate') { throw "RED: Windows shell validation does not snapshot and reap run-owned product processes before UIA" } - Test-ZigResolverDiagnostics $runnerSource - Assert-ShellHostPrerequisite $runnerSource - $contractCall = [regex]::Match($runnerSource, - '(?s)& \(Join-Path \$repoRoot "Tools\\windows\\Tests\\WindowsShell\.Tests\.ps1"\)\s*`\s*-ZigExecutable \$zig0152').Value - if (-not $contractCall) { throw "Cannot construct the host prerequisite ordering control" } - $earlyContracts = $runnerSource.Replace($contractCall, "").Replace( - 'Invoke-Native "Pinned Winghostty host build for App contracts"', - $contractCall + "`n " + 'Invoke-Native "Pinned Winghostty host build for App contracts"') - foreach ($mutation in @( - $runnerSource.Replace('& $zig0152 build -Demit-win32-host=true', 'Write-Output "deliberately skipped build"'), - $runnerSource.Replace('"Pinned Winghostty host build for App contracts"', '"deliberately removed prerequisite"'), - $earlyContracts - )) { - $rejected = $false - try { Assert-ShellHostPrerequisite $mutation } catch { $rejected = $true } - if (-not $rejected) { throw "Host prerequisite contract accepted a deliberate missing-build control" } - } - if ($runnerSource -notmatch '(?s)"packaging" \{\s*if \(\$PackagingPart -ne "real"\) \{\s*& .*?Packaging\.Signing\.Tests\.ps1.*?Packaging\.Tests\.ps1') { - throw "RED: packaging validation does not run signed catalog integrity contracts" - } - if ($runnerSource -notmatch '(?s)"packaging" \{\s*if \(\$PackagingPart -ne "real"\) \{\s*& .*?Packaging\.Rollback\.Tests\.ps1.*?Packaging\.Tests\.ps1') { - throw "RED: packaging validation does not run rollback preservation contracts" - } - if ($runnerSource -notmatch '(?s)"packaging" \{\s*if \(\$PackagingPart -ne "real"\) \{\s*& .*?Packaging\.Standalone\.Tests\.ps1.*?Packaging\.Tests\.ps1') { - throw "RED: packaging validation does not run standalone setup contracts" - } - foreach ($contract in @("Packaging.ScriptSigning.Tests.ps1", "Packaging.Scheduler.Tests.ps1")) { - if ($runnerSource -notmatch ('(?s)"packaging" \{\s*if \(\$PackagingPart -ne "real"\) \{\s*& .*?' + [regex]::Escape($contract) + '.*?Packaging\.Tests\.ps1')) { - throw "RED: packaging validation does not run $contract" - } - } + Test-ZigResolverDiagnostics $runnerSource + Assert-ShellHostPrerequisite $runnerSource + $contractCall = [regex]::Match($runnerSource, + '(?s)& \(Join-Path \$repoRoot "Tools\\windows\\Tests\\WindowsShell\.Tests\.ps1"\)\s*`\s*-ZigExecutable \$zig0152').Value + if (-not $contractCall) { throw "Cannot construct the host prerequisite ordering control" } + $earlyContracts = $runnerSource.Replace($contractCall, "").Replace( + 'Invoke-Native "Pinned Winghostty host build for App contracts"', + $contractCall + "`n " + 'Invoke-Native "Pinned Winghostty host build for App contracts"') + foreach ($mutation in @( + $runnerSource.Replace('& $zig0152 build -Demit-win32-host=true', 'Write-Output "deliberately skipped build"'), + $runnerSource.Replace('"Pinned Winghostty host build for App contracts"', '"deliberately removed prerequisite"'), + $earlyContracts + )) { + $rejected = $false + try { Assert-ShellHostPrerequisite $mutation } catch { $rejected = $true } + if (-not $rejected) { throw "Host prerequisite contract accepted a deliberate missing-build control" } + } + if ($runnerSource -notmatch '(?s)"packaging" \{\s*if \(\$PackagingPart -ne "real"\) \{\s*& .*?Packaging\.Signing\.Tests\.ps1.*?Packaging\.Tests\.ps1') { + throw "RED: packaging validation does not run signed catalog integrity contracts" + } + if ($runnerSource -notmatch '(?s)"packaging" \{\s*if \(\$PackagingPart -ne "real"\) \{\s*& .*?Packaging\.Rollback\.Tests\.ps1.*?Packaging\.Tests\.ps1') { + throw "RED: packaging validation does not run rollback preservation contracts" + } + if ($runnerSource -notmatch '(?s)"packaging" \{\s*if \(\$PackagingPart -ne "real"\) \{\s*& .*?Packaging\.Standalone\.Tests\.ps1.*?Packaging\.Tests\.ps1') { + throw "RED: packaging validation does not run standalone setup contracts" + } + foreach ($contract in @("Packaging.ScriptSigning.Tests.ps1", "Packaging.Scheduler.Tests.ps1")) { + if ($runnerSource -notmatch ('(?s)"packaging" \{\s*if \(\$PackagingPart -ne "real"\) \{\s*& .*?' + [regex]::Escape($contract) + '.*?Packaging\.Tests\.ps1')) { + throw "RED: packaging validation does not run $contract" + } + } if ($runnerSource -notmatch '(?s)if \(\$PackagingPart -ne "contracts"\) \{\s*Initialize-PackagingInputs\s*& \(Join-Path \$repoRoot "Tools\\windows\\Tests\\Packaging\.Tests\.ps1"\)') { throw "RED: real packaging does not rebuild its own inputs before Packaging.Tests.ps1" } Test-CiPartitionCoverage $runner $pwsh $repoRoot Test-ShellSectionGate $repoRoot $pwsh Test-CiAggregateGates $repoRoot $pwsh - if ($runnerSource -notmatch '(?s)"terminal-gate" \{\s*& .*?ProviderPins\.Tests\.ps1.*?TerminalGate\.Tests\.ps1') { - throw "RED: terminal validation does not run provider pin no-divergence contracts" - } - foreach ($source in @($runnerSource, $hardeningSource)) { - if ($source -notmatch '-StubResponseDelayMilliseconds 150') { - throw "RED: shell validation does not exercise delayed correlated responses" - } - } - if ($runnerSource -notmatch '(?s)Pinned GraphCode Windows shell build and smoke.*?Native UI Automation live gate.*?uia-live-gate\.ps1') { - throw "RED: Windows shell validation does not execute the UI Automation live gate" - } - $uiaLiveGateSource = Get-Content (Join-Path $repoRoot "Tools\windows\uia-live-gate.ps1") -Raw + if ($runnerSource -notmatch '(?s)"terminal-gate" \{\s*& .*?ProviderPins\.Tests\.ps1.*?TerminalGate\.Tests\.ps1') { + throw "RED: terminal validation does not run provider pin no-divergence contracts" + } + foreach ($source in @($runnerSource, $hardeningSource)) { + if ($source -notmatch '-StubResponseDelayMilliseconds 150') { + throw "RED: shell validation does not exercise delayed correlated responses" + } + } + if ($runnerSource -notmatch '(?s)Pinned GraphCode Windows shell build and smoke.*?Native UI Automation live gate.*?uia-live-gate\.ps1') { + throw "RED: Windows shell validation does not execute the UI Automation live gate" + } + $uiaLiveGateSource = Get-Content (Join-Path $repoRoot "Tools\windows\uia-live-gate.ps1") -Raw if ($uiaLiveGateSource -notmatch 'function Get-UiaStartupFileDiagnostic' -or $uiaLiveGateSource -notmatch 'RedirectStandardOutput' -or $uiaLiveGateSource -notmatch 'function Get-UiaPrelaunchDiagnostics' -or @@ -3353,83 +3353,83 @@ Start-Sleep -Seconds 60 $uiaLiveGateSource -notmatch 'AttachThreadInput\(currentThread, targetThread, true\).*?Marshal.GetLastWin32Error\(\)') { throw "RED: UIA foreground failure hides native return values and last-error diagnostics" } - if ($uiaLiveGateSource -notmatch 'AttachThreadInput' -or - $uiaLiveGateSource -notmatch 'keybd_event\(0x12, 0, 0, UIntPtr\.Zero\)' -or - $uiaLiveGateSource -notmatch 'SetActiveWindow\(window\)' -or - $uiaLiveGateSource -notmatch 'PostMessage\(window, 0x0101, \(UIntPtr\)key, IntPtr\.Zero\)' -or - $uiaLiveGateSource -notmatch '\[DllImport\("kernel32\.dll"\)\]\s*private static extern uint GetCurrentThreadId' -or - $uiaLiveGateSource -notmatch 'IsForegroundWindow\(\$window\)' -or - $uiaLiveGateSource -notmatch 'UIA_FOCUS_DIAGNOSTICS' -or - $uiaLiveGateSource -notmatch '(?s)function Hide-TestProviderZmxWindows.*?\[string\]\$_\.ExecutablePath\)\s+-eq\s+\$providerZmx.*?HideProcessWindows.*?HideWindow\(\$foreground\)' -or - $uiaLiveGateSource -notmatch 'function Retain-FocusWithRetry' -or - $uiaLiveGateSource -notmatch 'Test-FocusedElementIdentity \$candidate \$element \$expectedAutomationId' -or - $uiaLiveGateSource -notmatch '\[GraphCodeUiaGateState\]::ActivateWindow\(\$window\)' -or - $uiaLiveGateSource -notmatch '\$element\.SetFocus\(\)' -or - $uiaLiveGateSource -notmatch 'Retain-FocusWithRetry \$shellWindow \$safeFocusRow \$safeRowId "before-retention"' -or - $uiaLiveGateSource -notmatch '\$backendFocus = Retain-FocusWithRetry' -or - $uiaLiveGateSource -notmatch 'Product Settings backend control could not retain foreground focus' -or - $uiaLiveGateSource -notmatch '\$cancelFocus = Retain-FocusWithRetry' -or - $uiaLiveGateSource -notmatch 'Product Settings model control could not retain foreground focus') { - throw "RED: UIA live gate does not prove foreground ownership before accepting row focus" - } - if ($uiaLiveGateSource -notmatch 'function Wait-ForDesktopElement' -or - $uiaLiveGateSource -notmatch 'function Wait-ForDesktopElementGone' -or - $uiaLiveGateSource -notmatch 'UIA_WAIT_DIAGNOSTICS' -or - $uiaLiveGateSource -notmatch 'empty global New Loop node form' -or - $uiaLiveGateSource -notmatch 'empty project New Loop node form' -or - $uiaLiveGateSource -notmatch 'project-row New Loop node form' -or - $uiaLiveGateSource -notmatch 'Open Folder picker close') { - throw "RED: UIA live gate does not wait deterministically for asynchronous modal windows" - } - if ($uiaLiveGateSource -match '(?s)New Loop command was rejected.*?for \(\$index = 0; \$index -lt 40 -and \$null -eq \$.*NodeForm' -or - $uiaLiveGateSource -match '(?s)Open Folder command was rejected.*?Start-Sleep -Milliseconds 200\s*[\r\n]+\s*Require \(\[GraphCodeUiaGateState\]::PostCommand\(\$shellWindow, 4602\)\)') { - throw "RED: UIA live gate reintroduced short fixed polling around New Loop modal commands" - } - if ($uiaLiveGateSource -notmatch 'function Ensure-ShellForeground' -or - $uiaLiveGateSource -notmatch '\[GraphCodeUiaGateState\]::ActivateWindow\(\$window\)\s*[\r\n]+\s*\$acquired = \$activated -and \[GraphCodeUiaGateState\]::IsForegroundWindow\(\$window\)' -or - $uiaLiveGateSource -notmatch 'UIA_FOREGROUND_DIAGNOSTICS phase=\$label \$\(Get-FocusDiagnostics \$window\)' -or - $uiaLiveGateSource -notmatch '(?s)function Ensure-ShellForeground\(.*?if \(\[GraphCodeUiaGateState\]::IsForegroundWindow\(\$window\)\) \{\s*[\r\n]+\s*return \$true\s*[\r\n]+\s*\}') { - throw "RED: UIA live gate does not reacquire GraphCode shell foreground within a bounded deadline before command paths, or performs the invasive Alt-tap activation even when already foreground" - } - if ($uiaLiveGateSource -notmatch '(?s)function Wait-ForDesktopElement\(.*?\[switch\] \$RecoverForeground.*?\$remainingMilliseconds = \[Math\]::Max\(.*?\$recoveryTimeout = \[Math\]::Min\(1000, \$remainingMilliseconds\).*?UIA_WAIT_FOREGROUND_RECOVERY label=\$label.*?Ensure-ShellForeground.*?-TimeoutMilliseconds \$recoveryTimeout' -or - $uiaLiveGateSource -notmatch 'UIA_WAIT_DIAGNOSTICS label=\$label foregroundRecoveries=\$foregroundRecoveries' -or - $uiaLiveGateSource -notmatch '(?s)-label "project-row New Loop node form".*?-RecoverForeground' -or - $uiaLiveGateSource -notmatch '(?s)-label "empty global New Loop node form".*?-RecoverForeground' -or - $uiaLiveGateSource -notmatch '(?s)-label "empty project New Loop node form".*?-RecoverForeground') { - throw "RED: UIA modal waits do not boundedly reacquire foreground after a post-command foreground loss" - } - if ($uiaLiveGateSource -notmatch '(?s)Require \(\$null -ne \$projectNewLoop\) "project row omitted New Loop"\s*[\r\n]+\s*Require \(Ensure-ShellForeground \$shellWindow "project-row New Loop"\)\s*`\s*[\r\n]+\s*"GraphCode shell did not reacquire foreground before invoking project-row New Loop"\s*[\r\n]+\s*\$projectNewLoop\.GetCurrentPattern' -or - $uiaLiveGateSource -notmatch '(?s)Require \(Ensure-ShellForeground \$shellWindow "empty global New Loop"\)\s*`\s*[\r\n]+\s*"GraphCode shell did not reacquire foreground before empty global New Loop command"\s*[\r\n]+\s*Require \(\[GraphCodeUiaGateState\]::PostCommand\(\$shellWindow, 4602\)\)\s*`\s*[\r\n]+\s*"empty global New Loop command was rejected"' -or - $uiaLiveGateSource -notmatch '(?s)Require \(Ensure-ShellForeground \$shellWindow "empty project New Loop"\)\s*`\s*[\r\n]+\s*"GraphCode shell did not reacquire foreground before empty project New Loop command"\s*[\r\n]+\s*Require \(\[GraphCodeUiaGateState\]::PostCommand\(\$shellWindow, 4602\)\)\s*`\s*[\r\n]+\s*"empty project New Loop command was rejected"') { - throw "RED: UIA live gate New Loop invocation is not preceded by verified foreground recovery at every site" - } - $shellTests = Get-Content (Join-Path $PSScriptRoot "WindowsShell.Tests.ps1") -Raw - if ($uiaLiveGateSource -notmatch 'function Wait-ForPopupMenu' -or - $uiaLiveGateSource -notmatch 'function Get-PopupMenuItems' -or - $uiaLiveGateSource -notmatch 'function Close-PopupMenu' -or - $uiaLiveGateSource -notmatch 'FindPopupMenuWindow' -or - $uiaLiveGateSource -notmatch 'SendMessage\(popup, 0x01E1, UIntPtr\.Zero, IntPtr\.Zero\)' -or - $uiaLiveGateSource -notmatch 'PostMessage\(window, 0x802C, \(UIntPtr\)target, IntPtr\.Zero\)') { - throw "RED: UIA live gate cannot open, read, or dismiss a native TrackPopupMenu popup" - } - if ($uiaLiveGateSource -notmatch '\$moveProjectMenuText = "Move Project\.\.\. \(unavailable: daemon support required\)"' -or - $uiaLiveGateSource -notmatch '(?s)PostContextMenu\(\$shellWindow, 1\).*?Wait-ForPopupMenu \$process \$shellWindow "project"' -or - $uiaLiveGateSource -notmatch 'Require \(-not \$moveProjectItem\.Enabled\)' -or - $uiaLiveGateSource -notmatch '\$moveProjectItem\.Text -eq \$moveProjectMenuText' -or - $uiaLiveGateSource -notmatch '(?s)PostContextMenu\(\$shellWindow, 2\).*?\$_\.Id -in @\(5149, 5151, 5144\)' -or - $uiaLiveGateSource -notmatch 'project context menu did not dismiss, leaving the shell blocked in its modal loop') { - throw "RED: UIA live gate does not assert the live project context menu's disabled Move item and deterministic dismissal" - } - if ($shellTests -notmatch '(?s)Context menu and gate fixture message executable tests.*?zig test src\\GraphContextMenu\.zig' -or - $shellTests -notmatch '(?s)Context menu and gate fixture message executable tests.*?zig test src\\MainWindow\.zig') { - throw "RED: Windows shell validation does not run the context menu and gate fixture message tests" - } - $appSource = Get-Content (Join-Path $repoRoot "graphcode-windows\src\App.zig") -Raw - if ($appSource -notmatch 'fn showUiaContextMenu' -or - $appSource -notmatch 'MainWindow\.wm_uia_context_menu => \{' -or - $appSource -notmatch '(?s)wparam == MainWindow\.menu_watchdog_timer_id.*?c\.EndMenu\(\)') { - throw "RED: the shell cannot open a gate-requested context menu, or an abandoned popup can block its message loop forever" - } + if ($uiaLiveGateSource -notmatch 'AttachThreadInput' -or + $uiaLiveGateSource -notmatch 'keybd_event\(0x12, 0, 0, UIntPtr\.Zero\)' -or + $uiaLiveGateSource -notmatch 'SetActiveWindow\(window\)' -or + $uiaLiveGateSource -notmatch 'PostMessage\(window, 0x0101, \(UIntPtr\)key, IntPtr\.Zero\)' -or + $uiaLiveGateSource -notmatch '\[DllImport\("kernel32\.dll"\)\]\s*private static extern uint GetCurrentThreadId' -or + $uiaLiveGateSource -notmatch 'IsForegroundWindow\(\$window\)' -or + $uiaLiveGateSource -notmatch 'UIA_FOCUS_DIAGNOSTICS' -or + $uiaLiveGateSource -notmatch '(?s)function Hide-TestProviderZmxWindows.*?\[string\]\$_\.ExecutablePath\)\s+-eq\s+\$providerZmx.*?HideProcessWindows.*?HideWindow\(\$foreground\)' -or + $uiaLiveGateSource -notmatch 'function Retain-FocusWithRetry' -or + $uiaLiveGateSource -notmatch 'Test-FocusedElementIdentity \$candidate \$element \$expectedAutomationId' -or + $uiaLiveGateSource -notmatch '\[GraphCodeUiaGateState\]::ActivateWindow\(\$window\)' -or + $uiaLiveGateSource -notmatch '\$element\.SetFocus\(\)' -or + $uiaLiveGateSource -notmatch 'Retain-FocusWithRetry \$shellWindow \$safeFocusRow \$safeRowId "before-retention"' -or + $uiaLiveGateSource -notmatch '\$backendFocus = Retain-FocusWithRetry' -or + $uiaLiveGateSource -notmatch 'Product Settings backend control could not retain foreground focus' -or + $uiaLiveGateSource -notmatch '\$cancelFocus = Retain-FocusWithRetry' -or + $uiaLiveGateSource -notmatch 'Product Settings model control could not retain foreground focus') { + throw "RED: UIA live gate does not prove foreground ownership before accepting row focus" + } + if ($uiaLiveGateSource -notmatch 'function Wait-ForDesktopElement' -or + $uiaLiveGateSource -notmatch 'function Wait-ForDesktopElementGone' -or + $uiaLiveGateSource -notmatch 'UIA_WAIT_DIAGNOSTICS' -or + $uiaLiveGateSource -notmatch 'empty global New Loop node form' -or + $uiaLiveGateSource -notmatch 'empty project New Loop node form' -or + $uiaLiveGateSource -notmatch 'project-row New Loop node form' -or + $uiaLiveGateSource -notmatch 'Open Folder picker close') { + throw "RED: UIA live gate does not wait deterministically for asynchronous modal windows" + } + if ($uiaLiveGateSource -match '(?s)New Loop command was rejected.*?for \(\$index = 0; \$index -lt 40 -and \$null -eq \$.*NodeForm' -or + $uiaLiveGateSource -match '(?s)Open Folder command was rejected.*?Start-Sleep -Milliseconds 200\s*[\r\n]+\s*Require \(\[GraphCodeUiaGateState\]::PostCommand\(\$shellWindow, 4602\)\)') { + throw "RED: UIA live gate reintroduced short fixed polling around New Loop modal commands" + } + if ($uiaLiveGateSource -notmatch 'function Ensure-ShellForeground' -or + $uiaLiveGateSource -notmatch '\[GraphCodeUiaGateState\]::ActivateWindow\(\$window\)\s*[\r\n]+\s*\$acquired = \$activated -and \[GraphCodeUiaGateState\]::IsForegroundWindow\(\$window\)' -or + $uiaLiveGateSource -notmatch 'UIA_FOREGROUND_DIAGNOSTICS phase=\$label \$\(Get-FocusDiagnostics \$window\)' -or + $uiaLiveGateSource -notmatch '(?s)function Ensure-ShellForeground\(.*?if \(\[GraphCodeUiaGateState\]::IsForegroundWindow\(\$window\)\) \{\s*[\r\n]+\s*return \$true\s*[\r\n]+\s*\}') { + throw "RED: UIA live gate does not reacquire GraphCode shell foreground within a bounded deadline before command paths, or performs the invasive Alt-tap activation even when already foreground" + } + if ($uiaLiveGateSource -notmatch '(?s)function Wait-ForDesktopElement\(.*?\[switch\] \$RecoverForeground.*?\$remainingMilliseconds = \[Math\]::Max\(.*?\$recoveryTimeout = \[Math\]::Min\(1000, \$remainingMilliseconds\).*?UIA_WAIT_FOREGROUND_RECOVERY label=\$label.*?Ensure-ShellForeground.*?-TimeoutMilliseconds \$recoveryTimeout' -or + $uiaLiveGateSource -notmatch 'UIA_WAIT_DIAGNOSTICS label=\$label foregroundRecoveries=\$foregroundRecoveries' -or + $uiaLiveGateSource -notmatch '(?s)-label "project-row New Loop node form".*?-RecoverForeground' -or + $uiaLiveGateSource -notmatch '(?s)-label "empty global New Loop node form".*?-RecoverForeground' -or + $uiaLiveGateSource -notmatch '(?s)-label "empty project New Loop node form".*?-RecoverForeground') { + throw "RED: UIA modal waits do not boundedly reacquire foreground after a post-command foreground loss" + } + if ($uiaLiveGateSource -notmatch '(?s)Require \(\$null -ne \$projectNewLoop\) "project row omitted New Loop"\s*[\r\n]+\s*Require \(Ensure-ShellForeground \$shellWindow "project-row New Loop"\)\s*`\s*[\r\n]+\s*"GraphCode shell did not reacquire foreground before invoking project-row New Loop"\s*[\r\n]+\s*\$projectNewLoop\.GetCurrentPattern' -or + $uiaLiveGateSource -notmatch '(?s)Require \(Ensure-ShellForeground \$shellWindow "empty global New Loop"\)\s*`\s*[\r\n]+\s*"GraphCode shell did not reacquire foreground before empty global New Loop command"\s*[\r\n]+\s*Require \(\[GraphCodeUiaGateState\]::PostCommand\(\$shellWindow, 4602\)\)\s*`\s*[\r\n]+\s*"empty global New Loop command was rejected"' -or + $uiaLiveGateSource -notmatch '(?s)Require \(Ensure-ShellForeground \$shellWindow "empty project New Loop"\)\s*`\s*[\r\n]+\s*"GraphCode shell did not reacquire foreground before empty project New Loop command"\s*[\r\n]+\s*Require \(\[GraphCodeUiaGateState\]::PostCommand\(\$shellWindow, 4602\)\)\s*`\s*[\r\n]+\s*"empty project New Loop command was rejected"') { + throw "RED: UIA live gate New Loop invocation is not preceded by verified foreground recovery at every site" + } + $shellTests = Get-Content (Join-Path $PSScriptRoot "WindowsShell.Tests.ps1") -Raw + if ($uiaLiveGateSource -notmatch 'function Wait-ForPopupMenu' -or + $uiaLiveGateSource -notmatch 'function Get-PopupMenuItems' -or + $uiaLiveGateSource -notmatch 'function Close-PopupMenu' -or + $uiaLiveGateSource -notmatch 'FindPopupMenuWindow' -or + $uiaLiveGateSource -notmatch 'SendMessage\(popup, 0x01E1, UIntPtr\.Zero, IntPtr\.Zero\)' -or + $uiaLiveGateSource -notmatch 'PostMessage\(window, 0x802C, \(UIntPtr\)target, IntPtr\.Zero\)') { + throw "RED: UIA live gate cannot open, read, or dismiss a native TrackPopupMenu popup" + } + if ($uiaLiveGateSource -notmatch '\$moveProjectMenuText = "Move Project\.\.\. \(unavailable: daemon support required\)"' -or + $uiaLiveGateSource -notmatch '(?s)PostContextMenu\(\$shellWindow, 1\).*?Wait-ForPopupMenu \$process \$shellWindow "project"' -or + $uiaLiveGateSource -notmatch 'Require \(-not \$moveProjectItem\.Enabled\)' -or + $uiaLiveGateSource -notmatch '\$moveProjectItem\.Text -eq \$moveProjectMenuText' -or + $uiaLiveGateSource -notmatch '(?s)PostContextMenu\(\$shellWindow, 2\).*?\$_\.Id -in @\(5149, 5151, 5144\)' -or + $uiaLiveGateSource -notmatch 'project context menu did not dismiss, leaving the shell blocked in its modal loop') { + throw "RED: UIA live gate does not assert the live project context menu's disabled Move item and deterministic dismissal" + } + if ($shellTests -notmatch '(?s)Context menu and gate fixture message executable tests.*?zig test src\\GraphContextMenu\.zig' -or + $shellTests -notmatch '(?s)Context menu and gate fixture message executable tests.*?zig test src\\MainWindow\.zig') { + throw "RED: Windows shell validation does not run the context menu and gate fixture message tests" + } + $appSource = Get-Content (Join-Path $repoRoot "graphcode-windows\src\App.zig") -Raw + if ($appSource -notmatch 'fn showUiaContextMenu' -or + $appSource -notmatch 'MainWindow\.wm_uia_context_menu => \{' -or + $appSource -notmatch '(?s)wparam == MainWindow\.menu_watchdog_timer_id.*?c\.EndMenu\(\)') { + throw "RED: the shell cannot open a gate-requested context menu, or an abandoned popup can block its message loop forever" + } if ($uiaLiveGateSource -notmatch 'UIA_CANVAS_CONTEXT_MENU_EVIDENCE' -or $uiaLiveGateSource -notmatch '\$canvasContextMenuEvidence' -or $uiaLiveGateSource -notmatch 'canvasContextMenu = \$canvasContextMenuEvidence' -or @@ -3931,74 +3931,74 @@ Start-Sleep -Seconds 60 ($twoRemainders[0] -join ',') -ne '715,721,763,728') { throw "RED: node sheet rectangle subtraction does not handle more than one covering control" } - if ($shellTests -notmatch '(?s)Windows update feed executable tests.*?zig test src\\WindowsUpdates\.zig.*?-lwinhttp') { - throw "RED: Windows shell validation does not run the native updater tests" - } - if ($runnerSource -notmatch '\$SkipWslRemoteE2E' -or - $runnerSource -notmatch '"--skip-local-wsl"') { - throw "RED: hosted validation cannot explicitly isolate unavailable local WSL fixtures" - } - $privacyRaceSource = Get-Content ` - (Join-Path $repoRoot "Tools\windows\Tests\RemoteBridgePrivacyRace.Tests.ps1") -Raw - if ($privacyRaceSource -notmatch '\$AvailableProcessorCount = \[Environment\]::ProcessorCount' -or - $privacyRaceSource -notmatch '\$remoteProcessCount = 1' -or - $privacyRaceSource -notmatch '\[Math\]::Min\(24, \[Math\]::Max\(4, \$processorCount \* 2\)\)' -or - $privacyRaceSource -notmatch 'GRAPHCODE_REMOTE_BRIDGE_TEST_TIMEOUT_MULTIPLIER = "3"') { - throw "RED: remote bridge privacy race does not scale bounded concurrency to runner capacity" - } - if ($windowsWorkflow -notmatch "(?s)environment:.*Hardening\.Tests\.ps1 -Environment -SchemaOnly") { - throw "RED: environment CI does not invoke the exact schema-only hardening contract" - } - $macWorkflow = Get-Content (Join-Path $repoRoot ".github\workflows\macos-shared-regression.yml") -Raw - if ($macWorkflow -notmatch "brew install mise" -or - $macWorkflow -notmatch "mise install" -or - $macWorkflow -notmatch "mise exec -- make test") { - throw "RED: macOS CI does not install and execute pinned mise.toml tools" - } - $requiredWorkflows = [ordered]@{ - "macos-shared-regression.yml" = $macWorkflow - "windows-shell.yml" = $windowsShellWorkflow - "windows-port-validation.yml" = $windowsPortWorkflow - "windows-hardening.yml" = $windowsWorkflow - } - foreach ($entry in $requiredWorkflows.GetEnumerator()) { - $trigger = [regex]::Match( - $entry.Value, - '(?ms)^ pull_request:(?.*?)(?=^[^\s#]|^ [A-Za-z_][A-Za-z0-9_-]*:|\z)') - $settings = [regex]::Replace($trigger.Groups["settings"].Value, '(?m)#.*$', '').Trim() - if (-not $trigger.Success -or $settings -notin @("", "{}")) { - throw "RED: $($entry.Key) must report required checks for every PR, including documentation-only changes" - } - } -} finally { - Remove-Item -LiteralPath $foreignJunction -Recurse -Force -ErrorAction SilentlyContinue -} - -$oldWinghosttyRoot = [Environment]::GetEnvironmentVariable( - "GRAPHCODE_WINGHOSTTY_ROOT" -) -$oldZmxRoot = [Environment]::GetEnvironmentVariable("GRAPHCODE_ZMX_ROOT") -try { - $env:GRAPHCODE_WINGHOSTTY_ROOT = Join-Path $repoRoot ` - "investigation\spikes\missing-winghostty-provider" - $env:GRAPHCODE_ZMX_ROOT = Join-Path $repoRoot ` - "investigation\spikes\missing-zmx-provider" - & $pwsh -NoProfile -File $runner -Task terminal-gate *> $null - if ($LASTEXITCODE -eq 0) { - throw "terminal-gate passed without its pinned providers" - } -} finally { - if ($null -eq $oldWinghosttyRoot) { - Remove-Item Env:GRAPHCODE_WINGHOSTTY_ROOT -ErrorAction SilentlyContinue - } else { - $env:GRAPHCODE_WINGHOSTTY_ROOT = $oldWinghosttyRoot - } - if ($null -eq $oldZmxRoot) { - Remove-Item Env:GRAPHCODE_ZMX_ROOT -ErrorAction SilentlyContinue - } else { - $env:GRAPHCODE_ZMX_ROOT = $oldZmxRoot - } -} - -Write-Host "ValidationRunner.Tests.ps1: PASS" -exit 0 + if ($shellTests -notmatch '(?s)Windows update feed executable tests.*?zig test src\\WindowsUpdates\.zig.*?-lwinhttp') { + throw "RED: Windows shell validation does not run the native updater tests" + } + if ($runnerSource -notmatch '\$SkipWslRemoteE2E' -or + $runnerSource -notmatch '"--skip-local-wsl"') { + throw "RED: hosted validation cannot explicitly isolate unavailable local WSL fixtures" + } + $privacyRaceSource = Get-Content ` + (Join-Path $repoRoot "Tools\windows\Tests\RemoteBridgePrivacyRace.Tests.ps1") -Raw + if ($privacyRaceSource -notmatch '\$AvailableProcessorCount = \[Environment\]::ProcessorCount' -or + $privacyRaceSource -notmatch '\$remoteProcessCount = 1' -or + $privacyRaceSource -notmatch '\[Math\]::Min\(24, \[Math\]::Max\(4, \$processorCount \* 2\)\)' -or + $privacyRaceSource -notmatch 'GRAPHCODE_REMOTE_BRIDGE_TEST_TIMEOUT_MULTIPLIER = "3"') { + throw "RED: remote bridge privacy race does not scale bounded concurrency to runner capacity" + } + if ($windowsWorkflow -notmatch "(?s)environment:.*Hardening\.Tests\.ps1 -Environment -SchemaOnly") { + throw "RED: environment CI does not invoke the exact schema-only hardening contract" + } + $macWorkflow = Get-Content (Join-Path $repoRoot ".github\workflows\macos-shared-regression.yml") -Raw + if ($macWorkflow -notmatch "brew install mise" -or + $macWorkflow -notmatch "mise install" -or + $macWorkflow -notmatch "mise exec -- make test") { + throw "RED: macOS CI does not install and execute pinned mise.toml tools" + } + $requiredWorkflows = [ordered]@{ + "macos-shared-regression.yml" = $macWorkflow + "windows-shell.yml" = $windowsShellWorkflow + "windows-port-validation.yml" = $windowsPortWorkflow + "windows-hardening.yml" = $windowsWorkflow + } + foreach ($entry in $requiredWorkflows.GetEnumerator()) { + $trigger = [regex]::Match( + $entry.Value, + '(?ms)^ pull_request:(?.*?)(?=^[^\s#]|^ [A-Za-z_][A-Za-z0-9_-]*:|\z)') + $settings = [regex]::Replace($trigger.Groups["settings"].Value, '(?m)#.*$', '').Trim() + if (-not $trigger.Success -or $settings -notin @("", "{}")) { + throw "RED: $($entry.Key) must report required checks for every PR, including documentation-only changes" + } + } +} finally { + Remove-Item -LiteralPath $foreignJunction -Recurse -Force -ErrorAction SilentlyContinue +} + +$oldWinghosttyRoot = [Environment]::GetEnvironmentVariable( + "GRAPHCODE_WINGHOSTTY_ROOT" +) +$oldZmxRoot = [Environment]::GetEnvironmentVariable("GRAPHCODE_ZMX_ROOT") +try { + $env:GRAPHCODE_WINGHOSTTY_ROOT = Join-Path $repoRoot ` + "investigation\spikes\missing-winghostty-provider" + $env:GRAPHCODE_ZMX_ROOT = Join-Path $repoRoot ` + "investigation\spikes\missing-zmx-provider" + & $pwsh -NoProfile -File $runner -Task terminal-gate *> $null + if ($LASTEXITCODE -eq 0) { + throw "terminal-gate passed without its pinned providers" + } +} finally { + if ($null -eq $oldWinghosttyRoot) { + Remove-Item Env:GRAPHCODE_WINGHOSTTY_ROOT -ErrorAction SilentlyContinue + } else { + $env:GRAPHCODE_WINGHOSTTY_ROOT = $oldWinghosttyRoot + } + if ($null -eq $oldZmxRoot) { + Remove-Item Env:GRAPHCODE_ZMX_ROOT -ErrorAction SilentlyContinue + } else { + $env:GRAPHCODE_ZMX_ROOT = $oldZmxRoot + } +} + +Write-Host "ValidationRunner.Tests.ps1: PASS" +exit 0 From bb35439af79c35d5c0cad49aeab25866ed9d0817 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Tue, 6 Oct 2026 16:35:48 -0700 Subject: [PATCH 08/11] Validate complete receipts for session opens and retain Git fixture errors Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../windows/Tests/ValidationRunner.Tests.ps1 | 28 +++++++++++++++ Tools/windows/uia-live-gate.ps1 | 35 +++++++++++++------ graphcode-windows/src/WorktreeStatus.zig | 17 +++++++-- 3 files changed, 67 insertions(+), 13 deletions(-) diff --git a/Tools/windows/Tests/ValidationRunner.Tests.ps1 b/Tools/windows/Tests/ValidationRunner.Tests.ps1 index b59c55f7..d87aa520 100644 --- a/Tools/windows/Tests/ValidationRunner.Tests.ps1 +++ b/Tools/windows/Tests/ValidationRunner.Tests.ps1 @@ -482,6 +482,34 @@ function Test-MultiProjectProtocolContracts([string] $stubSource, [string] $gate $receipt.report.multiProjectPeer.controls.Count -eq 1 -and $receipt.rawJson -ceq $receiptFixture) "whole actual receipt lost custody/bytes" return "Actual accepted91dc file fixture only; not claimed as95e success state or current native evidence" } + function New-MultiReceiptWithResume { + $report = New-MultiReceiptReport + $peer = $report.multiProjectPeer + $id = "dddddddd-dddd-4ddd-8ddd-dddddddddddd" + $owner = $peer.graphs[1] + $response = [ordered]@{ version = 2; kind = "response"; requestID = $id; success = $true } + $frame = [ordered]@{ version = 2; kind = "request"; requestID = $id + command = [ordered]@{ graphCommand = [ordered]@{ projectPath = $owner.project.path + command = [ordered]@{ resumeSession = [ordered]@{ _0 = $owner.nodes[0].id } } } } } + $peer.received += [ordered]@{ requestID = $id; frame = $frame; expectedResponse = $response } + $peer.answered += [ordered]@{ requestID = $id; response = $response } + $peer.receivedCount++; $peer.requestCount++; $peer.responseCount++ + $report.requestCount++; $report.responseCount++ + $report.commands += "graphCommand" + return $report + } + Invoke-MultiCase "whole peer receipt accepts a correlated nonmutating session open" { + $report = New-MultiReceiptWithResume + Assert-MultiProjectCompleteReport $report + Assert-MultiCase ($report.multiProjectPeer.applied.Count -eq 2) "session open changed rename accounting" + return "Resume acknowledgement is accounted separately from the two exact renames" + } + Invoke-MultiCase "whole peer receipt rejects a session open for a foreign owner's node" -Negative { + $report = New-MultiReceiptWithResume + $report.multiProjectPeer.received[-1].frame.command.graphCommand.command.resumeSession._0 = + $report.multiProjectPeer.graphs[0].nodes[0].id + return Reject-MultiCase { Assert-MultiProjectCompleteReport $report } "MULTIPROJECT_PEER_RECEIPT:" + } foreach ($mutation in @("missing-top", "unknown-top", "disconnected", "correlation-false", "global-error", "global-unanswered", "root-count", "connection-type", "graph-not-sent", "missing-peer", "unknown-peer", "count-type", "count-missing", "unanswered", "empty-requests", "request-duplicate", "request-id", "request-kind-type", "request-extra", diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index bb605465..51974d38 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -2796,14 +2796,25 @@ function Assert-MultiProjectCompleteReport($report) { if ($verb -ceq "graphCommand") { $command = $frame.command.graphCommand Assert-MultiProjectReceiptObject $command @("projectPath","command") "graph command" - Assert-MultiProjectReceiptObject $command.command @("renameNode") "inner command" - Assert-MultiProjectReceiptObject $command.command.renameNode @("_0","title") "rename" - Assert-MultiProjectReceiptId $command.command.renameNode._0 - if ($command.projectPath -isnot [string] -or -not $owners.ContainsKey($command.projectPath) -or - $command.projectPath -cne $peer.graphs[0].project.path -or - $command.command.renameNode._0 -cne $owners[$command.projectPath].nodes[0].id -or - $command.command.renameNode.title -isnot [string] -or [string]::IsNullOrWhiteSpace($command.command.renameNode.title)) { - throw "MULTIPROJECT_PEER_RECEIPT: actual rename owner/value mismatch" + if ($command.command -is [Collections.IDictionary] -and + @($command.command.Keys) -ccontains "resumeSession") { + Assert-MultiProjectReceiptObject $command.command @("resumeSession") "inner command" + Assert-MultiProjectReceiptObject $command.command.resumeSession @("_0") "session open" + Assert-MultiProjectReceiptId $command.command.resumeSession._0 + if ($command.projectPath -isnot [string] -or -not $owners.ContainsKey($command.projectPath) -or + $command.command.resumeSession._0 -cne $owners[$command.projectPath].nodes[0].id) { + throw "MULTIPROJECT_PEER_RECEIPT: actual session-open owner mismatch" + } + } else { + Assert-MultiProjectReceiptObject $command.command @("renameNode") "inner command" + Assert-MultiProjectReceiptObject $command.command.renameNode @("_0","title") "rename" + Assert-MultiProjectReceiptId $command.command.renameNode._0 + if ($command.projectPath -isnot [string] -or -not $owners.ContainsKey($command.projectPath) -or + $command.projectPath -cne $peer.graphs[0].project.path -or + $command.command.renameNode._0 -cne $owners[$command.projectPath].nodes[0].id -or + $command.command.renameNode.title -isnot [string] -or [string]::IsNullOrWhiteSpace($command.command.renameNode.title)) { + throw "MULTIPROJECT_PEER_RECEIPT: actual rename owner/value mismatch" + } } } elseif ($verb -ceq "openProject") { Assert-MultiProjectReceiptObject $frame.command.openProject @("path") "openProject" @@ -2869,13 +2880,17 @@ function Assert-MultiProjectCompleteReport($report) { if ($application.requestID -isnot [string] -or -not $requests.ContainsKey($application.requestID) -or $applied.ContainsKey($application.requestID)) { throw "MULTIPROJECT_PEER_RECEIPT: application correlation missing/duplicated" } $wire = $requests[$application.requestID].frame.command.graphCommand - if ($null -eq $wire -or $application.projectPath -cne $wire.projectPath -or + if ($null -eq $wire -or -not $wire.command.Contains("renameNode") -or + $application.projectPath -cne $wire.projectPath -or $application.projectPath -isnot [string] -or $application.nodeID -isnot [string] -or $application.title -isnot [string] -or $application.nodeID -cne $wire.command.renameNode._0 -or $application.title -cne $wire.command.renameNode.title -or $application.beforeTitle -isnot [string]) { throw "MULTIPROJECT_PEER_RECEIPT: actual application differs from received wire" } $applied.Add($application.requestID,$application) } - if (@($peer.received | Where-Object { $_.frame.command.Contains("graphCommand") }).Count -ne 2) { + if (@($peer.received | Where-Object { + $_.frame.command.Contains("graphCommand") -and + $_.frame.command.graphCommand.command.Contains("renameNode") + }).Count -ne 2) { throw "MULTIPROJECT_PEER_RECEIPT: received/applied rename count differs" } $control = $peer.controls[0] diff --git a/graphcode-windows/src/WorktreeStatus.zig b/graphcode-windows/src/WorktreeStatus.zig index fb7a63d0..fcdabe42 100644 --- a/graphcode-windows/src/WorktreeStatus.zig +++ b/graphcode-windows/src/WorktreeStatus.zig @@ -1791,9 +1791,20 @@ pub const SubprocessTests = if (@import("builtin").is_test) struct { child.env_map = &environment; child.create_no_window = true; child.stdin_behavior = .Ignore; - child.stdout_behavior = .Ignore; - child.stderr_behavior = .Ignore; - const term = try child.spawnAndWait(); + child.stdout_behavior = .Pipe; + child.stderr_behavior = .Pipe; + var output: std.ArrayList(u8) = .empty; + defer output.deinit(allocator); + var errors: std.ArrayList(u8) = .empty; + defer errors.deinit(allocator); + try child.spawn(); + try child.collectOutput(allocator, &output, &errors, 64 * 1024); + const term = try child.wait(); + if (term != .Exited or term.Exited != 0) { + std.debug.print("fixture Git failed: {any}\nstdout: {s}\nstderr: {s}\n", .{ + term, output.items, errors.items, + }); + } try std.testing.expect(term == .Exited and term.Exited == 0); } } else void; From c873d268b972bdf7e9e1b03c97300d73e3e67bde Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Tue, 6 Oct 2026 16:48:33 -0700 Subject: [PATCH 09/11] Use the console-safe zmx helper for session listing capture Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Tools/windows/Tests/WindowsShell.Tests.ps1 | 4 ++-- graphcode-windows/src/TerminalSurface.zig | 12 ++++++------ graphcode-windows/src/ZmxSession.zig | 11 ++++++++++- 3 files changed, 18 insertions(+), 9 deletions(-) diff --git a/Tools/windows/Tests/WindowsShell.Tests.ps1 b/Tools/windows/Tests/WindowsShell.Tests.ps1 index ae5b0d71..91c03cc9 100644 --- a/Tools/windows/Tests/WindowsShell.Tests.ps1 +++ b/Tools/windows/Tests/WindowsShell.Tests.ps1 @@ -637,9 +637,9 @@ Assert-Contract ($zmxSessionSource -match '(?s)pub fn child\(.*?\.create_no_wind "zmx children must be created without a console window" Assert-Contract ($terminalSurfaceSource -notmatch 'std\.process\.Child\.init\(' -and $workspaceTeardownSource -notmatch 'std\.process\.Child\.init\(' -and - [regex]::Matches($terminalSurfaceSource, 'ZmxSession\.child\(').Count -eq 2 -and + [regex]::Matches($terminalSurfaceSource, 'ZmxSession\.child\(').Count -eq 3 -and [regex]::Matches($workspaceTeardownSource, 'ZmxSession\.child\(').Count -eq 1) ` - "zmx attach, resize, and kill must spawn through ZmxSession.child so the GUI shell never opens a console window" + "zmx attach, listing, resize, and kill must spawn through ZmxSession.child so the GUI shell never opens a console window" $zig = Resolve-TestZig Invoke-Native "Accessibility contract executable tests" { diff --git a/graphcode-windows/src/TerminalSurface.zig b/graphcode-windows/src/TerminalSurface.zig index e9d80079..df7ad917 100644 --- a/graphcode-windows/src/TerminalSurface.zig +++ b/graphcode-windows/src/TerminalSurface.zig @@ -698,12 +698,12 @@ pub const Workspace = struct { fn spawnListing(self: *Workspace) ?std.process.Child { var args: [2][]const u8 = undefined; - var child = std.process.Child.init(LoopLaunchWait.probeArguments(self.zmx_path, &args), self.allocator); - child.cwd = self.cwd; - child.stdin_behavior = .Ignore; - child.stdout_behavior = .Pipe; - child.stderr_behavior = .Ignore; - child.create_no_window = true; + var child = ZmxSession.child( + self.allocator, + LoopLaunchWait.probeArguments(self.zmx_path, &args), + self.cwd, + .capture, + ); child.spawn() catch { self.setInputError("Unable to start loop session listing"); return null; diff --git a/graphcode-windows/src/ZmxSession.zig b/graphcode-windows/src/ZmxSession.zig index 154d99ef..2f52bf5e 100644 --- a/graphcode-windows/src/ZmxSession.zig +++ b/graphcode-windows/src/ZmxSession.zig @@ -17,6 +17,8 @@ pub const ChildStdio = enum { attach, /// One-shot control commands such as `resize` and `kill`. control, + /// One-shot listings whose output is inspected before attaching. + capture, }; /// zmx.exe is a console-subsystem program. Spawned from the GUI shell without @@ -31,7 +33,7 @@ pub fn child( var result = std.process.Child.init(argv, allocator); result.cwd = cwd; result.stdin_behavior = if (stdio == .attach) .Pipe else .Ignore; - result.stdout_behavior = if (stdio == .attach) .Pipe else .Ignore; + result.stdout_behavior = if (stdio == .attach or stdio == .capture) .Pipe else .Ignore; result.stderr_behavior = .Ignore; result.create_no_window = true; return result; @@ -64,3 +66,10 @@ test "canonical zmx session names add the ownership prefix exactly once" { try nameBuffer("graphcode-alpha", &storage), ); } + +test "zmx listing capture has readable output without stdin or a console" { + const listing = child(std.testing.allocator, &.{ "zmx.exe", "ls" }, null, .capture); + try std.testing.expectEqual(std.process.Child.StdIo.Ignore, listing.stdin_behavior); + try std.testing.expectEqual(std.process.Child.StdIo.Pipe, listing.stdout_behavior); + try std.testing.expect(listing.create_no_window); +} From 861a653ff703c5bc92df7c1ded6fb15a0561984b Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Tue, 6 Oct 2026 16:57:06 -0700 Subject: [PATCH 10/11] Use the harness config file instead of NUL for Git fixture setup Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- graphcode-windows/src/WorktreeStatus.zig | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/graphcode-windows/src/WorktreeStatus.zig b/graphcode-windows/src/WorktreeStatus.zig index fcdabe42..177ae7d5 100644 --- a/graphcode-windows/src/WorktreeStatus.zig +++ b/graphcode-windows/src/WorktreeStatus.zig @@ -1562,6 +1562,7 @@ pub const SubprocessTests = if (@import("builtin").is_test) struct { try std.fs.cwd().makeDir(target); try std.fs.cwd().makeDir(outside); try fixtureGit(&.{ "git", "-C", target, "init", "-q", "-b", "main" }); + try fixtureGit(&.{ "git", "-C", target, "config", "--global", "--get", "graphcode.sentinel" }); try fixtureGit(&.{ "git", "-C", outside, "init", "-q", "-b", "outside" }); try fixtureGit(&.{ "git", "-C", target, "config", "graphcode.identity", "target" }); try fixtureGit(&.{ "git", "-C", outside, "config", "graphcode.identity", "outside-control" }); @@ -1781,7 +1782,10 @@ pub const SubprocessTests = if (@import("builtin").is_test) struct { try environment.put(entry.key_ptr.*, entry.value_ptr.*); } try environment.put("GIT_CONFIG_NOSYSTEM", "1"); - try environment.put("GIT_CONFIG_GLOBAL", "NUL"); + // The harness supplies an isolated config file. Newer Git builds reject the + // Windows NUL device as a config path before fixture initialization can run. + const fixture_config = inherited.get("GIT_CONFIG_GLOBAL") orelse return error.FixtureGitConfigMissing; + try environment.put("GIT_CONFIG_GLOBAL", fixture_config); const real_git = try std.process.getEnvVarOwned(allocator, "GRAPHCODE_WORKTREE_TEST_REAL_GIT"); defer allocator.free(real_git); const fixture_args = try allocator.dupe([]const u8, args); From e88f4146f2b4a015df50d9bc69cd67d78c7075d4 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Tue, 6 Oct 2026 17:24:01 -0700 Subject: [PATCH 11/11] Wait for the owned daemon listener before warm-restart assertions Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Tools/windows/Tests/DaemonRoundTrip.Live.Tests.ps1 | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Tools/windows/Tests/DaemonRoundTrip.Live.Tests.ps1 b/Tools/windows/Tests/DaemonRoundTrip.Live.Tests.ps1 index e6e44edb..ef70275a 100644 --- a/Tools/windows/Tests/DaemonRoundTrip.Live.Tests.ps1 +++ b/Tools/windows/Tests/DaemonRoundTrip.Live.Tests.ps1 @@ -124,6 +124,18 @@ function Start-OwnedDaemon { if (@(Get-OwnedDaemon $process.Id).Count -eq 1) { $script:daemonStdout = $stdout $script:daemonStderr = $stderr + # A PID exists before the lifetime mutex and listener are initialized. This is + # especially observable on restart, when the Zig test executable is already built. + $readyPipe = [IO.Pipes.NamedPipeClientStream]::new(".", $daemonPipe.Substring(9), + [IO.Pipes.PipeDirection]::InOut) + try { + $readyPipe.Connect(15000) + } catch { + if (-not $process.HasExited) { $process.Kill(); [void]$process.WaitForExit(5000) } + throw "Owned daemon did not publish its listener before the round-trip test: $($_.Exception.Message)" + } finally { + $readyPipe.Dispose() + } return $process } Start-Sleep -Milliseconds 100