diff --git a/.github/workflows/windows-port-validation.yml b/.github/workflows/windows-port-validation.yml index 38bede48f..c3823395f 100644 --- a/.github/workflows/windows-port-validation.yml +++ b/.github/workflows/windows-port-validation.yml @@ -255,6 +255,16 @@ jobs: timeout-minutes: 35 run: ./Tools/windows/validate.ps1 -Task swift-portable,swift-contracts,swift-production,swift-paths,swift-process,swift-named-pipe,swift-format + - name: Prefetch verified official gobject package + shell: pwsh + timeout-minutes: 5 + env: + GH_TOKEN: ${{ github.token }} + run: | + . .\.ci-tools\environment.ps1 + $env:GRAPHCODE_VALIDATION_ASSEMBLY = & .\Tools\windows\Build-CompiledValidationRunner.ps1 + ./Tools/windows/Initialize-GobjectPackageCache.ps1 -PublishJobEnvironment + # The swift-production run above skips the real-zmx test (no zmx.exe). This step # builds the pinned zmx and fails unless that test executed and passed. - name: Run real zmx empty-list test (required, not skippable) @@ -264,6 +274,17 @@ jobs: $env:GRAPHCODE_ZMX_ROOT = (Resolve-Path .ci-providers/zmx).Path $env:GRAPHCODE_ZIG0160 = (Resolve-Path .ci-tools/zig-0.16.0/zig.exe).Path ./Tools/windows/Test-RealZmxEmptyList.ps1 + + - name: Retain official package verification evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + timeout-minutes: 5 + with: + name: gobject-package-verification-${{ github.run_attempt }} + path: ${{ runner.temp }}\gobject-prefetch-*\** + include-hidden-files: true + if-no-files-found: warn + retention-days: 7 # Everything else in -Task all: remote bridge and E2E, visual baseline, TDD # evidence, privacy, and the pinned terminal gate. spikes-other: @@ -311,12 +332,14 @@ jobs: run: ./Tools/windows/validate.ps1 -Task all -SkipTrayLive -SkipWslRemoteE2E -SkipTask swift-portable,swift-contracts,swift-production,swift-paths,swift-process,swift-named-pipe,swift-format,windows-shell,packaging,hardening - name: Retain UIA failure diagnostics - if: failure() + if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 timeout-minutes: 5 with: name: uia-live-gate-diagnostics - path: ${{ runner.temp }}\gu-*\logs\*.json + path: | + ${{ runner.temp }}\gu-*\logs\*.json + ${{ runner.temp }}\terminal-owned-*\**\* if-no-files-found: warn retention-days: 7 diff --git a/.github/workflows/windows-release.yml b/.github/workflows/windows-release.yml index de78ba87f..27780aa32 100644 --- a/.github/workflows/windows-release.yml +++ b/.github/workflows/windows-release.yml @@ -42,6 +42,16 @@ jobs: shell: pwsh run: ./Tools/windows/bootstrap.ps1 -ToolRoot .ci-tools -ProviderRoot .ci-providers + - name: Prefetch verified official gobject package for zmx + shell: pwsh + timeout-minutes: 5 + env: + GH_TOKEN: ${{ github.token }} + run: | + . .\.ci-tools\environment.ps1 + $env:GRAPHCODE_VALIDATION_ASSEMBLY = & .\Tools\windows\Build-CompiledValidationRunner.ps1 + ./Tools/windows/Initialize-GobjectPackageCache.ps1 -PublishJobEnvironment + - name: Stage the Swift daemon and CLI for packaging shell: pwsh run: ./Tools/windows/stage-swift-products.ps1 @@ -71,3 +81,14 @@ jobs: name: graphcode-windows-x86_64 path: .build/windows/release-publish/publish/ if-no-files-found: warn + + - name: Retain official package verification evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + timeout-minutes: 5 + with: + name: gobject-package-verification-${{ github.run_attempt }} + path: ${{ runner.temp }}\gobject-prefetch-*\** + include-hidden-files: true + if-no-files-found: warn + retention-days: 7 diff --git a/Tools/windows/Initialize-GobjectPackageCache.ps1 b/Tools/windows/Initialize-GobjectPackageCache.ps1 new file mode 100644 index 000000000..017d3f393 --- /dev/null +++ b/Tools/windows/Initialize-GobjectPackageCache.ps1 @@ -0,0 +1,201 @@ +[CmdletBinding()] +param( + [string] $Zig0160 = $env:GRAPHCODE_ZIG0160, + [string] $ArtifactParent = $env:RUNNER_TEMP, + [switch] $PublishJobEnvironment +) + +$ErrorActionPreference = "Stop" + +function Assert-GobjectArchiveProof([long] $Bytes, [string] $Sha256) { + if ($Bytes -ne 1867503 -or + $Sha256 -cne "D572886E8672A46AC6C740E3FE3A1F1C9D9A1A9F09D59FD62328A41FCDB70196") { + throw "Official gobject asset size or SHA-256 mismatch; package fetch was not authorized." + } +} + +function Assert-GobjectFetchProof($Result) { + $expected = "gobject-0.3.2-Skun7F6HogCMynX2JqeSHS7xr-8pK4ob-qRFIcEasVi3" + if ($Result.ExitCode -ne 0 -or $Result.StandardOutput -cnotin @("$expected`n", "$expected`r`n") -or + $Result.StandardError -cne "" -or -not $Result.Cleanup.CanRelease -or + -not $Result.Cleanup.ResourcesClosed) { + throw "Local gobject fetch did not return exactly the pinned package hash with complete owned cleanup." + } +} + +function New-GobjectFetchStart([string] $Executable, [string] $Project, + [string] $Cache, [string] $Archive, [string] $Temp) { + $start = [Diagnostics.ProcessStartInfo]::new($Executable) + $start.UseShellExecute = $false + $start.CreateNoWindow = $true + $start.RedirectStandardOutput = $true + $start.RedirectStandardError = $true + $start.WorkingDirectory = $Project + $start.Environment["TEMP"] = $Temp + $start.Environment["TMP"] = $Temp + $start.Environment["ZIG_GLOBAL_CACHE_DIR"] = $Cache + $null = $start.Environment.Remove("ZIG_LOCAL_CACHE_DIR") + $null = $start.Environment.Remove("GH_TOKEN") + $null = $start.Environment.Remove("GITHUB_TOKEN") + foreach ($argument in @("fetch", "--global-cache-dir", $Cache, $Archive)) { + $start.ArgumentList.Add($argument) + } + return $start +} + +function Save-OfficialGobjectArchive([string] $Path, [string] $Token) { + if ([string]::IsNullOrWhiteSpace($Token) -or $Token -match '\s') { + throw "Official asset API requires the existing job token without whitespace." + } + $client = [Net.Http.HttpClient]::new() + $client.Timeout = [Threading.Timeout]::InfiniteTimeSpan + $deadline = [Threading.CancellationTokenSource]::new([TimeSpan]::FromSeconds(60)) + $request = [Net.Http.HttpRequestMessage]::new( + [Net.Http.HttpMethod]::Get, + "https://api.github.com/repos/ghostty-org/zig-gobject/releases/assets/493157198") + $response = $null + $input = $null + $output = $null + $primary = $null + try { + $request.Headers.UserAgent.ParseAdd("GraphCode-pinned-gobject-prefetch") + $request.Headers.Accept.ParseAdd("application/octet-stream") + $request.Headers.Authorization = [Net.Http.Headers.AuthenticationHeaderValue]::new("Bearer", $Token) + $response = $client.SendAsync($request, [Net.Http.HttpCompletionOption]::ResponseHeadersRead, + $deadline.Token).GetAwaiter().GetResult() + [IO.File]::WriteAllText("$Path.http-status.json", + (@{ statusCode = [int]$response.StatusCode; assetId = 493157198 } | ConvertTo-Json), + [Text.UTF8Encoding]::new($false)) + $null = $response.EnsureSuccessStatusCode() + $input = $response.Content.ReadAsStreamAsync($deadline.Token).GetAwaiter().GetResult() + $output = [IO.File]::Open($Path, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::Read) + $buffer = [byte[]]::new(16384) + $total = 0L + while (($read = $input.ReadAsync($buffer, 0, $buffer.Length, $deadline.Token). + GetAwaiter().GetResult()) -gt 0) { + $output.Write($buffer, 0, $read) + $total += $read + if ($total -gt 1867503) { throw "Official asset exceeds the exact pinned byte count." } + } + $output.Flush($true) + } catch { + $primary = $_.Exception + throw + } finally { + $cleanupErrors = [Collections.Generic.List[Exception]]::new() + foreach ($resource in @($output, $input, $response, $request, $deadline, $client)) { + if ($null -ne $resource) { + try { $resource.Dispose() } catch { $cleanupErrors.Add($_.Exception) } + } + } + if ($cleanupErrors.Count -gt 0) { + $cleanupFailure = [AggregateException]::new("Official asset transport cleanup failed.", $cleanupErrors) + if ($primary) { $primary.Data["GobjectTransportCleanupFailure"] = $cleanupFailure } else { throw $cleanupFailure } + } + } +} + +if ($MyInvocation.InvocationName -eq ".") { return } + +$assembly = $env:GRAPHCODE_VALIDATION_ASSEMBLY +if ([string]::IsNullOrWhiteSpace($assembly)) { + throw "Official prefetch requires the existing source-bound compiled ownership bindings." +} +& (Join-Path $PSScriptRoot "Import-CompiledValidationAssembly.ps1") ` + -AssemblyPath $assembly -RequiredType "GraphCode.Validation.Ownership.OwnedChildSupervisor" | Out-Null +& (Join-Path $PSScriptRoot "Import-CompiledValidationAssembly.ps1") ` + -AssemblyPath (Join-Path (Split-Path $assembly) "ValidationFixtureBindings.dll") ` + -RequiredType "GraphCode.ValidationFixtureDirectoryNative" | Out-Null +if ([string]::IsNullOrWhiteSpace($ArtifactParent) -or + $ArtifactParent.Contains("`r") -or $ArtifactParent.Contains("`n") -or + -not [IO.Path]::IsPathFullyQualified($ArtifactParent) -or + -not (Test-Path -LiteralPath $ArtifactParent -PathType Container) -or + [string]::IsNullOrWhiteSpace($Zig0160) -or -not [IO.Path]::IsPathFullyQualified($Zig0160) -or + -not (Test-Path -LiteralPath $Zig0160 -PathType Leaf)) { + throw "Official prefetch requires an existing absolute job temp parent and pinned Zig executable." +} +if ($PublishJobEnvironment -and ( + [string]::IsNullOrWhiteSpace($env:GITHUB_ENV) -or + -not [IO.Path]::IsPathFullyQualified($env:GITHUB_ENV) -or + -not (Test-Path -LiteralPath $env:GITHUB_ENV -PathType Leaf))) { + throw "Package cache publication requires the existing job environment file." +} +$root = Join-Path $ArtifactParent ("gobject-prefetch-" + [guid]::NewGuid().ToString("N")) +[GraphCode.ValidationFixtureDirectoryNative]::CreateOwnedByCurrentUser($root) +foreach ($name in @("project", "global", "temp", "logs")) { + [GraphCode.ValidationFixtureDirectoryNative]::CreateOwnedByCurrentUser((Join-Path $root $name)) +} +Write-Host "GOBJECT_PREFETCH_ARTIFACT_ROOT=$root" +$project = Join-Path $root "project" +$cache = Join-Path $root "global" +$marker = Join-Path $project "build.zig" +$file = [IO.File]::Open($marker, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None) +$file.Dispose() +$archive = Join-Path $root "official-gobject.tar.gz" +$version = $null +$result = $null +$primary = $null +try { + $versionStart = New-GobjectFetchStart $Zig0160 $project $cache $archive (Join-Path $root "temp") + $versionStart.ArgumentList.Clear() + $versionStart.ArgumentList.Add("version") + $version = [GraphCode.Validation.Ownership.OwnedChildSupervisor]::RunAsync( + $versionStart, [TimeSpan]::FromSeconds(10), (Join-Path $root "logs")).GetAwaiter().GetResult() + if ($version.ExitCode -ne 0 -or $version.StandardOutput -cnotin @("0.16.0`n", "0.16.0`r`n") -or + $version.StandardError -cne "" -or -not $version.Cleanup.CanRelease -or + -not $version.Cleanup.ResourcesClosed) { throw "Prefetch requires exactly pinned Zig 0.16.0." } + Save-OfficialGobjectArchive $archive $env:GH_TOKEN + $archiveInfo = Get-Item -LiteralPath $archive + $archiveHash = (Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash + Assert-GobjectArchiveProof $archiveInfo.Length $archiveHash + $start = New-GobjectFetchStart $Zig0160 $project $cache $archive (Join-Path $root "temp") + $before = [ordered]@{ + fileName = $start.FileName; executableSha256 = (Get-FileHash -LiteralPath $Zig0160 -Algorithm SHA256).Hash + argumentList = @($start.ArgumentList); workingDirectory = $start.WorkingDirectory + markerBytes = (Get-Item -LiteralPath $marker).Length + archiveBytes = $archiveInfo.Length; archiveSha256 = $archiveHash + globalCache = $cache; timeoutSeconds = 120; recursive = $false; save = $false + } + if ($before.markerBytes -ne 0) { throw "The private fetch context marker must remain empty." } + $before | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath (Join-Path $root "before-fetch.json") -Encoding utf8 + $result = [GraphCode.Validation.Ownership.OwnedChildSupervisor]::RunAsync( + $start, [TimeSpan]::FromSeconds(120), (Join-Path $root "logs")).GetAwaiter().GetResult() + Assert-GobjectFetchProof $result + $cachedArchive = Join-Path $cache "p\gobject-0.3.2-Skun7F6HogCMynX2JqeSHS7xr-8pK4ob-qRFIcEasVi3.tar.gz" + if (-not (Test-Path -LiteralPath $cachedArchive -PathType Leaf) -or + (Get-Item -LiteralPath $cachedArchive).Length -le 0 -or + (Get-Item -LiteralPath $marker).Length -ne 0 -or + (Test-Path -LiteralPath (Join-Path $project "build.zig.zon"))) { + throw "Verified fetch did not retain its native global archive or changed the empty project context." + } + if ($PublishJobEnvironment) { + [IO.File]::AppendAllText($env:GITHUB_ENV, "ZIG_GLOBAL_CACHE_DIR=$cache`n", [Text.UTF8Encoding]::new($false)) + } + $env:ZIG_GLOBAL_CACHE_DIR = $cache +} catch { + $primary = $_ + throw +} finally { + try { + [ordered]@{ + version = $version; result = $result + failure = if ($primary) { $primary.Exception.Message } else { $null } + globalCache = $cache; markerBytes = (Get-Item -LiteralPath $marker).Length + files = @(Get-ChildItem -LiteralPath $root -Recurse -File | ForEach-Object { + @{ path = $_.FullName.Substring($root.Length + 1); bytes = $_.Length + sha256 = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash } + }) + } | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath (Join-Path $root "receipt.json") -Encoding utf8 + } catch { + if ($primary) { + $primary.Exception.Data["GobjectPrefetchReceiptFailure"] = $_.Exception + } else { throw } + } finally { + if ([GraphCode.Validation.Ownership.OwnedChildSupervisor]::RequiresLifetimeHold) { + [Console]::Out.WriteLine("GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven") + [Console]::Out.Flush() + $null = [GraphCode.Validation.Ownership.OwnedChildSupervisor]::HoldRetainedResourcesAsync().GetAwaiter().GetResult() + } + } +} +Write-Host "GOBJECT_PACKAGE_HASH_VERIFIED=gobject-0.3.2-Skun7F6HogCMynX2JqeSHS7xr-8pK4ob-qRFIcEasVi3" diff --git a/Tools/windows/Tests/Release.Tests.ps1 b/Tools/windows/Tests/Release.Tests.ps1 index 92c924685..4bbc99e24 100644 --- a/Tools/windows/Tests/Release.Tests.ps1 +++ b/Tools/windows/Tests/Release.Tests.ps1 @@ -366,6 +366,61 @@ exit 2 if (-not $publishDefault.Success -or $publishDefault.Groups[1].Value -notmatch "^(false|'false'|`"false`")$") { throw "the publishing input no longer defaults to false" } + $normalizedWorkflow = $workflow -replace "\r\n", "`n" + $helperVariants = 0 + foreach ($variant in @($normalizedWorkflow, ($normalizedWorkflow -replace "`n", "`r`n"))) { + $source = $variant -replace "\r\n", "`n" + $steps = [regex]::Split($source, '(?m)(?=^ - (?:name|uses):)') + $prefetch = @($steps | Where-Object { $_ -match '^ - name: Prefetch verified official gobject package for zmx\n' }) + $retention = @($steps | Where-Object { $_ -match '^ - name: Retain official package verification evidence\n' }) + if ($prefetch.Count -ne 1 -or $retention.Count -ne 1) { + throw "the release workflow requires exactly one gobject prefetch and evidence step" + } + foreach ($required in @( + 'timeout-minutes: 5', + 'GH_TOKEN: ${{ github.token }}', + '. .\.ci-tools\environment.ps1', + '$env:GRAPHCODE_VALIDATION_ASSEMBLY = & .\Tools\windows\Build-CompiledValidationRunner.ps1', + './Tools/windows/Initialize-GobjectPackageCache.ps1 -PublishJobEnvironment')) { + if (-not $prefetch[0].Contains($required)) { + throw "the release prefetch step no longer references $required" + } + } + if ($prefetch[0] -match 'ValidationRunner\.exe|validate\.ps1|setup-dotnet@|permissions:') { + throw "release prefetch must build only the existing guard without new SDK or permissions" + } + foreach ($required in @( + 'if: always()', + 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02', + 'timeout-minutes: 5', + 'name: gobject-package-verification-${{ github.run_attempt }}', + 'path: ${{ runner.temp }}\gobject-prefetch-*\**', + 'include-hidden-files: true', + 'if-no-files-found: warn', + 'retention-days: 7')) { + if (-not $retention[0].Contains($required)) { + throw "the release evidence step no longer references $required" + } + } + $bootstrapPosition = $source.IndexOf(' - name: Bootstrap exact Windows dependencies') + $prefetchPosition = $source.IndexOf($prefetch[0]) + $packagePosition = $source.IndexOf(' - name: Package the Windows release artifact') + $retentionPosition = $source.IndexOf($retention[0]) + if ($bootstrapPosition -lt 0 -or $prefetchPosition -le $bootstrapPosition -or + $packagePosition -le $prefetchPosition -or $retentionPosition -le $packagePosition) { + throw "release prefetch and retained evidence must surround the existing package build" + } + $jobsSource = ($source -split "(?m)^jobs:\n", 2)[1] + if ([regex]::Matches($jobsSource, '(?m)^ [a-zA-Z][a-zA-Z0-9_-]*:\s*$').Count -ne 1 -or + $source -notmatch '(?m)^permissions:\n contents: read\n' -or + $source -notmatch '(?m)^ permissions:\n contents: write\n' -or + $source -match '(?m)^ (?:push|release|schedule):') { + throw "release helper composition changed the single job, permissions, or manual trigger" + } + $helperVariants++ + } + if ($helperVariants -ne 2) { throw "release helper contract did not execute both line-ending variants" } + Write-Output "Release workflow helper variants executed: $helperVariants" Write-Output "Release workflow contract: PASS" # 9. The workflow's own invocation actually binds release.ps1's parameters. diff --git a/Tools/windows/Tests/TerminalGate.Tests.ps1 b/Tools/windows/Tests/TerminalGate.Tests.ps1 index ae661f4f2..01d539b1c 100644 --- a/Tools/windows/Tests/TerminalGate.Tests.ps1 +++ b/Tools/windows/Tests/TerminalGate.Tests.ps1 @@ -1,6 +1,8 @@ [CmdletBinding()] param( [switch] $List, + [switch] $ResourceCountersOnly, + [switch] $OwnedDirectoryContractsOnly, [string] $ProductPinsPath, [string] $GatePinsPath ) @@ -14,7 +16,9 @@ if ($List) { "provider-pins", "graphcode-owned-host", "persistent-zmx", - "lifecycle-contract" + "lifecycle-contract", + "unrepaired-focus-guards", + "restored-terminal-route" ) exit 0 } @@ -26,6 +30,175 @@ function Assert-Contract([object] $condition, [string] $message) { } } +if ($ResourceCountersOnly) { + if ([string]::IsNullOrWhiteSpace($env:GRAPHCODE_VALIDATION_ASSEMBLY)) { + throw "Resource contracts require the explicit current source-bound compiled validation assembly" + } + & (Join-Path $PSScriptRoot "..\Import-CompiledValidationAssembly.ps1") ` + -AssemblyPath $env:GRAPHCODE_VALIDATION_ASSEMBLY ` + -RequiredType "GraphCode.Validation.Ownership.OwnedChildSupervisor" | Out-Null + $positive = [GraphCode.Validation.Ownership.OwnedChildSupervisor]::ValidateResourceSnapshot(42, 123, 42, 123, $false, 10, 65536) + Assert-Contract ($positive.ProcessId -eq 42 -and $positive.CreationFileTime -eq 123 -and + $positive.Handles -eq 10 -and $positive.PrivateBytes -eq 65536) "actual resource counter fields were lost" + $executed = 1 + foreach ($arguments in @( + @(0, 123, 42, 123, $false, 10, 65536), + @(42, 0, 42, 123, $false, 10, 65536), + @(42, 123, 99, 123, $false, 10, 65536), + @(42, 123, 42, 124, $false, 10, 65536), + @(42, 123, 42, 123, $true, 10, 65536), + @(42, 123, 42, 123, $false, 0, 65536), + @(42, 123, 42, 123, $false, 10, 0), + @(42, 123, 42, 123, $false, 10, [ulong]::MaxValue) + )) { + $failure = $null + try { + [GraphCode.Validation.Ownership.OwnedChildSupervisor]::ValidateResourceSnapshot( + $arguments[0], $arguments[1], $arguments[2], $arguments[3], $arguments[4], $arguments[5], $arguments[6]) | Out-Null + } catch { $failure = $_ } + Assert-Contract ($null -ne $failure -and $failure.Exception.Message.Contains("Held original resource identity or counters are invalid")) ` + "invalid metric identity/exit/counter must be refused" + $executed++ + } + $resourceSource = Get-Content (Join-Path $PSScriptRoot "..\ValidationRunner\Ownership\OwnedChildSupervisor.cs") -Raw + Assert-Contract ($resourceSource.Contains('K32GetProcessMemoryInfo(root, ref counters, counters.Size)') -and + $resourceSource.Contains('GetProcessHandleCount(root, out uint handles)') -and + $resourceSource.Contains('Marshal.SizeOf()') -and + ([regex]::Matches($resourceSource, 'RequireResourceRoot\(\);')).Count -eq 2 -and + -not $resourceSource.Contains('GetProcessById') -and -not $resourceSource.Contains('OpenProcess(')) ` + "resource native queries must use only the original held root with exact struct size and live identity guards" + $executed++ + $type = [GraphCode.Validation.Ownership.OwnedChildSupervisor] + $memoryType = $type.GetNestedType("ProcessMemoryCounters", [Reflection.BindingFlags]::NonPublic) + $sizeOfType = [Runtime.InteropServices.Marshal].GetMethod("SizeOf", [type[]]@([type])) + $memorySize = $sizeOfType.Invoke($null, @($memoryType)) + $query = $type.GetMethod("K32GetProcessMemoryInfo", [Reflection.BindingFlags]'Static,NonPublic') + $handleQuery = $type.GetMethod("GetProcessHandleCount", [Reflection.BindingFlags]'Static,NonPublic') + Assert-Contract ($null -ne $memoryType -and $memoryType.IsLayoutSequential -and + $memorySize -eq $(if ([IntPtr]::Size -eq 8) { 80 } else { 44 }) -and + $query.ReturnType -eq [bool] -and $handleQuery.ReturnType -eq [bool] -and + $query.GetParameters()[0].ParameterType -eq [IntPtr] -and + $handleQuery.GetParameters()[0].ParameterType -eq [IntPtr] -and + $handleQuery.GetParameters()[1].IsOut -and + $query.GetCustomAttributes([Runtime.InteropServices.DllImportAttribute], $false)[0].SetLastError -and + $handleQuery.GetCustomAttributes([Runtime.InteropServices.DllImportAttribute], $false)[0].SetLastError) ` + "compiled metric native signatures and architecture-correct structure size must remain exact" + $executed++ + Assert-Contract ($executed -eq 11) "expected eleven source/managed held-resource cases" + Write-Output "Terminal held resource counters: PASS executed=$executed source/managed-only" + exit 0 +} + +if ($OwnedDirectoryContractsOnly) { + if ([string]::IsNullOrWhiteSpace($env:GRAPHCODE_VALIDATION_ASSEMBLY)) { + throw "Owned directory contracts require the explicit current compiled assembly" + } + & (Join-Path $PSScriptRoot "..\Import-CompiledValidationAssembly.ps1") ` + -AssemblyPath (Join-Path (Split-Path $env:GRAPHCODE_VALIDATION_ASSEMBLY) "ValidationFixtureBindings.dll") ` + -RequiredType "GraphCode.ValidationFixtureDirectoryNative" | Out-Null + $executed = 0 + $sid = "S-1-5-21-1-2-3-1001" + $descriptor = [GraphCode.ValidationFixtureDirectoryNative]::CurrentUserDescriptor($sid) + $parsed = [Security.AccessControl.RawSecurityDescriptor]::new($descriptor) + Assert-Contract ($parsed.Owner.Value -ceq $sid -and $parsed.DiscretionaryAcl.Count -eq 1 -and + ($parsed.ControlFlags -band [Security.AccessControl.ControlFlags]::DiscretionaryAclProtected) -ne 0) ` + "fresh private descriptor requires exact current-user owner and one protected rule" + $ace = $parsed.DiscretionaryAcl[0] + Assert-Contract ($ace.SecurityIdentifier.Value -ceq $sid -and + $ace.AccessMask -eq [int][Security.AccessControl.FileSystemRights]::FullControl -and + $ace.AceFlags -eq ([Security.AccessControl.AceFlags]::ContainerInherit -bor [Security.AccessControl.AceFlags]::ObjectInherit) -and + $ace.AceQualifier -eq [Security.AccessControl.AceQualifier]::AccessAllowed) ` + "only the current user may receive inheritable FullControl; no other principal" + $executed++ + $script:directoryCalls = [Collections.Generic.List[string]]::new() + $getSid = [Func[byte[]]]{ return [byte[]]@(1,2,3,4,5,6,7,8) } + $create = [Func[string,byte[],int]]{ + param($path,$bytes) + $script:directoryCalls.Add("create") + Assert-Contract ($path -ceq "D:\owned-new" -and $bytes.Length -eq 8) "actual path/SID must reach creation unchanged" + return 0 + } + $verify = [Action[string,byte[]]]{ param($path,$bytes) $script:directoryCalls.Add("verify") } + [GraphCode.ValidationFixtureDirectoryNative]::CreateOwnedWith("D:\owned-new", $getSid, $create, $verify) + Assert-Contract (($directoryCalls -join "|") -ceq "create|verify") "verification must follow one successful exclusive create" + $executed++ + foreach ($errorCode in @(183,5)) { + $script:directoryCalls.Clear() + $failedCreate = [Func[string,byte[],int]]{ param($path,$bytes) return $errorCode } + $failure = $null + try { [GraphCode.ValidationFixtureDirectoryNative]::CreateOwnedWith("D:\owned-new",$getSid,$failedCreate,$verify) } + catch { $failure = $_ } + $cause = $failure.Exception + while ($cause.InnerException) { $cause = $cause.InnerException } + Assert-Contract ($cause -is [ComponentModel.Win32Exception] -and $cause.NativeErrorCode -eq $errorCode -and + $directoryCalls.Count -eq 0) "existing/refused creation must preserve native error and never adopt/verify the directory" + $executed++ + } + $failVerify = [Action[string,byte[]]]{ param($path,$bytes) throw "actual owner verification failure" } + $failure = $null + try { [GraphCode.ValidationFixtureDirectoryNative]::CreateOwnedWith("D:\owned-new",$getSid,$create,$failVerify) } + catch { $failure = $_ } + Assert-Contract ($failure.Exception.Message.Contains("actual owner verification failure")) "owner mismatch must fail without deletion/fallback" + $executed++ + foreach ($path in @("relative", "D:\owned-new\..\other")) { + $failure = $null + try { [GraphCode.ValidationFixtureDirectoryNative]::CreateOwnedWith($path,$getSid,$create,$verify) } + catch { $failure = $_ } + Assert-Contract ($null -ne $failure) "invalid fresh target path must refuse before creation" + $executed++ + } + $missingSid = [Func[byte[]]]{ return [byte[]]@() } + $failure = $null + try { [GraphCode.ValidationFixtureDirectoryNative]::CreateOwnedWith("D:\owned-new",$missingSid,$create,$verify) } + catch { $failure = $_ } + Assert-Contract ($null -ne $failure) "unavailable current user identity must fail" + $executed++ + $nativeSource = Get-Content (Join-Path $PSScriptRoot "..\ValidationFixtureDirectoryNative.cs") -Raw + Assert-Contract ($nativeSource.Contains('bool created = CreateDirectoryWithOwner(path, ref attributes);') -and + $nativeSource.Contains('int error = created ? 0 : Marshal.GetLastWin32Error();') -and + $nativeSource.Contains('pin.Free();') -and $nativeSource.Contains('GetSecurityInfo(handle, 1, 1') -and + $nativeSource.Contains('EqualSid(owner, pin.AddrOfPinnedObject())') -and + -not ($nativeSource -match 'SetNamedSecurityInfo|SetFileSecurity|AdjustTokenPrivileges|SetAcl')) ` + "native descriptor lifetime/error/held-directory verification must not edit existing ACLs or enable privileges" + $executed++ + Assert-Contract ($nativeSource.Contains('CreateDirectoryW(path, IntPtr.Zero)')) "existing Create semantics must remain unchanged" + $executed++ + $script:releaseCalls = [Collections.Generic.List[string]]::new() + $releases = [Action[]]@( + [Action]{ $script:releaseCalls.Add("descriptor"); throw "descriptor free failed" }, + [Action]{ $script:releaseCalls.Add("pin") }, + [Action]{ $script:releaseCalls.Add("handle"); throw "handle close failed" } + ) + $primary = [InvalidOperationException]::new("primary owner refusal") + [GraphCode.ValidationFixtureDirectoryNative]::ReleaseOwnedWith($primary, $releases) + Assert-Contract (($releaseCalls -join "|") -ceq "descriptor|pin|handle" -and + $primary.Data["OwnedFixtureReleaseFailures"].InnerExceptions.Count -eq 2 -and + $primary.Message -ceq "primary owner refusal") "all cleanup actions must run and retain every failure without replacing primary" + $executed++ + $failure = $null + try { [GraphCode.ValidationFixtureDirectoryNative]::ReleaseOwnedWith($null, $releases) } catch { $failure = $_ } + Assert-Contract ($null -ne $failure -and $failure.Exception.Message.Contains("Owned fixture resource release failed")) ` + "native release failure without primary must refuse a success result" + $executed++ + $script:releaseCalls.Clear() + [GraphCode.ValidationFixtureDirectoryNative]::ReleaseOwnedWith($null, [Action[]]@([Action]{ $script:releaseCalls.Add("all-released") })) + Assert-Contract (($releaseCalls -join "|") -ceq "all-released") "successful release must execute its actual actions" + $executed++ + $verifySource = $nativeSource.Substring($nativeSource.IndexOf("private static void VerifyOwner")) + Assert-Contract ($verifySource.Contains("GCHandle pin = default;") -and + $verifySource.IndexOf("pin = GCHandle.Alloc") -gt $verifySource.IndexOf("try") -and + $verifySource.Contains("if (pin.IsAllocated) pin.Free()") -and + $verifySource.Contains("CloseOwnedHandle(handle)") -and + $nativeSource.Contains('[DllImport("kernel32.dll", SetLastError = true)] private static extern IntPtr LocalFree') -and + $nativeSource.Contains("if (remaining == IntPtr.Zero) return;") -and + $nativeSource.Contains("Owned LocalFree failed without a native error.")) ` + "pin allocation failures must still close directory handles and native free failures must remain explicit" + $executed++ + Assert-Contract ($executed -eq 14) "expected fourteen owner-at-creation source/managed cases" + Write-Output "Terminal owned directory contracts: PASS executed=$executed source/managed-only nativeCreates=0" + exit 0 +} + foreach ($path in @( "build.zig", "build.zig.zon", @@ -143,17 +316,21 @@ Assert-Contract (-not $source.Contains("app.tick == 6")) ` "surface recreation still depends on a fixed startup tick" $harness = Get-Content -LiteralPath (Join-Path $gateRoot "..\..\..\Tools\windows\terminal-gate.ps1") -Raw +Assert-Contract ($harness.Contains('[GraphCode.ValidationFixtureDirectoryNative]::CreateOwnedByCurrentUser($artifactRoot)') -and + $harness.Contains('[GraphCode.ValidationFixtureDirectoryNative]::CreateOwnedByCurrentUser($env:ZMX_DIR)')) ` + "fresh terminal artifact and configured zmx roots require explicit current-user owner at creation" foreach ($token in @( - "zmx send", + "Send-TerminalText", "history", "--vt", "Assert-ZmxSessionHealthy", - "pwd", + "pwd/cwd", "same-session restart", "Assert-PinnedCleanWorktree", - "status --porcelain", - "Get-CimInstance", - "Get-ZmxSessionProcessIds", + '"status", "--porcelain"', + "OwnedChildSupervisor", + "Start-TerminalOwnedChild", + "Complete-TerminalSeeds", "RedirectStandardOutput", "RedirectStandardError", "NewGuid", @@ -168,6 +345,383 @@ foreach ($token in @( } Assert-Contract (-not $harness.Contains("& `$zmx list")) ` "smoke harness still performs an unbounded zmx list" +Assert-Contract (-not ($harness -match 'Get-CimInstance|Get-ZmxSessionProcessIds|Get-ProcessTreeIds|Stop-Process|\.WaitForExit\(\)')) ` + "terminal harness must not discover cleanup authority or wait without a deadline" +Assert-Contract (-not $harness.Contains('Remove-Item -LiteralPath $path')) ` + "terminal harness must retain original stdout and stderr on failure" +Assert-Contract ($harness.Contains('HoldRetainedResourcesAsync') -and + $harness.Contains('CompleteAsync().GetAwaiter().GetResult()') -and + $harness.Contains('StopAsync($primary)') -and + $harness.Contains('WaitAll($tasks.ToArray(), 9000)') -and + $harness.IndexOf('Windows terminal gate smoke/stress: PASS') -gt $harness.IndexOf('finally {')) ` + "terminal success must follow held-job cleanup, with shared reserve and UNKNOWN hold" +Assert-Contract ($harness.Contains('"test", "src\AttachCleanup.zig"') -and + $harness.Contains('All [1-9][0-9]* tests passed')) "pure attach contracts must run in canonical gate builds with a positive count" +Assert-Contract ($source.Contains('AttachCleanup.stopWith(api, child.id)') -and + $source.Contains('c.WaitForSingleObject') -and $source.Contains('c.TerminateProcess') -and + -not $source.Contains('child.kill() catch {}') -and -not $source.Contains('child.wait() catch {}') -and + $source.Contains('AttachCleanup.cleanupWith(&api)') -and + $source.Contains('try cleanup(app);') -and $source.Contains('retainUnknownAttach')) ` + "spike must retain unknown child authority and require bounded teardown before success" + +$terminalAst = [Management.Automation.Language.Parser]::ParseFile( + (Join-Path $repoRoot "Tools\windows\terminal-gate.ps1"), [ref]$null, [ref]$null) +foreach ($name in @("Get-TerminalRemaining", "New-TerminalStartInfo", "Invoke-TerminalOwnedCommand", + "Complete-TerminalSeeds", "Invoke-GateProcess", "Write-OwnedResourceMetrics", "Resolve-TerminalApplication", + "Get-TerminalArtifactRoot", "Write-TerminalDiagnostic", "Throw-TerminalHealthFailure")) { + $definition = $terminalAst.Find({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -ceq $name + }, $true) + Assert-Contract ($null -ne $definition) "missing canonical owned terminal helper: $name" + . ([scriptblock]::Create($definition.Extent.Text)) +} +$workflow = Get-Content (Join-Path $repoRoot ".github\workflows\windows-port-validation.yml") -Raw +function Assert-TerminalWorkflowRetention([string] $source) { + $source = $source.Replace("`r`n", "`n") + Assert-Contract ($source.Contains('${{ runner.temp }}\terminal-owned-*\**\*') -and + $source.Contains('- name: Retain UIA failure diagnostics' + "`n" + ' if: always()')) ` + "spikes-other must always retain original terminal raw diagnostics in its existing upload step" +} +Assert-TerminalWorkflowRetention $workflow +$retentionFixture = '- name: Retain UIA failure diagnostics' + "`n" + + ' if: always()' + "`n" + ' ${{ runner.temp }}\terminal-owned-*\**\*' +$script:workflowRetentionCases = 0 +foreach ($source in @($retentionFixture, $retentionFixture.Replace("`n", "`r`n"))) { + Assert-TerminalWorkflowRetention $source + $script:workflowRetentionCases++ +} +foreach ($source in @( + $retentionFixture.Replace('if: always()', 'if: failure()'), + $retentionFixture.Replace('${{ runner.temp }}\terminal-owned-*\**\*', '${{ runner.temp }}\gu-*\logs\*.json') + )) { + $failure = $null + try { Assert-TerminalWorkflowRetention $source } catch { $failure = $_ } + Assert-Contract ($null -ne $failure) "missing always/raw-terminal path must remain refused" + $script:workflowRetentionCases++ +} +Assert-Contract ($script:workflowRetentionCases -eq 4) "expected four workflow retention source cases" +Write-Output "Terminal workflow retention: PASS executed=$script:workflowRetentionCases source-only" +$script:terminalOwnershipCases = 0 +function Test-TerminalOwnership([scriptblock] $case) { + function Write-Host { } + & $case + $script:terminalOwnershipCases++ +} +Test-TerminalOwnership { + $workClock = @{ ElapsedMilliseconds = 1794999L } + Assert-Contract ((Get-TerminalRemaining 5000) -eq 1) "shared terminal work clock must clamp each call" + $workClock.ElapsedMilliseconds = 1795000L + $failure = $null + try { Get-TerminalRemaining 5000 } catch { $failure = $_ } + Assert-Contract ($null -ne $failure) "expired terminal clock must refuse another command" +} +Test-TerminalOwnership { + $info = New-TerminalStartInfo $PSCommandPath @("one argument", 'literal"quote') $repoRoot.Path + Assert-Contract ($info.UseShellExecute -eq $false -and $info.RedirectStandardOutput -and + $info.RedirectStandardError -and $info.ArgumentList.Count -eq 2 -and + $info.ArgumentList[0] -ceq "one argument" -and $info.ArgumentList[1] -ceq 'literal"quote') ` + "owned child must preserve argv boundaries and independent raw stream capture" +} +Test-TerminalOwnership { + $script:terminalStartCalled = 0 + $workClock = @{ ElapsedMilliseconds = 0L } + function Start-TerminalOwnedChild($start, $role, $milliseconds) { + Assert-Contract ($role -ceq "history" -and $milliseconds -eq 1234) "owned command must preserve its supplied remaining deadline" + $script:terminalStartCalled++ + $child = [pscustomobject]@{} + $child | Add-Member ScriptMethod CompleteAsync { + return [Threading.Tasks.Task]::FromResult([object]@{ ExitCode=0; StandardOutput="retained"; StandardError="separate" }) + } + return $child + } + $result = Invoke-TerminalOwnedCommand $PSCommandPath @("history", "exact session") "history" 1234 + Assert-Contract ($terminalStartCalled -eq 1 -and $result.StandardOutput -ceq "retained" -and + $result.StandardError -ceq "separate") "one owned command must consume its original completion only once" +} +foreach ($failCleanup in @($false, $true)) { + Test-TerminalOwnership { + $ownedSeeds = [Collections.Generic.List[object]]::new() + $script:terminalStops = 0 + $cleanupUnproven = $false + $primary = [InvalidOperationException]::new("original failure") + foreach ($index in 0..2) { + $child = [pscustomobject]@{ Fail = ($failCleanup -and $index -eq 1) } + $child | Add-Member ScriptMethod StopAsync { + param($original) + $script:terminalStops++ + if ($this.Fail) { + return [Threading.Tasks.Task]::FromException[object]([InvalidOperationException]::new("held job refusal")) + } + return [Threading.Tasks.Task]::FromResult([object]@{ CanRelease=$true; ResourcesClosed=$true }) + } + $ownedSeeds.Add(@{ child=$child }) + } + Complete-TerminalSeeds $primary + Assert-Contract ($terminalStops -eq 3 -and + ($null -ne $primary.Data["TerminalCleanupFailure"]) -eq $failCleanup) ` + "all independently held seeds must be cleaned and cleanup errors retained on the original failure" + } +} +Test-TerminalOwnership { + $ownedSeeds = @(@{ child=[pscustomobject]@{} }) * 3 + $ownedSeeds[0].child | Add-Member ScriptMethod Observe { return @{ RootExited=$true; ActiveJobProcesses=0 } } + function Invoke-TerminalOwnedCommand { throw "UNEXPECTED_SPIKE_LAUNCH" } + $failure = $null + try { Invoke-GateProcess @("--smoke") "refused" } catch { $failure = $_ } + Assert-Contract ($failure.Exception.Message.Contains("Original terminal seed root is not live")) ` + "spike must refuse a dead original seed instead of silently recreating the session" +} +Test-TerminalOwnership { + $ownedSeeds = [Collections.Generic.List[object]]::new() + $script:cleanupUnproven = $false + $child = [pscustomobject]@{} + $child | Add-Member ScriptMethod StopAsync { + param($original) + return [Threading.Tasks.Task]::FromResult([object]@{ CanRelease=$false; ResourcesClosed=$false }) + } + $ownedSeeds.Add(@{ child=$child }) + $original = [InvalidOperationException]::new("primary retained") + Complete-TerminalSeeds $original + Assert-Contract ($script:cleanupUnproven -and $null -ne $original.Data["TerminalCleanupFailure"] -and + $original.Message -ceq "primary retained") "unproved cleanup must retain original failure and select lifetime hold" +} +Assert-Contract ($script:terminalOwnershipCases -eq 7) "expected seven owned terminal source/managed cases" +Write-Output "Terminal ownership contracts: PASS executed=$script:terminalOwnershipCases source/managed-only" + +$script:terminalApplicationCases = 0 +function Test-TerminalApplication([scriptblock] $case) { + & $case + $script:terminalApplicationCases++ +} +Test-TerminalApplication { + $scratch = Join-Path ([IO.Path]::GetTempPath()) ("terminal application " + [guid]::NewGuid().ToString("N")) + $null = New-Item -ItemType Directory -Path $scratch + try { + $first = Join-Path $scratch "first git.exe" + $second = Join-Path $scratch "second git.exe" + [IO.File]::WriteAllText($first, "HEADLESS_NOT_EXECUTABLE") + [IO.File]::WriteAllText($second, "HEADLESS_NOT_EXECUTABLE") + $discover = { + @( + [pscustomobject]@{ CommandType=[Management.Automation.CommandTypes]::Application; Source=$first }, + [pscustomobject]@{ CommandType=[Management.Automation.CommandTypes]::Application; Source=$second } + ) + } + $selected = @(Resolve-TerminalApplication "git" $discover) + Assert-Contract ($selected.Count -eq 1 -and $selected[0] -ceq $first) ` + "multiple application candidates must select exactly the first path including spaces" + } finally { Remove-Item -LiteralPath $scratch -Recurse -Force } +} +Test-TerminalApplication { + $failure = $null + try { Resolve-TerminalApplication "git" { @() } } catch { $failure = $_ } + Assert-Contract ($failure.Exception.Message.Contains("application is absent")) "absent application must fail explicitly" +} +Test-TerminalApplication { + $failure = $null + try { + Resolve-TerminalApplication "pwsh" { + @( + [pscustomobject]@{ CommandType=[Management.Automation.CommandTypes]::Application; Source="Z:\absent-first.exe" }, + [pscustomobject]@{ CommandType=[Management.Automation.CommandTypes]::Application; Source=$PSCommandPath } + ) + } + } catch { $failure = $_ } + Assert-Contract ($failure.Exception.Message.Contains("First terminal application candidate")) ` + "invalid first precedence candidate must not silently fall back" +} +Test-TerminalApplication { + $selected = Resolve-TerminalApplication "git" + Assert-Contract ($selected -is [string] -and (Test-Path -LiteralPath $selected -PathType Leaf)) ` + "actual current git discovery must return one existing file without launching it" +} +Assert-Contract ($script:terminalApplicationCases -eq 4) "expected four application selection cases" +Write-Output "Terminal application contracts: PASS executed=$script:terminalApplicationCases managed/read-only-discovery" + +$script:terminalScratchCases = 0 +foreach ($case in @( + @{ environment=@{RUNNER_TEMP='D:\a\_temp'}; temp='C:\Users\runneradmin\AppData\Local\Temp'; expected='D:\a\_temp'; admissible=$true }, + @{ environment=@{}; temp='D:\t'; expected='D:\t'; admissible=$true }, + @{ environment=@{}; temp='C:\Users\runneradmin\AppData\Local\Temp'; expected='C:\Users\runneradmin\AppData\Local\Temp'; admissible=$false } + )) { + $root = Get-TerminalArtifactRoot $case.environment $case.temp ("a" * 32) { param($path) $true } + Assert-Contract ($root -ceq (Join-Path $case.expected ("terminal-owned-" + ("a" * 32)))) ` + "artifact root must prefer existing CI runner temp, without shortening an explicitly selected path" + $session = "gc-" + ("b" * 32) + "-shared" + $lease = Join-Path (Join-Path $root "zmx") ("ipc\" + ("s" * 52) + "\" + + ("0" * (2 * [Text.Encoding]::UTF8.GetByteCount($session))) + ".endpoint.lease") + Assert-Contract (($lease.Length -le 232) -eq $case.admissible) ` + "actual CI path formula must fit only the short root; long fallback remains refused" + $script:terminalScratchCases++ +} +foreach ($invalid in @("", " ", "relative-root", "D:\missing-directory")) { + $failure = $null + try { + Get-TerminalArtifactRoot @{ RUNNER_TEMP=$invalid } "D:\t" ("a" * 32) { + param($path) + return $path -cne "D:\missing-directory" + } + } catch { $failure = $_ } + Assert-Contract ($null -ne $failure) "present empty/invalid/non-directory runner temp must refuse without fallback" + $script:terminalScratchCases++ +} +Assert-Contract ($script:terminalScratchCases -eq 7) "expected seven pure scratch selection/budget cases" +Write-Output "Terminal scratch contracts: PASS executed=$script:terminalScratchCases pure-path-only" + +$script:terminalDiagnosticCases = 0 +foreach ($breakOutput in @($false, $true)) { + & { + $script:diagnosticLines = [Collections.Generic.List[string]]::new() + function Write-Host([string] $text) { + if ($breakOutput) { throw "actual diagnostic transport failure" } + $script:diagnosticLines.Add($text) + } + $first = @{ StandardError="first actual error"; ExitCode=1 } + $last = @{ StandardError=("last actual error" * 300); ExitCode=2 } + $failure = $null + try { Throw-TerminalHealthFailure "original health deadline" $first $last } catch { $failure = $_ } + Assert-Contract ($failure.Exception.Message -ceq "original health deadline" -and + [Object]::ReferenceEquals($failure.Exception.Data["TerminalHealthFirst"], $first) -and + [Object]::ReferenceEquals($failure.Exception.Data["TerminalHealthLast"], $last)) ` + "diagnostic reporting must retain original primary and actual completed command results" + if ($breakOutput) { + Assert-Contract ($null -ne $failure.Exception.Data["TerminalHealthDiagnosticFailure"]) ` + "transport failure must remain attached without replacing health failure" + } else { + $parsed = $diagnosticLines[1].Substring("TERMINAL_DIAGNOSTIC=".Length) | ConvertFrom-Json + Assert-Contract ($diagnosticLines.Count -eq 2 -and $parsed.truncated -and $parsed.text.Length -eq 2048 -and + $parsed.originalCharacters -eq $last.StandardError.Length) "bounded excerpts must declare truncation and retain original length" + } + $script:terminalDiagnosticCases++ + } +} +$retention = $terminalAst.Find({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -ceq "Complete-TerminalSeeds" +}, $true).Extent.Text +Assert-Contract ($retention.IndexOf('original-seed-stderr-after-held-cleanup') -gt + $retention.IndexOf('if (-not $proof.CanRelease') -and $retention.Contains('$taskSeeds[$task]') -and + $retention.Contains('TaskStatus]::RanToCompletion')) "seed stderr diagnostics require matching original task and proven held cleanup" +$script:terminalDiagnosticCases++ +foreach ($invalid in @($null, 42)) { + $failure = $null + try { Write-TerminalDiagnostic "invalid-actual-text" $invalid } catch { $failure = $_ } + Assert-Contract ($null -ne $failure -and $failure.Exception.Message.Contains("Actual diagnostic text is unavailable")) ` + "unavailable/nonstring diagnostic text must not become an actual empty string" + $script:terminalDiagnosticCases++ +} +& { + $script:diagnosticLines = [Collections.Generic.List[string]]::new() + function Write-Host([string] $text) { $script:diagnosticLines.Add($text) } + Write-TerminalDiagnostic "actual-empty" "" + $record = $diagnosticLines[0].Substring("TERMINAL_DIAGNOSTIC=".Length) | ConvertFrom-Json + Assert-Contract ($diagnosticLines.Count -eq 1 -and $record.originalCharacters -eq 0 -and + $record.text -ceq "" -and -not $record.truncated) "legitimate completed empty stderr must remain an actual empty diagnostic" + $script:terminalDiagnosticCases++ +} +& { + $ownedSeeds = [Collections.Generic.List[object]]::new() + $primary = [InvalidOperationException]::new("original seed health failure") + $taskIds = [Collections.Generic.List[int]]::new() + foreach ($index in 0..2) { + $child = [pscustomobject]@{ StandardError=[Threading.Tasks.Task]::FromResult([string]"actual error") } + $child | Add-Member ScriptMethod StopAsync { + param($original) + $task = [Threading.Tasks.Task]::FromResult([object]@{ CanRelease=$true; ResourcesClosed=$true }) + $taskIds.Add($task.Id) + return $task + } + $ownedSeeds.Add(@{ child=$child }) + } + function Write-TerminalDiagnostic { throw "diagnostic transport refused" } + function Write-Host { } + Complete-TerminalSeeds $primary + $retained = @($primary.Data.Keys | Where-Object { $_ -like "TerminalSeedDiagnosticFailure:*" }) + Assert-Contract ($retained.Count -eq 3 -and $taskIds.Count -eq 3 -and + $primary.Message -ceq "original seed health failure") "all three diagnostic failures must remain attached to the original health error" + foreach ($id in $taskIds) { + Assert-Contract ($primary.Data["TerminalSeedDiagnosticFailure:$id"].Message -ceq "diagnostic transport refused") ` + "each original completion task must retain its distinct reporting failure" + } + $script:terminalDiagnosticCases++ +} +Assert-Contract ($script:terminalDiagnosticCases -eq 7) "expected seven diagnostic preservation cases" +Write-Output "Terminal diagnostic contracts: PASS executed=$script:terminalDiagnosticCases source/managed-only" + +$script:terminalMetricCases = 0 +function Test-TerminalMetrics([scriptblock] $case) { + & $case + $script:terminalMetricCases++ +} +Test-TerminalMetrics { + $script:metricSequence = 0 + $sessionPrefix = "synthetic-owned-metrics" + $ownedSessionNames = @("synthetic-session") + $child = [pscustomobject]@{} + $child | Add-Member ScriptMethod ReadResources { + return @{ ProcessId=42; CreationFileTime=123; Handles=10; PrivateBytes=65536 } + } + $ownedSeeds = @(@{ child=$child }) * 3 + $script:metricLines = [Collections.Generic.List[string]]::new() + function Write-Host([string]$Object) { $script:metricLines.Add($Object) } + Write-OwnedResourceMetrics "terminal-gate:typed-input" $child + Write-OwnedResourceMetrics "terminal-gate:typed-input" $child + $first = $metricLines[0].Substring("PRODUCT_RESOURCE_METRICS_JSON=".Length) | ConvertFrom-Json + $second = $metricLines[1].Substring("PRODUCT_RESOURCE_METRICS_JSON=".Length) | ConvertFrom-Json + Assert-Contract ($metricLines.Count -eq 2 -and $first.snapshotId -cne $second.snapshotId -and + @($first.processes).Count -eq 4 -and + @($first.processes.role) -ccontains "winghostty" -and @($first.processes.role) -ccontains "zmx" -and + @($first.processes | Where-Object { $_.handles -ne 10 -or $_.privateBytes -ne 65536 }).Count -eq 0) ` + "actual counter fields and both hardening roles must survive with unique repeated-phase snapshots" +} +Test-TerminalMetrics { + $child = [pscustomobject]@{} + $child | Add-Member ScriptMethod ReadResources { throw "original held metric query failed" } + $ownedSeeds = @(@{ child=$child }) * 3 + $sessionPrefix = "synthetic-refusal" + $ownedSessionNames = @() + $script:metricLines = [Collections.Generic.List[string]]::new() + function Write-Host([string]$Object) { $script:metricLines.Add($Object) } + $failure = $null + try { Write-OwnedResourceMetrics "terminal-gate:stress" $child } catch { $failure = $_ } + Assert-Contract ($metricLines.Count -eq 0 -and $failure.Exception.Message.Contains("held metric query failed")) ` + "failed original metric reads must not publish zero-filled or empty success evidence" +} +Test-TerminalMetrics { + $function = $terminalAst.Find({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -ceq "Invoke-GateProcess" + }, $true).Extent.Text + Assert-Contract ($function.IndexOf('Write-OwnedResourceMetrics $phase $child') -lt + $function.IndexOf('$child.CompleteAsync()') -and $function.Contains('StopAsync($primary)') -and + $function.Contains('TerminalMetricCleanupFailure')) ` + "active spike metrics must precede completion and metric failures preserve owned cleanup" +} +Test-TerminalMetrics { + $app = $PSCommandPath + $repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..\..")).Path + $assembly = "synthetic-source-bound-assembly" + $workClock = @{ ElapsedMilliseconds=0 } + $script:metricCleanupCalls = 0 + $seed = [pscustomobject]@{} + $seed | Add-Member ScriptMethod Observe { return @{ RootExited=$false; ActiveJobProcesses=1 } } + $ownedSeeds = @(@{ child=$seed }) * 3 + $child = [pscustomobject]@{} + $child | Add-Member ScriptMethod StopAsync { + param($original) + $script:metricCleanupCalls++ + return [Threading.Tasks.Task]::FromResult([object]@{ CanRelease=$true; ResourcesClosed=$true }) + } + function Start-TerminalOwnedChild { return $child } + function Write-OwnedResourceMetrics { throw "actual metric refusal" } + $failure = $null + try { Invoke-GateProcess @("--smoke") "terminal-gate:typed-input" } catch { $failure = $_ } + Assert-Contract ($metricCleanupCalls -eq 1 -and $failure.Exception.Message -ceq "actual metric refusal") ` + "pre-completion metric refusal must clean its original spike once and preserve the primary failure" +} +Assert-Contract ($script:terminalMetricCases -eq 4) "expected four managed metric orchestration cases" +Write-Output "Terminal metric contracts: PASS executed=$script:terminalMetricCases source/managed-only" $runner = Get-Content -LiteralPath (Join-Path $gateRoot "..\..\..\Tools\windows\validate.ps1") -Raw foreach ($token in @( @@ -181,5 +735,472 @@ foreach ($token in @( "validation runner is missing real-provider gate handling: $token" } +$gatePath = Join-Path $repoRoot "Tools\windows\uia-live-gate.ps1" +$tokens = $null +$parseErrors = $null +$ast = [Management.Automation.Language.Parser]::ParseFile($gatePath, [ref]$tokens, [ref]$parseErrors) +Assert-Contract ($parseErrors.Count -eq 0) "UIA gate must parse before extracting headless guards" +foreach ($name in @("Require", "Assert-UiaUnrepairedTerminalFocus", "Assert-UiaTerminalChallengeOutput", + "Read-UiaTerminalComputedMarker", "Resolve-UiaRestorationSessionMarkers", + "Assert-UiaRestorationPeerCapability", "Get-UiaRestorationRemaining", "Invoke-UiaRestoredTerminalRoute", + "Initialize-UiaRestorationSessions", "Close-UiaRestorationState", "Save-UiaRestorationPartialOutput", + "Initialize-UiaRestorationProviderBinary")) { + $functions = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -ceq $name + }, $true)) + Assert-Contract ($functions.Count -eq 1) "expected one source-bound guard: $name" + . ([scriptblock]::Create($functions[0].Extent.Text)) +} + +$script:guardCases = 0 +function Test-Guard([string] $name, [scriptblock] $action, [string] $expectedError = "") { + $failure = $null + try { $null = & $action } catch { $failure = $_ } + if ($expectedError) { + Assert-Contract ($null -ne $failure -and $failure.Exception.Message.Contains($expectedError)) ` + "$name did not reject for the expected reason: $failure" + } else { + Assert-Contract ($null -eq $failure) "$name unexpectedly failed: $failure" + } + $script:guardCases++ +} +function New-FocusSnapshot { + [pscustomobject]@{ + foreground = $true; focusedProcessId = 42; focusedIdentity = "current-pane-1" + panes = @( + [pscustomobject]@{ identity = "current-pane-0"; processId = 42; parentProcessId = 42 + visible = $true; focused = $false }, + [pscustomobject]@{ identity = "current-pane-1"; processId = 42; parentProcessId = 42 + visible = $true; focused = $true } + ) + } +} +Test-Guard "exclusive observed focus" { + Assert-UiaUnrepairedTerminalFocus (New-FocusSnapshot) 42 "current-pane-1" +} +Test-Guard "empty roster" { + $snapshot = New-FocusSnapshot; $snapshot.panes = @() + Assert-UiaUnrepairedTerminalFocus $snapshot 42 "current-pane-1" +} "nontrivial pane topology" +Test-Guard "foreign foreground focus" { + $snapshot = New-FocusSnapshot; $snapshot.focusedProcessId = 99 + Assert-UiaUnrepairedTerminalFocus $snapshot 42 "current-pane-1" +} "actual foreground focus" +Test-Guard "background window" { + $snapshot = New-FocusSnapshot; $snapshot.foreground = $false + Assert-UiaUnrepairedTerminalFocus $snapshot 42 "current-pane-1" +} "actual foreground focus" +Test-Guard "foreign provider" { + $snapshot = New-FocusSnapshot; $snapshot.panes[0].processId = 99 + Assert-UiaUnrepairedTerminalFocus $snapshot 42 "current-pane-1" +} "foreign pane topology" +Test-Guard "foreign parent" { + $snapshot = New-FocusSnapshot; $snapshot.panes[1].parentProcessId = 99 + Assert-UiaUnrepairedTerminalFocus $snapshot 42 "current-pane-1" +} "foreign pane topology" +Test-Guard "duplicate providers" { + $snapshot = New-FocusSnapshot; $snapshot.panes[0].identity = "current-pane-1" + Assert-UiaUnrepairedTerminalFocus $snapshot 42 "current-pane-1" +} "foreign pane topology" +Test-Guard "two focused providers" { + $snapshot = New-FocusSnapshot; $snapshot.panes[0].focused = $true + Assert-UiaUnrepairedTerminalFocus $snapshot 42 "current-pane-1" +} "exclusively own keyboard focus" +Test-Guard "stale selected identity" { + Assert-UiaUnrepairedTerminalFocus (New-FocusSnapshot) 42 "previous-window-pane-1" +} "actual foreground focus" +Test-Guard "trivial first pane" { + $snapshot = New-FocusSnapshot + $snapshot.focusedIdentity = "current-pane-0" + $snapshot.panes[0].focused = $true; $snapshot.panes[1].focused = $false + Assert-UiaUnrepairedTerminalFocus $snapshot 42 "current-pane-0" +} "nonzero selected pane" + +$nonce = "0123456789abcdef01234567" +$before = @("pane zero", "cmd prompt") +$resultLine = "GC_${nonce}_1234_323" +Test-Guard "shell-computed output" { + $observed = Assert-UiaTerminalChallengeOutput $before @("pane zero", $resultLine) 1 $nonce 323 + Assert-Contract ($observed -eq 1234) "challenge must return its parsed shell PID" +} +Test-Guard "echo is not output" { + Assert-UiaTerminalChallengeOutput $before @("pane zero", "cmd /c echo $resultLine") 1 $nonce 323 +} "shell-computed output line" +Test-Guard "empty output topology" { + Assert-UiaTerminalChallengeOutput $before @() 1 $nonce 323 +} "exact nonzero pane" +Test-Guard "first pane output" { + Assert-UiaTerminalChallengeOutput $before @($resultLine, "cmd prompt") 0 $nonce 323 +} "exact nonzero pane" +Test-Guard "foreign destination" { + Assert-UiaTerminalChallengeOutput $before @($resultLine, $resultLine) 1 $nonce 323 +} "nonselected pane" +Test-Guard "old nonce" { + Assert-UiaTerminalChallengeOutput @("pane zero", $resultLine) @("pane zero", $resultLine) 1 $nonce 323 +} "already present" +Test-Guard "wrong arithmetic" { + Assert-UiaTerminalChallengeOutput $before @("pane zero", "GC_${nonce}_1234_322") 1 $nonce 323 +} "arithmetic is invalid" +Test-Guard "duplicate output" { + Assert-UiaTerminalChallengeOutput $before @("pane zero", "$resultLine`n$resultLine") 1 $nonce 323 +} "exactly one" +Test-Guard "zero shell PID" { + Assert-UiaTerminalChallengeOutput $before @("pane zero", "GC_${nonce}_0_323") 1 $nonce 323 +} "shell-computed output line" +Test-Guard "overflow shell PID" { + Assert-UiaTerminalChallengeOutput $before @("pane zero", "GC_${nonce}_9999999999999999999999_323") 1 $nonce 323 +} "arithmetic is invalid" +Assert-Contract ($script:guardCases -eq 20) "expected 20 executed unrepaired terminal guard cases" +Write-Output "Unrepaired terminal input guards: PASS executed=$script:guardCases headless-only" + +$script:routeCases = 0 +function Test-Route([string] $name, [scriptblock] $action) { + try { & $action } catch { throw "M2 headless orchestration case '$name': $_" } + $script:routeCases++ +} +Test-Route "canonical opt-in dispatch" { + $dispatch = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.IfStatementAst] -and + $node.Clauses[0].Item1.Extent.Text -ceq '$RestoredTerminalInputOnly' + }, $true)) + Assert-Contract ($dispatch.Count -eq 1 -and + $dispatch[0].Extent.Text.Contains('Invoke-UiaRestoredTerminalRoute') -and + $dispatch[0].Extent.Text.Contains('return') -and + $dispatch[0].Extent.StartOffset -lt $ast.Extent.Text.IndexOf('$oldZmx =')) ` + "real opt-in must return before any default fixture environment or PID-reopen launch" + $imports = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.IfStatementAst] -and + $node.Clauses[0].Item1.Extent.Text -ceq '-not $RestoredTerminalInputOnly' -and + $node.Extent.Text.Contains("Import-Module -Name (Join-Path `$PSScriptRoot 'UiCaptureDriver.psm1')") + }, $true)) + Assert-Contract ($imports.Count -eq 1) "default shared-helper import must not preload the real route's peer type" + $preflight = $ast.Find({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -ceq "Assert-UiaRestorationPeerCapability" + }, $true).Extent.Text + Assert-Contract ( + $preflight.IndexOf('Initialize-CapUiaClient -AssemblyPath $validationAssemblyPath') -gt + $preflight.IndexOf('Import-Module $module -ErrorAction Stop') -and + $preflight.Contains('Require (-not ("CapNative" -as [type]))') + ) "opt-in must initialize the current source-bound UIA proxy loader after its fresh module import" +} +Test-Route "unqualified peer bytes refuse before resources" { + function Join-Path($Path, $ChildPath) { return "HEADLESS_MODULE_PATH" } + function Get-FileHash($LiteralPath, $Algorithm) { return @{ Hash = "0" * 64 } } + function New-UiaRestorationState { throw "UNEXPECTED_RESOURCE_CREATION" } + $failure = $null + try { Invoke-UiaRestoredTerminalRoute "shell" "daemon" "cli" "zmx" "root" } catch { $failure = $_ } + Assert-Contract ($failure.Exception.Message.Contains("shared pipe-peer source differs from its qualified intake")) ` + "unqualified shared source bytes must still refuse before creating resources: $failure" +} +Test-Route "work deadline preserves reserve" { + $state = @{ clock = @{ ElapsedMilliseconds = 1110000L } } + $failure = $null + try { Get-UiaRestorationRemaining $state } catch { $failure = $_ } + Assert-Contract ($failure.Exception.Message.Contains("90-second cleanup reserve")) "work must not consume cleanup reserve" + $state.clock.ElapsedMilliseconds = 1109999L + Assert-Contract ((Get-UiaRestorationRemaining $state 5000) -eq 1) "remaining work timeout must clamp to original budget" +} +Test-Route "real route sequence and cleanup" { + $script:steps = [Collections.Generic.List[string]]::new() + function Assert-UiaRestorationPeerCapability { $script:steps.Add("preflight") } + function New-UiaRestorationState { + $script:steps.Add("state") + return [pscustomobject]@{ daemon = "daemon"; root = "mock-root"; failure = $null } + } + function Start-UiaRestorationProcess { $script:steps.Add("daemon"); return "held-daemon" } + function Confirm-UiaRestorationDaemonPeer { $script:steps.Add("peer"); return @{ scope = "observer" } } + function Initialize-UiaRestorationSessions { $script:steps.Add("real-seeds") } + function Open-UiaRestorationWorkspace($state, $stem) { $script:steps.Add($stem); return $stem } + function Select-UiaRestorationInitialPane { $script:steps.Add("initial-user-selection") } + function Close-UiaRestorationWindow($state, $app) { $script:steps.Add("close:$app") } + function Invoke-UiaRestorationFreshInput { $script:steps.Add("unrepaired-guarded-input"); return @{ focusRepaired = $false } } + function Close-UiaRestorationState { $script:steps.Add("zero-survivor-cleanup") } + $result = Invoke-UiaRestoredTerminalRoute "shell" "daemon" "cli" "zmx" "root" + Assert-Contract (($script:steps -join "|") -ceq + "preflight|state|daemon|peer|real-seeds|first-shell|initial-user-selection|close:first-shell|reopened-shell|unrepaired-guarded-input|peer|close:reopened-shell|zero-survivor-cleanup") ` + "actual route must close original shell before reopening and guard input before a success result" + Assert-Contract ($result.input.focusRepaired -eq $false) "route must preserve unrepaired-input evidence" +} +Test-Route "reopened focus refusal cleans and never reports success" { + $script:steps = [Collections.Generic.List[string]]::new() + function Assert-UiaRestorationPeerCapability {} + function New-UiaRestorationState { return [pscustomobject]@{ daemon = "daemon"; failure = $null } } + function Start-UiaRestorationProcess { return "held-daemon" } + function Confirm-UiaRestorationDaemonPeer {} + function Initialize-UiaRestorationSessions {} + function Open-UiaRestorationWorkspace($state, $stem) { return $stem } + function Select-UiaRestorationInitialPane { $script:steps.Add("initial-only-selection") } + function Close-UiaRestorationWindow { $script:steps.Add("close") } + function Invoke-UiaRestorationFreshInput { + $snapshot = New-FocusSnapshot; $snapshot.foreground = $false + Assert-UiaUnrepairedTerminalFocus $snapshot 42 "current-pane-1" + $script:steps.Add("UNSAFE_INPUT") + } + function Close-UiaRestorationState($state) { + Assert-Contract ($null -ne $state.failure) "original focus failure must survive cleanup" + $script:steps.Add("cleanup") + } + $failure = $null; $result = $null + try { $result = Invoke-UiaRestoredTerminalRoute "shell" "daemon" "cli" "zmx" "root" } catch { $failure = $_ } + Assert-Contract ($null -eq $result -and $failure.Exception.Message.Contains("actual foreground focus") -and + ($script:steps -join "|") -ceq "initial-only-selection|close|cleanup") ` + "focus refusal must not repair, inject, or produce a success-shaped result" +} +function New-MarkerFixture { + $firstNonce = "111111111111111111111111" + $secondNonce = "222222222222222222222222" + [pscustomobject]@{ + state = @{ sessions = @("graphcode-FIRST", "graphcode-second") + seeds = @(@{ session = "graphcode-FIRST"; nonce = $firstNonce }, + @{ session = "graphcode-second"; nonce = $secondNonce }) } + snapshots = @( + [pscustomobject]@{ processId = 42; parentProcessId = 42; identity = "provider-first" + window = [IntPtr]201; windowClass = "WinghosttyEmbeddableSurface"; element = $null + text = "GC_${firstNonce}_101_323"; visible = $true; focused = $false }, + [pscustomobject]@{ processId = 42; parentProcessId = 42; identity = "provider-second" + window = [IntPtr]202; windowClass = "WinghosttyEmbeddableSurface"; element = $null + text = "GC_${secondNonce}_102_323"; visible = $true; focused = $true } + ) + } +} +Test-Route "content maps reordered providers, not screen order" { + $fixture = New-MarkerFixture + $mapped = @(Resolve-UiaRestorationSessionMarkers $fixture.state @($fixture.snapshots[1], $fixture.snapshots[0]) 42) + Assert-Contract ($mapped.Count -eq 2 -and $mapped[0].identity -ceq "provider-first" -and + $mapped[1].identity -ceq "provider-second" -and $mapped[1].seedWitnessPid -eq 102) ` + "exact computed content must bind providers back into persisted session order" +} +foreach ($case in @( + @{ name = "foreign provider"; change = { $fixture.snapshots[1].processId = 99 }; error = "foreign, duplicated" }, + @{ name = "foreign parent"; change = { $fixture.snapshots[1].parentProcessId = 99 }; error = "foreign, duplicated" }, + @{ name = "duplicate HWND"; change = { $fixture.snapshots[1].window = [IntPtr]201 }; error = "foreign, duplicated" }, + @{ name = "duplicate runtime identity"; change = { $fixture.snapshots[1].identity = "provider-first" }; error = "foreign, duplicated" }, + @{ name = "duplicate session content"; change = { $fixture.snapshots[1].text = $fixture.snapshots[0].text }; error = "ambiguous or duplicated" }, + @{ name = "seed echo only"; change = { $fixture.snapshots[1].text = "echo " + $fixture.snapshots[1].text }; error = "shell-computed output line" }, + @{ name = "empty selector"; change = { $fixture.snapshots = @() }; error = "two distinct seeded sessions" }, + @{ name = "unpublished nonce"; change = { $fixture.snapshots[1].text = "cmd prompt" }; error = "not published yet" } + )) { + Test-Route $case.name { + $fixture = New-MarkerFixture + & $case.change + $failure = $null + try { Resolve-UiaRestorationSessionMarkers $fixture.state $fixture.snapshots 42 | Out-Null } catch { $failure = $_ } + Assert-Contract ($null -ne $failure -and $failure.Exception.Message.Contains($case.error)) ` + "content binding must refuse $($case.name): $failure" + } +} +Test-Route "only initial selection can set pane focus" { + $definitions = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -match '^(?:.*-UiaRestoration|Invoke-UiaRestoredTerminalRoute)' + }, $true)) + $setters = @($definitions | Where-Object { $_.Extent.Text.Contains('.SetFocus(') }) + Assert-Contract ($setters.Count -eq 1 -and $setters[0].Name -ceq "Select-UiaRestorationInitialPane") ` + "no restoration observer, reopened-window or input stage may repair pane focus" + foreach ($definition in $definitions) { + Assert-Contract ($definition.Extent.Text -notmatch 'AttachThreadInput|FocusControl|SetForegroundWindow|Start-UiaOwnedProcess|Start-MultiProjectOwnedProcess|Read-CapMatchedPanes|Invoke-CapMatchedTerminalInputChallenge') ` + "real route must not use legacy authority or repaired-focus proxies: $($definition.Name)" + } +} +Test-Route "real launch environment is explicit and nonsynthetic" { + $start = $ast.Find({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -ceq "Start-UiaRestorationProcess" + }, $true).Extent.Text + $stateSource = $ast.Find({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -ceq "New-UiaRestorationState" + }, $true).Extent.Text + Assert-Contract ($start.Contains('$info.Environment.Clear()') -and $start.Contains('Start-ShellOwnedProcess') -and + $start.Contains('$info.ArgumentList.Add($argument)') -and + $stateSource -notmatch 'GRAPHCODE_UIA_|GRAPHCODE_GATE_CWD|GRAPHCODE_WORKSPACE_(LAYOUT|PROJECT)|ZMX_SESSION_PREFIX') ` + "the real route must explicitly omit inherited synthetic workspace flags and preserve argument boundaries" +} +Test-Route "seed and native input command shapes" { + $seedSource = $ast.Find({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -ceq "Initialize-UiaRestorationSessions" + }, $true).Extent.Text + $inputSource = $ast.Find({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -ceq "Invoke-UiaRestorationFreshInput" + }, $true).Extent.Text + Assert-Contract ($seedSource.Contains('@("--daemon", $state.sessions[$index]') -and + $seedSource.Contains('"/Q", "/K", $seedScript') -and $seedSource.Contains('@("projects", "--json")') -and + $seedSource.Contains('focused = 0') -and -not $seedSource.Contains('focused = 1') -and + $inputSource.Contains('Assert-UiaRestorationObservedFocus') -and + $inputSource.Contains('SendUnicodeInput($app.window, $app.process.Id, $panes[1].window, $command)') -and + $inputSource.Contains('SendKeyInput($app.window, $app.process.Id, $panes[1].window, 0x0D, 1)') -and + $inputSource.Contains('Assert-UiaTerminalChallengeOutput')) ` + "actual route must seed computed content without target selection, then use exact owned guarded native input" +} +Test-Route "unknown cleanup retains original authority" { + $cleanup = $ast.Find({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -ceq "Close-UiaRestorationState" + }, $true).Extent.Text + $hold = $ast.Find({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -ceq "Wait-UiaRestorationUnknown" + }, $true).Extent.Text + Assert-Contract ($cleanup.Contains('Close-ShellProcessScope $state.scope') -and + $cleanup.Contains('Read-ShellOwnedOutput') -and $cleanup.Contains('@("stdout", "stderr")') -and + $cleanup.Contains('M2CleanupFailure') -and $cleanup.Contains('Test-ShellOwnershipUnproven') -and + $cleanup.Contains('Wait-UiaRestorationUnknown $state') -and $hold.Contains('[Threading.Timeout]::Infinite')) ` + "unknown teardown must preserve original failure and retain lifetime; raw streams stay independent" +} +Test-Route "actual seed adapter creates source-shaped artifacts without native launches" { + $scratch = Join-Path ([IO.Path]::GetTempPath()) ("m2-headless-" + [guid]::NewGuid().ToString("N")) + Assert-Contract (-not (Test-Path -LiteralPath $scratch)) "headless scratch must be new" + $null = New-Item -ItemType Directory -Path $scratch + try { + $script:seedCalls = [Collections.Generic.List[object]]::new() + $node = "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE" + $state = [pscustomobject]@{ + root = $scratch; project = Join-Path $scratch "M2Project"; projectDirectory = Join-Path $scratch "M2Project" + support = Join-Path $scratch "support"; nodeId = ""; paneIds = @(); sessions = @(); seeds = @() + layoutPath = ""; zmx = "mock-pinned-zmx" + } + function Invoke-UiaRestorationCli($state, $arguments, $stem) { + $script:seedCalls.Add([pscustomobject]@{ stem = $stem; arguments = $arguments }) + switch ($stem) { + "register-project" { return "M2Project (idle)" } + "canonical-project" { return ConvertTo-Json -InputObject @(@{ name = "M2Project"; path = $state.project.Replace('\', '/') }) -Compress } + "create-loop" { return "M2Project (idle)`n $node idle sketch M2Restore" } + "stop-loop" { return "M2Project (idle)`n $node stopped sketch M2Restore" } + default { throw "unexpected native CLI adapter: $stem" } + } + } + function Start-UiaRestorationProcess($state, $file, $arguments, $stem) { + $script:seedCalls.Add([pscustomobject]@{ stem = $stem; file = $file; arguments = $arguments }) + return "MOCK_HELD_ROOT_$stem" + } + function Assert-UiaRestorationHeldRoot {} + function Invoke-UiaRestorationCommand($state, $file, $arguments, $stem) { + $script:seedCalls.Add([pscustomobject]@{ stem = $stem; arguments = $arguments }) + return (($state.sessions | ForEach-Object { "name=$_`tpid=1" }) -join "`n") + } + Initialize-UiaRestorationSessions $state + Assert-Contract (($script:seedCalls.stem -join "|") -ceq + "register-project|canonical-project|create-loop|stop-loop|seed-0|seed-1|seed-list-1") ` + "actual seed adapter must use only the declared CLI and provider boundaries" + Assert-Contract ($state.seeds.Count -eq 2 -and $state.sessions[0] -ceq "graphcode-$node" -and + $state.project -ceq $state.projectDirectory.Replace('\', '/')) ` + "adapter must use actual canonical daemon path and exact session namespace" + $layout = Get-Content -LiteralPath $state.layoutPath -Raw | ConvertFrom-Json + Assert-Contract ($layout.schemaVersion -eq 2 -and $layout.tabs[0].focused -eq 0 -and + $layout.tabs[0].panes[1].id -ceq $state.paneIds[1] -and + (Get-Content -LiteralPath (Join-Path $state.support "terminal-layouts\shell-sessions\$($state.sessions[1])") -Raw) -ceq $node) ` + "prepared topology must match normal layout/ownership source without fabricating selected pane one" + foreach ($index in 0..1) { + $call = @($script:seedCalls | Where-Object { $_.stem -ceq "seed-$index" })[0] + $scriptText = Get-Content -LiteralPath (Join-Path $scratch "seed-$index.cmd") -Raw + Assert-Contract ($call.file -ceq "mock-pinned-zmx" -and $call.arguments[0] -ceq "--daemon" -and + $call.arguments[1] -ceq $state.sessions[$index] -and $call.arguments[3] -ceq "/Q" -and + $call.arguments[4] -ceq "/K" -and $scriptText.Contains('$v=17*19') -and + $scriptText.Contains('$PID') -and $scriptText.Contains($state.seeds[$index].nonce)) ` + "each seed must execute its distinct computed marker through the declared pinned-provider argv" + } + } finally { Remove-Item -LiteralPath $scratch -Recurse -Force } +} +Test-Route "actual unknown cleanup takes the lifetime hold branch" { + Import-Module (Join-Path $PSScriptRoot "..\ShellProcessOwnership.psm1") -ErrorAction Stop + $original = [InvalidOperationException]::new("original-input-refusal") + $cleanupFailure = [InvalidOperationException]::new("owned-job-empty-unproved") + $cleanupFailure.Data["GraphCodeValidationRootRetentionRequired"] = $true + $state = @{ root = "mock-root"; scope = @{ Closed = $false }; launches = @(); expectedCleanup = $cleanupFailure + failure = [Management.Automation.ErrorRecord]::new($original, "M2", "InvalidOperation", $null) } + function Close-ShellProcessScope { throw $state.expectedCleanup } + function Wait-UiaRestorationUnknown($state) { + Assert-Contract (-not $state.scope.Closed -and + [Object]::ReferenceEquals($state.failure.Exception.Data["M2CleanupFailure"], $state.expectedCleanup)) ` + "unknown cleanup must preserve the open scope and original/cleanup failures" + throw "MOCK_UNKNOWN_HOLD_ENTERED" + } + $failure = $null + try { Close-UiaRestorationState $state } catch { $failure = $_ } + Assert-Contract ($failure.Exception.Message -ceq "MOCK_UNKNOWN_HOLD_ENTERED" -and + $state.failure.Exception.Message -ceq "original-input-refusal") ` + "actual unknown branch must hold, not return success: observed=$failure original=$($state.failure.Exception.Message)" +} +Test-Route "session names remain case sensitive" { + $fixture = New-MarkerFixture + $fixture.state.sessions = @("graphcode-A", "graphcode-a") + $fixture.state.seeds[0].session = "graphcode-A" + $fixture.state.seeds[1].session = "graphcode-a" + $mapped = @(Resolve-UiaRestorationSessionMarkers $fixture.state $fixture.snapshots 42) + Assert-Contract ($mapped.Count -eq 2 -and $mapped[0].session -ceq "graphcode-A" -and + $mapped[1].session -ceq "graphcode-a") "agent and shell case-sensitive namespaces must not coalesce" +} +Test-Route "success evidence is withheld until cleanup proves quiescence" { + function Assert-UiaRestorationPeerCapability {} + function New-UiaRestorationState { return [pscustomobject]@{ daemon = "daemon"; root = "mock-root"; failure = $null } } + function Start-UiaRestorationProcess { return "held-daemon" } + function Confirm-UiaRestorationDaemonPeer { return @{ scope = "observer" } } + function Initialize-UiaRestorationSessions {} + function Open-UiaRestorationWorkspace($state, $stem) { return $stem } + function Select-UiaRestorationInitialPane {} + function Close-UiaRestorationWindow {} + function Invoke-UiaRestorationFreshInput { return @{ focusRepaired = $false } } + function Close-UiaRestorationState { throw "CLEANUP_PROOF_REFUSED" } + $emitted = [Collections.Generic.List[object]]::new() + $failure = $null + try { + Invoke-UiaRestoredTerminalRoute "shell" "daemon" "cli" "zmx" "root" | + ForEach-Object { $emitted.Add($_) } + } catch { $failure = $_ } + Assert-Contract ($emitted.Count -eq 0 -and $failure.Exception.Message -ceq "CLEANUP_PROOF_REFUSED") ` + "native evidence must not enter the output pipeline before successful cleanup: emitted=$($emitted.Count)" +} +Test-Route "partial raw streams remain separate without claiming EOF" { + $scratch = Join-Path ([IO.Path]::GetTempPath()) ("m2-partial-" + [guid]::NewGuid().ToString("N")) + Assert-Contract (-not (Test-Path -LiteralPath $scratch)) "partial capture scratch must be new" + $null = New-Item -ItemType Directory -Path (Join-Path $scratch "logs") + try { + $state = @{ root = $scratch; scope = "mock-scope"; launches = @(@{ process = "original-mock"; stem = "held" }) } + function Receive-ShellOwnedOutput($scope, $process, $MaximumCharacters) { + Assert-Contract ($scope -ceq "mock-scope" -and $process -ceq "original-mock" -and + $MaximumCharacters -eq 65536) "partial output must retain original launch and independent cursor bounds" + return [pscustomobject]@{ Output = "raw stdout`r`n tail "; Error = "raw stderr`n tail`t" } + } + Save-UiaRestorationPartialOutput $state @{ ElapsedMilliseconds = 1 } + Assert-Contract ( + [IO.File]::ReadAllText((Join-Path $scratch "logs\held.stdout.partial.log")) -ceq "raw stdout`r`n tail " -and + [IO.File]::ReadAllText((Join-Path $scratch "logs\held.stderr.partial.log")) -ceq "raw stderr`n tail`t" + ) "partial raw output must not merge streams, normalize lines, or append success/EOF assertions" + } finally { Remove-Item -LiteralPath $scratch -Recurse -Force } +} +foreach ($foreignAdjacent in @($false, $true)) { + Test-Route "Swift provider locator private copy foreign=$foreignAdjacent" { + $scratch = Join-Path ([IO.Path]::GetTempPath()) ("m2-provider-" + [guid]::NewGuid().ToString("N")) + Assert-Contract (-not (Test-Path -LiteralPath $scratch)) "provider scratch must be new" + $null = New-Item -ItemType Directory -Path (Join-Path $scratch "runtime") + try { + $state = @{ zmx = Join-Path $scratch "admitted-zmx.exe"; support = Join-Path $scratch "support" + daemon = Join-Path $scratch "runtime\graphcoded.exe"; cli = Join-Path $scratch "runtime\graphcode.exe" } + [IO.File]::WriteAllText($state.zmx, "HEADLESS_NONEXECUTABLE_PROVIDER_BYTES") + if ($foreignAdjacent) { + [IO.File]::WriteAllText((Join-Path $scratch "runtime\zmx.exe"), "FOREIGN_NONEXECUTABLE_BYTES") + $failure = $null + try { Initialize-UiaRestorationProviderBinary $state } catch { $failure = $_ } + Assert-Contract ($failure.Exception.Message.Contains("adjacent Swift provider differs") -and + -not (Test-Path -LiteralPath (Join-Path $state.support "bin\zmx.exe"))) ` + "normal Swift locator must not silently bind a foreign adjacent provider" + } else { + Initialize-UiaRestorationProviderBinary $state + Assert-Contract ((Get-FileHash -LiteralPath (Join-Path $state.support "bin\zmx.exe")).Hash -ceq + (Get-FileHash -LiteralPath $state.zmx).Hash) "private support-bin copy must preserve admitted bytes exactly" + } + } finally { Remove-Item -LiteralPath $scratch -Recurse -Force } + } +} +Assert-Contract ($script:routeCases -eq 25) "expected 25 executed headless route cases" +Write-Output "M2 route orchestration: PASS executed=$script:routeCases mock/source/managed-file-only" Write-Output "Windows terminal gate contract: PASS" exit 0 diff --git a/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 b/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 index 96334598a..ded72fdf2 100644 --- a/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 +++ b/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 @@ -13,6 +13,9 @@ param( [switch]$ActionBudgetContractOnly, [switch]$PreflightReportContractOnly, [switch]$NativeBindingsContractOnly, + [switch]$StaticNativeExportsContractOnly, + [switch]$NativePipePeerContractOnly, + [ValidateSet('All','SourceOnly')][string]$NativePipePeerCase = 'All', [ValidateSet('All','Accepted')][string]$NativeBindingCase = 'All', [switch]$SelectorRefusalContractOnly, [switch]$IdentityContractOnly, @@ -775,6 +778,7 @@ function Assert-CapRejects { 'CapNative|WaitForSingleObject|kernel32.dll|WaitForSingleObject', 'CapNative|TerminateProcess|kernel32.dll|TerminateProcess', 'CapNative|WaitNamedPipeW|kernel32.dll|WaitNamedPipeW', + 'CapNative|GetNamedPipeServerProcessId|kernel32.dll|GetNamedPipeServerProcessId', 'CapNative|GetWindowThreadProcessId|user32.dll|GetWindowThreadProcessId', 'CapNative|GetWindowRect|user32.dll|GetWindowRect', 'CapNative|GetClientRect|user32.dll|GetClientRect', @@ -812,9 +816,24 @@ function Assert-CapRejects { 'CapProcessJob|GetStdHandle|kernel32.dll|GetStdHandle', 'CapProcessJob|SetHandleInformation|kernel32.dll|SetHandleInformation' ) - $actual = @(Test-CapNativeBindings | ForEach-Object { "$($_.Type)|$($_.Method)|$($_.Library)|$($_.EntryPoint)" }) - Assert-CapTest ($actual.Count -eq 56 -and @(Compare-Object $expected $actual).Count -eq 0) 'Exact static56 export contract differs.' - return @{ subcases = 39; evidence = 'API export resolution only, no native window or GUI action.' } + $bindings = @(Test-CapNativeBindings) + $actual = @($bindings | ForEach-Object { "$($_.Type)|$($_.Method)|$($_.Library)|$($_.EntryPoint)" }) + Assert-CapTest ($expected.Count -eq 57 -and $actual.Count -eq $expected.Count -and + @(Compare-Object $expected $actual -CaseSensitive).Count -eq 0) 'Exact static57 export contract differs.' + $peer = @([CapNative].GetMethods([Reflection.BindingFlags]'Static,NonPublic') | + Where-Object Name -CEQ 'GetNamedPipeServerProcessId') + Assert-CapTest ($peer.Count -eq 1) 'Exact safe-handle pipe peer declaration differs.' + $parameters = @($peer[0].GetParameters()) + $imports = @($peer[0].GetCustomAttributes([Runtime.InteropServices.DllImportAttribute], $false)) + Assert-CapTest ($peer[0].ReturnType -eq [bool] -and $parameters.Count -eq 2 -and + $parameters[0].ParameterType -eq [Microsoft.Win32.SafeHandles.SafePipeHandle] -and + $parameters[1].ParameterType.FullName -ceq 'System.UInt32&' -and $parameters[1].IsOut -and + $imports.Count -eq 1 -and $imports[0].Value -ceq 'kernel32.dll' -and + $imports[0].EntryPoint -ceq 'GetNamedPipeServerProcessId' -and + $imports[0].SetLastError -and $imports[0].CharSet -eq [Runtime.InteropServices.CharSet]::None -and + -not $imports[0].ExactSpelling) 'Exact safe-handle pipe peer declaration differs.' + return @{ exports = $actual.Count; pipePeerDeclarations = $peer.Count + evidence = 'Exact declaration and API export resolution only; no native function, window or GUI action.' } } Invoke-CapTest 'legacy mutable seed journal proof is refused and exact numbered terminal proof is accepted' { $f = New-JournalTerminalFixture 'seed-reference' @@ -7690,6 +7709,240 @@ function Invoke-CapIdentityContracts { } } +if ($NativePipePeerContractOnly) { + $expectedNames = @( + 'pipe peer helper exposes only a safe pipe handle query' + 'pipe peer native source preserves error and nonzero guards' + 'pipe peer rejects null invalid and disposed handles before native access' + ) + if ($NativePipePeerCase -ceq 'All') { + $expectedNames += @( + 'pipe peer identifies the actual connected owned server within one deadline' + 'pipe peer preserves the kernel error for a nonpipe handle' + ) + } + + Invoke-CapTest 'pipe peer helper exposes only a safe pipe handle query' { + $method = [CapNative].GetMethod('PipeServerProcessId', [Reflection.BindingFlags]'Public,Static') + Assert-CapTest ($null -ne $method) 'Missing read-only pipe peer helper.' + $parameters = $method.GetParameters() + Assert-CapTest ($parameters.Count -eq 1 -and + $parameters[0].ParameterType -eq [Microsoft.Win32.SafeHandles.SafePipeHandle] -and + $method.ReturnType -eq [int]) 'Pipe peer helper must take a SafePipeHandle and return a PID only.' + return @{ parameters = 1; returns = 'System.Int32'; nativeCalls = 0 } + } + + Invoke-CapTest 'pipe peer native source preserves error and nonzero guards' { + $source = Get-Content -LiteralPath $modulePath -Raw + $declaration = [regex]::Matches($source, + '(?s)\[DllImport\("kernel32\.dll", SetLastError=true\)\]\s*private static extern bool GetNamedPipeServerProcessId\(Microsoft\.Win32\.SafeHandles\.SafePipeHandle pipe, out uint processId\);') + $methods = [regex]::Matches($source, + '(?s)public static int PipeServerProcessId\(Microsoft\.Win32\.SafeHandles\.SafePipeHandle pipe\) \{(?.*?)\n \}') + Assert-CapTest ($declaration.Count -eq 1 -and $methods.Count -eq 1) 'Expected exactly one safe-handle kernel declaration and public query.' + $body = $methods[0].Groups['body'].Value + $nullGuard = $body.IndexOf('pipe == null', [StringComparison]::Ordinal) + $closedGuard = $body.IndexOf('pipe.IsClosed', [StringComparison]::Ordinal) + $invalidGuard = $body.IndexOf('pipe.IsInvalid', [StringComparison]::Ordinal) + $query = $body.IndexOf('GetNamedPipeServerProcessId(pipe, out processId)', [StringComparison]::Ordinal) + Assert-CapTest ($nullGuard -ge 0 -and $closedGuard -gt $nullGuard -and + $invalidGuard -gt $closedGuard -and $query -gt $invalidGuard -and + $body.Contains('throw new Win32Exception(Marshal.GetLastWin32Error())') -and + $body.Contains('if (processId == 0) throw new InvalidOperationException(') -and + $body.Contains('return checked((int)processId);') -and + -not $body.Contains('DangerousGetHandle') -and + -not $body.Contains('OpenProcess') -and -not $body.Contains('CloseHandle')) ` + 'Pipe peer query lost guard ordering, kernel error, zero refusal or read-only handle ownership.' + Assert-CapTest ($source.Contains('[Microsoft.Win32.SafeHandles.SafePipeHandle].Assembly.Location')) ` + 'Native helper compilation must reference the actual SafePipeHandle assembly.' + $tokens = $null + $parseErrors = $null + $testAst = [Management.Automation.Language.Parser]::ParseFile($PSCommandPath, [ref]$tokens, [ref]$parseErrors) + $pipeCases = @($testAst.FindAll({ + param($node) + $node -is [Management.Automation.Language.CommandAst] -and + $node.GetCommandName() -ceq 'Invoke-CapTest' -and + $node.CommandElements.Count -ge 3 -and + $node.CommandElements[1] -is [Management.Automation.Language.StringConstantExpressionAst] -and + $node.CommandElements[1].Value -ceq 'pipe peer identifies the actual connected owned server within one deadline' + }, $true)) + Assert-CapTest ($parseErrors.Count -eq 0 -and $pipeCases.Count -eq 1) 'Owned pipe case must parse exactly once.' + $testSource = $pipeCases[0].CommandElements[2].Extent.Text + $posted = $testSource.IndexOf('$pendingRead = $server.ReadAsync($read, 0, $read.Length, $limit.Token)', [StringComparison]::Ordinal) + $write = $testSource.IndexOf('$pendingWrite = $client.WriteAsync($payload, 0, $payload.Length, $limit.Token)', [StringComparison]::Ordinal) + $writeWait = $testSource.IndexOf('$null = $pendingWrite.GetAwaiter().GetResult()', [StringComparison]::Ordinal) + Assert-CapTest ($posted -ge 0 -and $write -gt $posted -and $writeWait -gt $write) ` + 'Owned same-process pipe must post its reader before waiting for writer completion.' + Assert-CapTest ([regex]::Matches($testSource, '\[Threading\.CancellationTokenSource\]::new\(5000\)').Count -eq 1 -and + -not $testSource.Contains('CancelAfter(') -and -not $testSource.Contains('Start-Sleep') -and + $testSource.Contains('$primary.Data[''CapTestDetail''] = [pscustomobject]$observation') -and + $testSource.Contains('beforePid = $null; afterPid = $null') -and + $testSource.Contains('$observation.payloadBytes = $offset') -and + $testSource.Contains("@{ name = 'read'; task = `$pendingRead }") -and + $testSource.Contains("@{ name = 'write'; task = `$pendingWrite }")) ` + 'Owned pipe case must preserve one original deadline, partial observations and pending-task cleanup.' + return @{ declarations = 1; nativeCalls = 0; processControlCalls = 0 } + } + + Invoke-CapTest 'pipe peer rejects null invalid and disposed handles before native access' { + $zero = [Microsoft.Win32.SafeHandles.SafePipeHandle]::new([IntPtr]::Zero, $false) + $minusOne = [Microsoft.Win32.SafeHandles.SafePipeHandle]::new([IntPtr]::new(-1), $false) + $disposed = [Microsoft.Win32.SafeHandles.SafePipeHandle]::new([IntPtr]::new(-2), $false) + $disposed.Dispose() + try { + $cases = @( + @{ handle = $null; expected = [ArgumentNullException] }, + @{ handle = $zero; expected = [ArgumentException] }, + @{ handle = $minusOne; expected = [ArgumentException] }, + @{ handle = $disposed; expected = [ObjectDisposedException] } + ) + $refused = 0 + foreach ($case in $cases) { + $failure = $null + try { $null = [CapNative]::PipeServerProcessId($case.handle) } catch { + $failure = $_.Exception + while ($failure.InnerException) { $failure = $failure.InnerException } + } + Assert-CapTest ($null -ne $failure -and $failure.GetType() -eq $case.expected) ` + 'Null, invalid or disposed pipe handle did not fail before native access.' + $refused++ + } + return @{ refused = $refused; nativeCalls = 0; handlesOwned = 0 } + } finally { + $zero.Dispose() + $minusOne.Dispose() + $disposed.Dispose() + } + } + + if ($NativePipePeerCase -ceq 'All') { + Invoke-CapTest 'pipe peer identifies the actual connected owned server within one deadline' { + $pipeName = 'graphcode-peer-contract-' + [guid]::NewGuid().ToString('N') + $limit = [Threading.CancellationTokenSource]::new(5000) + $server = $null + $client = $null + $waiting = $null + $pendingRead = $null + $pendingWrite = $null + $primary = $null + $observation = [ordered]@{ + stage = 'starting'; connected = $false; beforePid = $null; afterPid = $null + readPosted = $false; writePosted = $false; writeCompleted = $false + payloadBytes = 0; payloadMatched = $null; deadlineMilliseconds = 5000 + } + try { + $server = [IO.Pipes.NamedPipeServerStream]::new($pipeName, [IO.Pipes.PipeDirection]::InOut, + 1, [IO.Pipes.PipeTransmissionMode]::Byte, + ([IO.Pipes.PipeOptions]::Asynchronous -bor [IO.Pipes.PipeOptions]::CurrentUserOnly)) + $client = [IO.Pipes.NamedPipeClientStream]::new('.', $pipeName, + [IO.Pipes.PipeDirection]::InOut, [IO.Pipes.PipeOptions]::Asynchronous) + $waiting = $server.WaitForConnectionAsync($limit.Token) + $observation.stage = 'connecting' + $null = $client.ConnectAsync($limit.Token).GetAwaiter().GetResult() + $null = $waiting.GetAwaiter().GetResult() + $observation.connected = $true + $observation.stage = 'query-before' + $before = [CapNative]::PipeServerProcessId($client.SafePipeHandle) + $observation.beforePid = $before + Assert-CapTest ($before -gt 0 -and $before -eq [Environment]::ProcessId) ` + 'Kernel peer differs from the known owned same-process server.' + $payload = [byte[]]@(11, 22, 33) + $read = [byte[]]::new($payload.Length) + # Neither pipe endpoint may wait for its peer before that peer has posted I/O. + $observation.stage = 'posting-read' + $pendingRead = $server.ReadAsync($read, 0, $read.Length, $limit.Token) + $observation.readPosted = $true + $observation.stage = 'posting-write' + $pendingWrite = $client.WriteAsync($payload, 0, $payload.Length, $limit.Token) + $observation.writePosted = $true + $offset = 0 + while ($offset -lt $read.Length) { + $observation.stage = 'waiting-read' + $count = $pendingRead.GetAwaiter().GetResult() + $pendingRead = $null + Assert-CapTest ($count -gt 0) 'Owned pipe ended before its complete payload.' + $offset += $count + $observation.payloadBytes = $offset + if ($offset -lt $read.Length) { + $pendingRead = $server.ReadAsync($read, $offset, ($read.Length - $offset), $limit.Token) + } + } + $observation.stage = 'waiting-write' + $null = $pendingWrite.GetAwaiter().GetResult() + $observation.writeCompleted = $true + $observation.stage = 'query-after' + $after = [CapNative]::PipeServerProcessId($client.SafePipeHandle) + $observation.afterPid = $after + $observation.payloadMatched = ($read -join ',') -ceq ($payload -join ',') + Assert-CapTest ($after -eq $before -and ($read -join ',') -ceq ($payload -join ',')) ` + 'Kernel peer or actual owned pipe payload changed during the live connection.' + $observation.stage = 'complete' + return @{ before = $before; after = $after; payloadBytes = $offset; observation = $observation + scope = 'ACTUAL_OBSERVER_CONNECTION_ONLY_NOT_ANOTHER_APP_CONNECTION' } + } catch { + $primary = $_.Exception + $primary.Data['CapTestDetail'] = [pscustomobject]$observation + throw + } finally { + $limit.Cancel() + if ($null -ne $client) { $client.Dispose() } + if ($null -ne $server) { $server.Dispose() } + try { + foreach ($entry in @( + @{ name = 'connection'; task = $waiting }, + @{ name = 'read'; task = $pendingRead }, + @{ name = 'write'; task = $pendingWrite } + )) { + if ($null -ne $entry.task) { + try { $null = $entry.task.GetAwaiter().GetResult() } catch { + $cause = $_.Exception + while ($cause.InnerException) { $cause = $cause.InnerException } + if ($cause -isnot [OperationCanceledException] -or -not $limit.IsCancellationRequested) { + if ($null -eq $primary) { throw } + $primary.Data["CapPipeCleanupFailure.$($entry.name)"] = $cause.ToString() + } + } + } + } + } finally { $limit.Dispose() } + } + } + + Invoke-CapTest 'pipe peer preserves the kernel error for a nonpipe handle' { + # -2 is the current-thread pseudo-handle, never a pipe; this wrapper owns nothing. + $notPipe = [Microsoft.Win32.SafeHandles.SafePipeHandle]::new([IntPtr]::new(-2), $false) + try { + $failure = $null + try { $null = [CapNative]::PipeServerProcessId($notPipe) } catch { + $failure = $_.Exception + while ($failure.InnerException) { $failure = $failure.InnerException } + } + Assert-CapTest ($failure -is [ComponentModel.Win32Exception] -and $failure.NativeErrorCode -eq 6) ` + 'Expected the real ERROR_INVALID_HANDLE from a nonpipe handle.' + return @{ nativeErrorCode = $failure.NativeErrorCode; handlesOwned = 0 } + } finally { $notPipe.Dispose() } + } + } + + foreach ($path in $sourceHashesBefore.Keys) { + Assert-CapTest ((Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash -ceq $sourceHashesBefore[$path]) ` + "Source changed during pipe peer contracts: $path" + } + $names = @($results | ForEach-Object { $_.name }) + Assert-CapTest ($names.Count -eq $expectedNames.Count -and + ($names -join '|') -ceq ($expectedNames -join '|') -and + @($names | Sort-Object -Unique).Count -eq $expectedNames.Count) 'Pipe peer literal case registry differs.' + $passed = @($results | Where-Object passed -EQ $true).Count + $failed = @($results | Where-Object passed -EQ $false).Count + $report = [pscustomobject]@{ suite = 'UiCaptureNativePipePeerContracts'; executed = $results.Count + passed = $passed; failed = $failed; skipped = 0; cases = @($results); sourceHashesBeforeImport = $sourceHashesBefore + mode = if ($NativePipePeerCase -ceq 'SourceOnly') { 'PURE_SOURCE_AND_MANAGED_GUARDS_NO_KERNEL_PIPE_QUERY' } + else { 'OWNED_SAME_PROCESS_PIPE_NO_GUI_NO_PROCESS_CONTROL' } } + $report | ConvertTo-Json -Depth 25 | Set-Content -LiteralPath (Join-Path $scratch 'report.json') -Encoding utf8NoBOM + $report | Select-Object suite,executed,passed,failed,skipped | ConvertTo-Json -Compress + if ($results.Count -ne $expectedNames.Count -or $passed -ne $expectedNames.Count -or $failed -ne 0) { exit 1 } + exit 0 +} + if ($IdentityContractOnly) { Invoke-CapIdentityContracts foreach ($path in $sourceHashesBefore.Keys) { @@ -7719,6 +7972,48 @@ if ($SelectorRefusalContractOnly) { exit 0 } +if ($StaticNativeExportsContractOnly) { + $parseErrors = $null + $ast = [Management.Automation.Language.Parser]::ParseFile($PSCommandPath, [ref]$null, [ref]$parseErrors) + Assert-CapTest ($parseErrors.Count -eq 0) 'Static export source must parse without errors.' + $cases = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.CommandAst] -and + $node.GetCommandName() -ceq 'Invoke-CapTest' -and $node.CommandElements.Count -ge 3 -and + $node.CommandElements[1].Extent.Text -ceq "'journal native bindings match the exact job-ownership declaration contract'" + }, $true)) + Assert-CapTest ($cases.Count -eq 1) 'Expected one canonical static export contract.' + $contract = [scriptblock]::Create($cases[0].CommandElements[2].ScriptBlock.EndBlock.Extent.Text) + $resolved = @(Test-CapNativeBindings) + Invoke-CapTest 'actual exact static exports and safe-handle peer declaration' { & $contract } + foreach ($scenario in @('missing','extra','duplicate','wrong-library','wrong-case')) { + Invoke-CapTest "static exports refuse $scenario without relaxing the allowlist" { + $bindings = @($resolved | ForEach-Object { + [pscustomobject]@{ Name = $_.Name; Type = $_.Type; Method = $_.Method + Library = $_.Library; EntryPoint = $_.EntryPoint } + }) + switch ($scenario) { + 'missing' { $bindings = @($bindings | Where-Object Method -CNE 'GetNamedPipeServerProcessId') } + 'extra' { $bindings += [pscustomobject]@{ Type='CapNative'; Method='UnknownExport'; Library='kernel32.dll'; EntryPoint='UnknownExport' } } + 'duplicate' { $bindings[0] = $bindings[-1] } + 'wrong-library' { $bindings[0].Library = 'foreign.dll' } + 'wrong-case' { $bindings[0].Method = $bindings[0].Method.ToLowerInvariant() } + } + function Test-CapNativeBindings { return $bindings } + $null = Assert-CapRejects { & $contract } 'Exact static57 export contract differs.' + return @{ nativeFunctionCalls = 0; mutation = $scenario } + } + } + $passed = @($results | Where-Object passed -EQ $true).Count + $failed = @($results | Where-Object passed -EQ $false).Count + $report = [pscustomobject]@{ suite='UiCaptureStaticNativeExports'; executed=$results.Count + passed=$passed; failed=$failed; skipped=0; cases=@($results); mode='DECLARATION_EXPORT_RESOLUTION_NO_NATIVE_FUNCTION_CALLS' } + $report | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath (Join-Path $scratch 'report.json') -Encoding utf8NoBOM + $report | Select-Object suite,executed,passed,failed,skipped | ConvertTo-Json -Compress + if ($results.Count -ne 6 -or $passed -ne 6 -or $failed -ne 0) { exit 1 } + exit 0 +} + if ($NativeBindingsContractOnly) { Invoke-CapLiveNativeBindingContracts $passed = @($results | Where-Object passed -EQ $true).Count diff --git a/Tools/windows/Tests/WindowsCiTopology.Tests.ps1 b/Tools/windows/Tests/WindowsCiTopology.Tests.ps1 index 0b055ae52..9f1c3addd 100644 --- a/Tools/windows/Tests/WindowsCiTopology.Tests.ps1 +++ b/Tools/windows/Tests/WindowsCiTopology.Tests.ps1 @@ -1,5 +1,5 @@ [CmdletBinding()] -param() +param([switch] $RetentionPropagationOnly) $ErrorActionPreference = "Stop" $repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..\..\..") @@ -81,9 +81,163 @@ function Test-CompiledCompatibilityWrapper([string] $Text) { $withoutCompilerFlags -notmatch 'ValidationRunner\.Tests\.ps1|--suite\b|--self-test\b|--fixture\b' } +function Test-GobjectSwiftRoute([string] $Job) { + $steps = @([regex]::Matches($Job, '(?ms)^ - .*?(?=^ - |\z)') | + ForEach-Object Value | Where-Object { $_ -match 'name: Prefetch verified official gobject package' }) + return $steps.Count -eq 1 -and + $steps[0] -match '(?m)^ timeout-minutes: 5[ \t]*$' -and + $steps[0].Contains('. .\.ci-tools\environment.ps1') -and + $steps[0].Contains('$env:GRAPHCODE_VALIDATION_ASSEMBLY = & .\Tools\windows\Build-CompiledValidationRunner.ps1') -and + $steps[0].Contains('./Tools/windows/Initialize-GobjectPackageCache.ps1 -PublishJobEnvironment') -and + $steps[0].Contains('GH_TOKEN: ${{ github.token }}') -and + $Job -notmatch 'ValidationRunner\.exe|ValidationRunner\.Tests\.ps1|Invoke-ValidationRunnerContract\.ps1|--suite\b|--self-test\b|--fixture\b' -and + $Job.IndexOf('name: Prefetch verified official gobject package') -lt + $Job.IndexOf('name: Run real zmx empty-list test') -and + $Job.Contains('./Tools/windows/Test-RealZmxEmptyList.ps1') +} + +function Get-GobjectRetentionBody([string] $Text) { + $parseErrors = $null + $ast = [Management.Automation.Language.Parser]::ParseInput($Text, [ref]$null, [ref]$parseErrors) + if ($parseErrors.Count -gt 0) { throw "Invalid prefetch helper syntax" } + $guards = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.IfStatementAst] -and + $node.Clauses[0].Item1.Extent.Text.Contains('OwnedChildSupervisor]::RequiresLifetimeHold') + }, $true)) + if ($guards.Count -ne 1) { throw "Expected one production retention guard" } + $body = $guards[0].Clauses[0].Item2 + if ($body.Statements.Count -ne 3 -or + $body.Statements[0].Extent.Text -cne '[Console]::Out.WriteLine("GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven")' -or + $body.Statements[1].Extent.Text -cne '[Console]::Out.Flush()' -or + -not $body.Statements[2].Extent.Text.Contains('HoldRetainedResourcesAsync().GetAwaiter().GetResult()')) { + throw "Retention sentinel and flush must precede the original lifetime hold" + } + return $body +} + $portJobs = Get-WorkflowJobs $portWorkflow $hardeningJobs = Get-WorkflowJobs $hardeningWorkflow +$prefetchHelper = Join-Path $repoRoot "Tools\windows\Initialize-GobjectPackageCache.ps1" +Assert-Contract (Test-Path -LiteralPath $prefetchHelper -PathType Leaf) ` + "the Swift provider build needs a bounded official gobject package prefetch helper" +. $prefetchHelper +$retentionSource = (Get-Content -LiteralPath $prefetchHelper -Raw).Replace("`r`n", "`n") +$retentionBody = Get-GobjectRetentionBody $retentionSource +Assert-Contract ($retentionBody.Statements.Count -eq 3) ` + "unknown helper lifetime must announce and flush retention before holding" +if ($RetentionPropagationOnly) { + & (Join-Path $repoRoot "Tools\windows\Import-CompiledValidationAssembly.ps1") ` + -AssemblyPath $env:GRAPHCODE_VALIDATION_ASSEMBLY ` + -RequiredType "GraphCode.Validation.Ownership.RetentionSignalObserver" | Out-Null + $script:retentionSignals = 0 + $observer = [GraphCode.Validation.Ownership.RetentionSignalObserver]::new( + [Action]{ $script:retentionSignals++ }) + $writer = [IO.StringWriter]::new() + $priorWriter = [Console]::Out + try { + [Console]::SetOut($writer) + $announcer = [scriptblock]::Create(($retentionBody.Statements[0..1] | + ForEach-Object { $_.Extent.Text }) -join "`n") + & $announcer + $text = $writer.ToString() + $observer.OnText($text.Substring(0, 20)) + Assert-Contract ($script:retentionSignals -eq 0) "partial stdout cannot imply a completed retention signal" + $observer.OnText($text.Substring(20)) + Assert-Contract ($script:retentionSignals -eq 1) "the flushed helper marker must propagate through the actual outer observer" + $observer.OnText($text) + Assert-Contract ($script:retentionSignals -eq 1) "the actual observer must signal retention once" + } finally { + [Console]::SetOut($priorWriter) + $writer.Dispose() + } + Write-Host "Gobject retention propagation: 1 managed case, $checks checks, 0 failures; no native lifetime hold" + exit 0 +} +foreach ($mutation in @( + $retentionSource.Replace('GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven', 'wrong-retention-marker'), + $retentionSource.Replace('[Console]::Out.Flush()', ''), + $retentionSource.Replace('[Console]::Out.Flush()', '[Console]::Out.WriteLine("not-a-flush")') + )) { + $refused = $false + try { $null = Get-GobjectRetentionBody $mutation } catch { $refused = $true } + Assert-Contract ($mutation -cne $retentionSource -and $refused) ` + "retention source must reject missing or foreign markers and missing flush before hold" +} +Assert-GobjectArchiveProof 1867503 "D572886E8672A46AC6C740E3FE3A1F1C9D9A1A9F09D59FD62328A41FCDB70196" +Assert-Contract $true "the exact official archive byte proof must pass" +foreach ($proof in @( + @(1867502, "D572886E8672A46AC6C740E3FE3A1F1C9D9A1A9F09D59FD62328A41FCDB70196"), + @(1867503, "D572886E8672A46AC6C740E3FE3A1F1C9D9A1A9F09D59FD62328A41FCDB70195") + )) { + $refused = $false + try { Assert-GobjectArchiveProof $proof[0] $proof[1] } catch { $refused = $true } + Assert-Contract $refused "wrong archive size or digest must stop before fetch" +} +$expectedGobjectHash = "gobject-0.3.2-Skun7F6HogCMynX2JqeSHS7xr-8pK4ob-qRFIcEasVi3" +$fetchResult = [pscustomobject]@{ + ExitCode = 0; StandardOutput = "$expectedGobjectHash`n"; StandardError = "" + Cleanup = [pscustomobject]@{ CanRelease = $true; ResourcesClosed = $true } +} +Assert-GobjectFetchProof $fetchResult +Assert-Contract $true "one exact pinned package hash and complete owned cleanup must pass" +foreach ($mutation in @("exit", "empty", "foreign", "duplicate", "blank", "stderr", "cleanup", "resources")) { + $candidate = [pscustomobject]@{ + ExitCode = 0; StandardOutput = "$expectedGobjectHash`n"; StandardError = "" + Cleanup = [pscustomobject]@{ CanRelease = $true; ResourcesClosed = $true } + } + switch ($mutation) { + "exit" { $candidate.ExitCode = 1 } + "empty" { $candidate.StandardOutput = "" } + "foreign" { $candidate.StandardOutput = "wrong-package`n" } + "duplicate" { $candidate.StandardOutput += "$expectedGobjectHash`n" } + "blank" { $candidate.StandardOutput += "`n" } + "stderr" { $candidate.StandardError = "fetch error" } + "cleanup" { $candidate.Cleanup.CanRelease = $false } + "resources" { $candidate.Cleanup.ResourcesClosed = $false } + } + $refused = $false + try { Assert-GobjectFetchProof $candidate } catch { $refused = $true } + Assert-Contract $refused "fetch must reject $mutation rather than treating exit zero as equivalence" +} + +$fetchStart = New-GobjectFetchStart "D:\owned\zig.exe" "D:\owned\project" ` + "D:\owned\global" "D:\owned\official.tar.gz" "D:\owned\temp" +Assert-Contract ($fetchStart.WorkingDirectory -ceq "D:\owned\project" -and + $fetchStart.ArgumentList.Count -eq 4 -and $fetchStart.Arguments -ceq "" -and + $fetchStart.ArgumentList[0] -ceq "fetch" -and + $fetchStart.ArgumentList[1] -ceq "--global-cache-dir" -and + $fetchStart.ArgumentList[2] -ceq "D:\owned\global" -and + $fetchStart.ArgumentList[3] -ceq "D:\owned\official.tar.gz" -and + $fetchStart.Environment["ZIG_GLOBAL_CACHE_DIR"] -ceq "D:\owned\global" -and + $fetchStart.Environment["TEMP"] -ceq "D:\owned\temp" -and + -not $fetchStart.Environment.ContainsKey("GH_TOKEN") -and + -not $fetchStart.Environment.ContainsKey("GITHUB_TOKEN") -and + -not $fetchStart.Environment.ContainsKey("ZIG_LOCAL_CACHE_DIR")) ` + "local fetch must preserve actual argument transport, private paths and token exclusion" +foreach ($token in @("", "controlled token")) { + $refused = $false + try { Save-OfficialGobjectArchive "D:\not-created.tar.gz" $token } catch { $refused = $true } + Assert-Contract $refused "missing or malformed job tokens must refuse before any transport" +} +$prefetchSource = $retentionSource +Assert-Contract ($prefetchSource.Contains('releases/assets/493157198') -and + $prefetchSource.Contains('"application/octet-stream"') -and + $prefetchSource.Contains('[TimeSpan]::FromSeconds(60)') -and + $prefetchSource.Contains('[TimeSpan]::FromSeconds(120)') -and + $prefetchSource.Contains('$output.Write($buffer, 0, $read)') -and + $prefetchSource.Contains('CreateOwnedByCurrentUser') -and + $prefetchSource.Contains('[IO.FileMode]::CreateNew') -and + $prefetchSource.Contains('OwnedChildSupervisor]::RunAsync') -and + $prefetchSource.Contains('HoldRetainedResourcesAsync') -and + $prefetchSource.IndexOf('Assert-GobjectArchiveProof $archiveInfo') -lt + $prefetchSource.IndexOf('Assert-GobjectFetchProof $result') -and + $prefetchSource.IndexOf('Assert-GobjectFetchProof $result') -lt + $prefetchSource.IndexOf('[IO.File]::AppendAllText($env:GITHUB_ENV') -and + $prefetchSource -notmatch '--save|--insecure|releases/download|Set-Acl|icacls|Copy-Item|Start-Sleep') ` + "official transport must be bounded and binary, exclusive, source-owned and publish only after both proofs" + Assert-Contract $portJobs.Contains("validation-runner") ` "Windows port CI needs a dedicated validation-runner job" $runnerJob = $portJobs["validation-runner"] @@ -108,6 +262,24 @@ Assert-Contract ($runnerJob -notmatch 'bootstrap\.ps1|\.ci-providers|validate\.p "the source-only validation runner job must not bootstrap providers or hide failures" $spikesSwift = $portJobs["spikes-swift"] +Assert-Contract (Test-GobjectSwiftRoute $spikesSwift) ` + "the Swift job must compile existing bindings only and prefetch before its unchanged required real-empty test" +foreach ($suffix in @( + "`n ValidationRunner.exe", + "`n ValidationRunner.Tests.ps1", + "`n Invoke-ValidationRunnerContract.ps1", + "`n command --suite owned", + "`n command --fixture owned", + "`n command --self-test" + )) { + Assert-Contract (-not (Test-GobjectSwiftRoute ($spikesSwift + $suffix))) ` + "build-only preparation must not admit duplicate runner, test, contract, suite or fixture execution" +} +Assert-Contract ($spikesSwift.Contains('if: always()') -and + $spikesSwift.Contains('${{ runner.temp }}\gobject-prefetch-*\**') -and + $spikesSwift.Contains('include-hidden-files: true') -and + $spikesSwift -notmatch 'continue-on-error|permissions:') ` + "prefetch failure evidence must be retained without waiving failure or adding permissions" Assert-Contract ($spikesSwift -notmatch 'ValidationRunner\.Tests\.ps1|Invoke-ValidationRunnerContract\.ps1|ValidationRunner\.exe') ` "the sequential Swift job must not repeat the validation runner contract" diff --git a/Tools/windows/UiCaptureDriver.psm1 b/Tools/windows/UiCaptureDriver.psm1 index 194d4598c..5fe7863f7 100644 --- a/Tools/windows/UiCaptureDriver.psm1 +++ b/Tools/windows/UiCaptureDriver.psm1 @@ -279,6 +279,8 @@ public static class CapNative { [DllImport("user32.dll", SetLastError=true)] private static extern bool PostMessage(IntPtr window, uint message, UIntPtr wParam, IntPtr lParam); [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] private static extern bool WaitNamedPipeW(string name, uint timeout); + [DllImport("kernel32.dll", SetLastError=true)] + private static extern bool GetNamedPipeServerProcessId(Microsoft.Win32.SafeHandles.SafePipeHandle pipe, out uint processId); private static IntPtr EnterPerMonitorV2() { IntPtr previous = SetThreadDpiAwarenessContext(new IntPtr(-4)); @@ -884,6 +886,18 @@ public static class CapNative { return Marshal.GetLastWin32Error(); } + // This connection's kernel peer is not evidence of another client's connection or process custody. + public static int PipeServerProcessId(Microsoft.Win32.SafeHandles.SafePipeHandle pipe) { + if (pipe == null) throw new ArgumentNullException("pipe"); + if (pipe.IsClosed) throw new ObjectDisposedException("pipe"); + if (pipe.IsInvalid) throw new ArgumentException("A valid connected pipe handle is required.", "pipe"); + uint processId; + if (!GetNamedPipeServerProcessId(pipe, out processId)) + throw new Win32Exception(Marshal.GetLastWin32Error()); + if (processId == 0) throw new InvalidOperationException("The pipe server PID is unavailable."); + return checked((int)processId); + } + [DllImport("user32.dll", SetLastError=true)] private static extern bool PrintWindow(IntPtr window, IntPtr dc, uint flags); } @@ -894,6 +908,7 @@ if (-not ('CapNative' -as [type])) { [System.Drawing.Bitmap].Assembly.Location, [System.Drawing.Graphics].Assembly.Location, [System.Drawing.Color].Assembly.Location, + [Microsoft.Win32.SafeHandles.SafePipeHandle].Assembly.Location, (Join-Path $PSHOME 'System.Private.Windows.Core.dll'), (Join-Path $PSHOME 'System.Private.Windows.GdiPlus.dll'), 'System.Collections.dll', diff --git a/Tools/windows/ValidationFixtureDirectoryNative.cs b/Tools/windows/ValidationFixtureDirectoryNative.cs index 8f44ead8b..51de71353 100644 --- a/Tools/windows/ValidationFixtureDirectoryNative.cs +++ b/Tools/windows/ValidationFixtureDirectoryNative.cs @@ -1,11 +1,44 @@ using System; using System.ComponentModel; +using System.IO; using System.Runtime.InteropServices; +using System.Text.RegularExpressions; +using System.Collections.Generic; namespace GraphCode; public static class ValidationFixtureDirectoryNative { + [StructLayout(LayoutKind.Sequential)] + private struct SecurityAttributes { public uint Length; public IntPtr Descriptor; public int Inherit; } + [StructLayout(LayoutKind.Sequential)] + private struct FileInformation + { + public uint Attributes; + public System.Runtime.InteropServices.ComTypes.FILETIME Creation, Access, Write; + public uint Volume, SizeHigh, SizeLow, Links, IndexHigh, IndexLow; + } + [DllImport("kernel32.dll")] private static extern IntPtr GetCurrentProcess(); + [DllImport("kernel32.dll", SetLastError = true)] private static extern bool CloseHandle(IntPtr handle); + [DllImport("advapi32.dll", SetLastError = true)] private static extern bool OpenProcessToken(IntPtr process, uint access, out IntPtr token); + [DllImport("advapi32.dll", SetLastError = true)] private static extern bool GetTokenInformation(IntPtr token, int kind, IntPtr buffer, uint length, out uint needed); + [DllImport("advapi32.dll", SetLastError = true)] private static extern uint GetLengthSid(IntPtr sid); + [DllImport("advapi32.dll", SetLastError = true)] private static extern bool IsValidSid(IntPtr sid); + [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool ConvertSidToStringSidW(IntPtr sid, out IntPtr text); + [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool ConvertStringSecurityDescriptorToSecurityDescriptorW(string text, uint revision, out IntPtr descriptor, out uint size); + [DllImport("kernel32.dll", SetLastError = true)] private static extern IntPtr LocalFree(IntPtr memory); + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, EntryPoint = "CreateDirectoryW", ExactSpelling = true, SetLastError = true)] + private static extern bool CreateDirectoryWithOwner(string path, ref SecurityAttributes attributes); + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern IntPtr CreateFileW(string path, uint access, uint share, IntPtr security, uint disposition, uint flags, IntPtr template); + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GetFileInformationByHandle(IntPtr handle, out FileInformation information); + [DllImport("advapi32.dll")] + private static extern uint GetSecurityInfo(IntPtr handle, int kind, uint information, out IntPtr owner, + IntPtr group, IntPtr dacl, IntPtr sacl, out IntPtr descriptor); + [DllImport("advapi32.dll")] private static extern bool EqualSid(IntPtr first, IntPtr second); [DllImport("kernel32.dll", CharSet = CharSet.Unicode, ExactSpelling = true, SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool CreateDirectoryW(string path, IntPtr securityAttributes); @@ -18,4 +51,158 @@ public static void Create(string path) throw new Win32Exception(error, $"Exclusive validation fixture create failed (Win32 {error}): {path}"); } } + + public static void CreateOwnedWith(string path, Func userSid, + Func create, Action verify) + { + if (userSid == null || create == null || verify == null) throw new ArgumentNullException(); + if (string.IsNullOrWhiteSpace(path) || !Path.IsPathRooted(path) || + !string.Equals(Path.GetFullPath(path), path, StringComparison.OrdinalIgnoreCase)) + throw new ArgumentException("Owned fixture path must be a normalized absolute path.", nameof(path)); + byte[] sid = userSid(); + if (sid == null || sid.Length < 8) throw new InvalidOperationException("Current token user SID is unavailable."); + int error = create(path, sid); + if (error != 0) throw new Win32Exception(error, $"Exclusive owned fixture creation failed (Win32 {error}): {path}"); + verify(path, sid); + } + + public static string CurrentUserDescriptor(string sid) + { + if (sid == null || !Regex.IsMatch(sid, @"\AS-1-(?:\d+-)+\d+\z")) + throw new ArgumentException("Current user SID descriptor is invalid.", nameof(sid)); + return "O:" + sid + "D:P(A;OICI;FA;;;" + sid + ")"; + } + + public static void ReleaseOwnedWith(Exception? primary, params Action[] releases) + { + var failures = new List(); + foreach (Action release in releases) + { + try { release(); } + catch (Exception failure) { failures.Add(failure); } + } + if (failures.Count == 0) return; + var cleanup = new AggregateException("Owned fixture resource release failed.", failures); + if (primary != null) primary.Data["OwnedFixtureReleaseFailures"] = cleanup; + else throw cleanup; + } + + private static void FreeOwnedLocal(IntPtr memory) + { + if (memory == IntPtr.Zero) return; + IntPtr remaining = LocalFree(memory); + int error = Marshal.GetLastWin32Error(); + if (remaining == IntPtr.Zero) return; + if (error == 0) throw new InvalidOperationException("Owned LocalFree failed without a native error."); + throw new Win32Exception(error); + } + + private static void CloseOwnedHandle(IntPtr handle) + { + bool closed = CloseHandle(handle); + int error = Marshal.GetLastWin32Error(); + if (closed) return; + if (error == 0) throw new InvalidOperationException("Owned CloseHandle failed without a native error."); + throw new Win32Exception(error); + } + + public static void CreateOwnedByCurrentUser(string path) + { + var parent = new DirectoryInfo(Path.GetDirectoryName(path) ?? throw new ArgumentException("Missing parent.")); + for (var directory = parent; directory != null; directory = directory.Parent) + if (!directory.Exists || (directory.Attributes & FileAttributes.ReparsePoint) != 0) + throw new InvalidOperationException("Owned fixture parent is missing or a reparse point."); + CreateOwnedWith(path, ReadCurrentUserSid, CreateWithOwner, VerifyOwner); + } + + private static byte[] ReadCurrentUserSid() + { + if (!OpenProcessToken(GetCurrentProcess(), 0x0008, out IntPtr token)) throw new Win32Exception(Marshal.GetLastWin32Error()); + IntPtr buffer = IntPtr.Zero; + Exception? primary = null; + try + { + bool first = GetTokenInformation(token, 1, IntPtr.Zero, 0, out uint needed); + int error = Marshal.GetLastWin32Error(); + if (first || error != 122 || needed < IntPtr.Size || needed > 65536) + throw new Win32Exception(error, "Current token user sizing failed."); + buffer = Marshal.AllocHGlobal(checked((int)needed)); + if (!GetTokenInformation(token, 1, buffer, needed, out uint actual)) throw new Win32Exception(Marshal.GetLastWin32Error()); + if (actual > needed || actual < IntPtr.Size) throw new InvalidOperationException("Token user buffer length differs."); + IntPtr sid = Marshal.ReadIntPtr(buffer); + if (sid.ToInt64() < buffer.ToInt64() || sid.ToInt64() > buffer.ToInt64() + actual - 8) + throw new InvalidOperationException("Token SID is outside its retained buffer."); + if (!IsValidSid(sid)) throw new InvalidOperationException("Current token user SID is invalid."); + uint length = GetLengthSid(sid); + if (length < 8 || length > actual || sid.ToInt64() + length > buffer.ToInt64() + actual) + throw new InvalidOperationException("Current token SID length is invalid."); + byte[] bytes = new byte[length]; + Marshal.Copy(sid, bytes, 0, bytes.Length); + return bytes; + } + catch (Exception failure) { primary = failure; throw; } + finally + { + ReleaseOwnedWith(primary, + () => { if (buffer != IntPtr.Zero) Marshal.FreeHGlobal(buffer); }, + () => CloseOwnedHandle(token)); + } + } + + private static int CreateWithOwner(string path, byte[] sid) + { + GCHandle pin = default; + IntPtr sidText = IntPtr.Zero, descriptor = IntPtr.Zero; + Exception? primary = null; + try + { + pin = GCHandle.Alloc(sid, GCHandleType.Pinned); + if (!ConvertSidToStringSidW(pin.AddrOfPinnedObject(), out sidText)) throw new Win32Exception(Marshal.GetLastWin32Error()); + string owner = Marshal.PtrToStringUni(sidText) ?? throw new InvalidOperationException("SID conversion failed."); + if (!ConvertStringSecurityDescriptorToSecurityDescriptorW(CurrentUserDescriptor(owner), 1, out descriptor, out _)) + throw new Win32Exception(Marshal.GetLastWin32Error()); + var attributes = new SecurityAttributes { Length = checked((uint)Marshal.SizeOf()), Descriptor = descriptor }; + bool created = CreateDirectoryWithOwner(path, ref attributes); + int error = created ? 0 : Marshal.GetLastWin32Error(); + if (!created && error == 0) throw new InvalidOperationException("Owned fixture create failed without a native error."); + if (!created) throw new Win32Exception(error); + return error; + } + catch (Exception failure) { primary = failure; throw; } + finally + { + ReleaseOwnedWith(primary, + () => FreeOwnedLocal(descriptor), + () => FreeOwnedLocal(sidText), + () => { if (pin.IsAllocated) pin.Free(); }); + } + } + + private static void VerifyOwner(string path, byte[] sid) + { + IntPtr handle = CreateFileW(path, 0x00020000, 0x00000001, IntPtr.Zero, 3, 0x02200000, IntPtr.Zero); + if (handle == new IntPtr(-1)) throw new Win32Exception(Marshal.GetLastWin32Error()); + IntPtr descriptor = IntPtr.Zero; + GCHandle pin = default; + Exception? primary = null; + try + { + pin = GCHandle.Alloc(sid, GCHandleType.Pinned); + if (!GetFileInformationByHandle(handle, out FileInformation information)) throw new Win32Exception(Marshal.GetLastWin32Error()); + if ((information.Attributes & 0x10) == 0 || (information.Attributes & 0x400) != 0) + throw new InvalidOperationException("Fresh owned fixture is not a non-reparse directory."); + uint error = GetSecurityInfo(handle, 1, 1, out IntPtr owner, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, out descriptor); + if (error != 0) throw new Win32Exception(checked((int)error)); + if (owner == IntPtr.Zero || descriptor == IntPtr.Zero || !EqualSid(owner, pin.AddrOfPinnedObject())) + throw new InvalidOperationException("Fresh fixture owner differs from the current token user."); + } + catch (Exception failure) { primary = failure; throw; } + finally + { + ReleaseOwnedWith(primary, + () => FreeOwnedLocal(descriptor), + () => { if (pin.IsAllocated) pin.Free(); }, + () => CloseOwnedHandle(handle)); + } + } } diff --git a/Tools/windows/ValidationRunner/Adapters/StandaloneSuites.cs b/Tools/windows/ValidationRunner/Adapters/StandaloneSuites.cs index a1f10d303..b8a7d82a3 100644 --- a/Tools/windows/ValidationRunner/Adapters/StandaloneSuites.cs +++ b/Tools/windows/ValidationRunner/Adapters/StandaloneSuites.cs @@ -107,6 +107,11 @@ public async Task> RunAsync(ValidationContext context, const string script = "Tools\\windows\\Tests\\UiCaptureDriver.Tests.ps1"; var captureRoot = PowerShellPayload.CreateScratchRoot(context.RepositoryRoot); await File.WriteAllTextAsync(Path.Combine(context.ArtifactRoot, "capture-root.txt"), captureRoot, cancellationToken); + var peerContracts = await PowerShellPayload.RunAsync(context, Id + ".native-pipe-peer", script, + ["-NativePipePeerContractOnly", "-ScratchRoot", Path.Combine(captureRoot, "native-pipe-peer")], + cancellationToken); + if (EvidenceParser.Structured(Id, "UiCaptureNativePipePeerContracts", peerContracts.Output, 5, 5).Status != UnitStatus.Passed) + throw new InvalidDataException("Native pipe peer contracts failed"); var prebuilt = Path.Combine(captureRoot, "prebuilt"); var compilerContracts = await PowerShellPayload.RunAsync(context, Id + ".compiler-input", "Tools\\windows\\Tests\\FixtureCompiler.Tests.ps1", diff --git a/Tools/windows/ValidationRunner/Ownership/OwnedChildSupervisor.cs b/Tools/windows/ValidationRunner/Ownership/OwnedChildSupervisor.cs index f16c5c954..b79819886 100644 --- a/Tools/windows/ValidationRunner/Ownership/OwnedChildSupervisor.cs +++ b/Tools/windows/ValidationRunner/Ownership/OwnedChildSupervisor.cs @@ -20,6 +20,7 @@ public sealed record OwnedCleanupProof(bool RootExited, bool JobEmpty, bool Stdo } public sealed record OwnedChildResult(int ExitCode, string StandardOutput, string StandardError, OwnedCleanupProof Cleanup); public sealed record OwnedChildObservation(bool RootExited, uint ActiveJobProcesses, bool StdoutCompleted, bool StderrCompleted); +public sealed record OwnedRootResources(uint ProcessId, long CreationFileTime, uint Handles, long PrivateBytes); public sealed class OwnedChildSupervisor { @@ -36,6 +37,13 @@ private struct Startup } [StructLayout(LayoutKind.Sequential)] private struct ProcessInfo { public IntPtr Process, Thread; public uint Pid, Tid; } + [StructLayout(LayoutKind.Sequential)] + private struct ProcessMemoryCounters + { + public uint Size, PageFaultCount; + public UIntPtr PeakWorkingSetSize, WorkingSetSize, QuotaPeakPagedPoolUsage, QuotaPagedPoolUsage, + QuotaPeakNonPagedPoolUsage, QuotaNonPagedPoolUsage, PagefileUsage, PeakPagefileUsage, PrivateUsage; + } [DllImport("kernel32.dll", SetLastError = true)] private static extern bool CreatePipe(out IntPtr read, out IntPtr write, ref Security security, int size); @@ -66,6 +74,10 @@ private static extern bool CreateProcessW(string file, StringBuilder command, In private static extern bool GetExitCodeProcess(IntPtr process, out uint exitCode); [DllImport("kernel32.dll", SetLastError = true)] private static extern bool CloseHandle(IntPtr handle); + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GetProcessHandleCount(IntPtr process, out uint count); + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool K32GetProcessMemoryInfo(IntPtr process, ref ProcessMemoryCounters counters, uint size); // Unknown leases stay reachable: a finalizer must not close a kill-on-close job. private static readonly ConcurrentBag retained = new(); @@ -116,6 +128,46 @@ private void RegisterRetention() public OwnedChildObservation Observe() => new(RootExited(), StandaloneProcessJob.Active(job), StandardOutput.IsCompleted, StandardError.IsCompleted); + public static OwnedRootResources ValidateResourceSnapshot(uint expectedPid, long expectedCreated, + uint observedPid, long observedCreated, bool exited, uint handles, ulong privateBytes) + { + if (expectedPid == 0 || expectedCreated <= 0 || observedPid != expectedPid || + observedCreated != expectedCreated || exited || handles == 0 || privateBytes == 0 || + privateBytes > long.MaxValue) + throw new InvalidOperationException("Held original resource identity or counters are invalid."); + return new OwnedRootResources(observedPid, observedCreated, handles, checked((long)privateBytes)); + } + + public OwnedRootResources ReadResources() + { + lock (retentionGate) + { + if (completed || descendantUnknown || root == IntPtr.Zero || !assigned || !resumed) + throw new InvalidOperationException("Resources require the active original held launch."); + RequireResourceRoot(); + if (!GetProcessHandleCount(root, out uint handles)) throw new Win32Exception(); + var counters = new ProcessMemoryCounters { Size = checked((uint)Marshal.SizeOf()) }; + if (!K32GetProcessMemoryInfo(root, ref counters, counters.Size)) throw new Win32Exception(); + if (counters.Size != Marshal.SizeOf()) + throw new InvalidOperationException("Native resource counter structure size differs."); + RequireResourceRoot(); + return ValidateResourceSnapshot(ProcessId, creationFileTime, ProcessId, creationFileTime, + false, handles, counters.PrivateUsage.ToUInt64()); + } + } + + private void RequireResourceRoot() + { + uint state = WaitForSingleObject(root, 0); + if (state == 0) throw new InvalidOperationException("Held original root exited before resource proof."); + if (state != 258) throw new Win32Exception(); + uint pid = GetProcessId(root); + if (pid == 0) throw new Win32Exception(); + if (!GetProcessTimes(root, out long created, out _, out _, out _)) throw new Win32Exception(); + if (pid != ProcessId || created != creationFileTime) + throw new InvalidOperationException("Held original root generation differs."); + } + private OwnedChildSupervisor(TimeSpan timeout, string artifactRoot) { deadline = new Deadline(timeout); diff --git a/Tools/windows/terminal-gate.ps1 b/Tools/windows/terminal-gate.ps1 index 94ce975c2..42f9c7c4f 100644 --- a/Tools/windows/terminal-gate.ps1 +++ b/Tools/windows/terminal-gate.ps1 @@ -1,353 +1,400 @@ [CmdletBinding()] param( - [Parameter(Mandatory)] - [string] $WinghosttyRoot, - [Parameter(Mandatory)] - [string] $ZmxRoot, + [Parameter(Mandatory)][string] $WinghosttyRoot, + [Parameter(Mandatory)][string] $ZmxRoot, [string] $Zig0152 = "zig", [string] $Zig0160 = "zig", [switch] $SkipBuild, - # Providers were already compiled by provider-build.ps1 (validate.ps1 runs it - # first); still build the gate executable itself. [switch] $SkipProviderBuild, [switch] $Stress ) $ErrorActionPreference = "Stop" -$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..\..") +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path $gateRoot = Join-Path $repoRoot "investigation\spikes\windows-terminal-gate" $pins = Get-Content (Join-Path $gateRoot "provider-pins.json") -Raw | ConvertFrom-Json $app = Join-Path $gateRoot "zig-out\bin\graphcode-terminal-gate.exe" $wingLib = Join-Path $WinghosttyRoot "zig-out\lib\winghostty-win32-host.lib" $zmx = Join-Path $ZmxRoot "zig-out\bin\zmx.exe" -$ownedSessionNames = [System.Collections.Generic.HashSet[string]]::new() -$ownedProcessIds = [System.Collections.Generic.HashSet[int]]::new() -$gateOutputFiles = [System.Collections.Generic.List[string]]::new() -$gateProcess = $null -$resourceRole = "winghostty" +$assembly = $env:GRAPHCODE_VALIDATION_ASSEMBLY +if (-not $assembly) { $assembly = & (Join-Path $PSScriptRoot "Build-CompiledValidationRunner.ps1") } +& (Join-Path $PSScriptRoot "Import-CompiledValidationAssembly.ps1") ` + -AssemblyPath $assembly -RequiredType "GraphCode.Validation.Ownership.OwnedChildSupervisor" | Out-Null +& (Join-Path $PSScriptRoot "Import-CompiledValidationAssembly.ps1") ` + -AssemblyPath (Join-Path (Split-Path $assembly) "ValidationFixtureBindings.dll") ` + -RequiredType "GraphCode.ValidationFixtureDirectoryNative" | Out-Null +$workClock = [Diagnostics.Stopwatch]::StartNew() +function Get-TerminalArtifactRoot( + [Collections.IDictionary] $environment, [string] $processTemp, [string] $nonce, + [scriptblock] $directoryExists = { param($path) Test-Path -LiteralPath $path -PathType Container } +) { + $parent = if ($environment.Contains("RUNNER_TEMP")) { $environment["RUNNER_TEMP"] } else { $processTemp } + if ($parent -isnot [string] -or [string]::IsNullOrWhiteSpace($parent) -or + -not [IO.Path]::IsPathFullyQualified($parent) -or $nonce -cnotmatch '^[0-9a-f]{32}$' -or + -not (& $directoryExists $parent)) { + throw "Terminal artifact root requires an absolute temp parent and fresh nonce" + } + return Join-Path $parent ("terminal-owned-" + $nonce) +} +$artifactRoot = Get-TerminalArtifactRoot ([Environment]::GetEnvironmentVariables("Process")) ` + ([IO.Path]::GetTempPath()) ([guid]::NewGuid().ToString("N")) +[GraphCode.ValidationFixtureDirectoryNative]::CreateOwnedByCurrentUser($artifactRoot) +$ownedSessionNames = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) +$ownedSeeds = [Collections.Generic.List[object]]::new() +$launchSequence = 0 +$primaryFailure = $null +$cleanupUnproven = $false +$stageCount = 0 $metricSequence = 0 $sessionPrefix = "gc-$([guid]::NewGuid().ToString('N'))" -$names = @( - "$sessionPrefix-a", - "$sessionPrefix-b", - "$sessionPrefix-shared" -) +$names = @("$sessionPrefix-a", "$sessionPrefix-b", "$sessionPrefix-shared") +$priorEnvironment = @{} +foreach ($name in @("GRAPHCODE_ZMX", "GRAPHCODE_GATE_CWD", "GRAPHCODE_TERMINAL_SESSION_PREFIX", "ZMX_DIR")) { + $priorEnvironment[$name] = [Environment]::GetEnvironmentVariable($name, "Process") +} +Write-Host "TERMINAL_GATE_ARTIFACT_ROOT=$artifactRoot" + +function Get-TerminalRemaining([int] $maximumMilliseconds) { + # Leave the owned command's original five-second teardown inside the work phase. + $remaining = 1800000L - 5000L - $workClock.ElapsedMilliseconds + if ($maximumMilliseconds -le 0 -or $remaining -le 0) { + throw "Terminal work deadline reached; original cleanup reserve remains protected" + } + return [int][Math]::Min($maximumMilliseconds, $remaining) +} + +function Resolve-TerminalApplication( + [string] $name, + [scriptblock] $discover = { param($commandName) Get-Command -Name $commandName -CommandType Application -ErrorAction Stop } +) { + if ($name -cnotin @("git", "pwsh")) { throw "Terminal application resolver only admits git and pwsh" } + $commands = @(& $discover $name) + if ($commands.Count -eq 0) { throw "Terminal application is absent: $name" } + $selected = $commands[0] + $source = $selected.Source + if ($selected.CommandType -ne [Management.Automation.CommandTypes]::Application -or + $source -isnot [string] -or -not [IO.Path]::IsPathFullyQualified($source) -or + -not (Test-Path -LiteralPath $source -PathType Leaf)) { + throw "First terminal application candidate is not an existing absolute application: $name" + } + return $source +} + +function New-TerminalStartInfo([string] $file, [string[]] $arguments, [string] $directory) { + $start = [Diagnostics.ProcessStartInfo]::new((Resolve-Path -LiteralPath $file).Path) + $start.UseShellExecute = $false + $start.CreateNoWindow = $true + $start.RedirectStandardOutput = $true + $start.RedirectStandardError = $true + $start.Environment["GRAPHCODE_VALIDATION_ASSEMBLY"] = $assembly + $start.WorkingDirectory = $directory + foreach ($argument in $arguments) { $start.ArgumentList.Add($argument) } + return $start +} + +function Start-TerminalOwnedChild($start, [string] $role, [int] $milliseconds) { + $script:launchSequence++ + $directory = Join-Path $artifactRoot ("{0:D4}-{1}" -f $launchSequence, $role) + $null = New-Item -ItemType Directory -Path $directory + return [GraphCode.Validation.Ownership.OwnedChildSupervisor]::Start( + $start, [TimeSpan]::FromMilliseconds((Get-TerminalRemaining $milliseconds)), $directory) +} + +function Invoke-TerminalOwnedCommand([string] $file, [string[]] $arguments, + [string] $role, [int] $milliseconds = 5000, [string] $directory = $repoRoot) { + $start = New-TerminalStartInfo $file $arguments $directory + $child = Start-TerminalOwnedChild $start $role $milliseconds + return $child.CompleteAsync().GetAwaiter().GetResult() +} function Invoke-Native([string] $description, [scriptblock] $command) { Write-Host "==> $description" + $global:LASTEXITCODE = 0 & $command - if ($LASTEXITCODE -ne 0) { - throw "$description failed with exit code $LASTEXITCODE" - } + if ($LASTEXITCODE -ne 0) { throw "$description failed with exit code $LASTEXITCODE" } + $script:stageCount++ } -function Assert-Equal([string] $actual, [string] $expected, [string] $label) { - if ($actual -ne $expected) { - throw "$label expected $expected but found $actual" +function Assert-PinnedCleanWorktree([string] $root, [string] $sha, [string] $label) { + $git = Resolve-TerminalApplication "git" + $status = Invoke-TerminalOwnedCommand $git @("-C", $root, "status", "--porcelain", "--untracked-files=all") "pin-status" + $head = Invoke-TerminalOwnedCommand $git @("-C", $root, "rev-parse", "HEAD") "pin-head" + if ($status.ExitCode -ne 0 -or $head.ExitCode -ne 0 -or + $status.StandardOutput.Trim().Length -ne 0 -or $head.StandardOutput.Trim() -cne $sha) { + throw "$label provider pin/status differs; no unverified provider is admitted" } } function Assert-HistoryContains([string] $name, [string] $marker, [string] $label) { + $watch = [Diagnostics.Stopwatch]::StartNew() for ($attempt = 0; $attempt -lt 40; $attempt++) { - $history = (& $zmx history $name --vt 2>&1 | Out-String) - if ($LASTEXITCODE -eq 0 -and - $history -match [regex]::Escape($marker)) { - return - } - Start-Sleep -Milliseconds 250 + $remaining = [int][Math]::Min(5000, 10000 - $watch.ElapsedMilliseconds) + if ($remaining -le 0) { break } + $result = Invoke-TerminalOwnedCommand $zmx @("history", $name, "--vt") "history" $remaining + if ($watch.ElapsedMilliseconds -ge 10000) { throw "$label exceeded its shared history deadline" } + if ($result.ExitCode -eq 0 -and $result.StandardOutput.Contains($marker)) { return } + Write-Host "TERMINAL_HISTORY_PENDING attempt=$attempt exit=$($result.ExitCode)" + Start-Sleep -Milliseconds ([Math]::Min(250, [Math]::Max(0, 10000 - $watch.ElapsedMilliseconds))) } throw "$label did not contain the persistent VT marker '$marker'" } function Assert-ZmxSessionHealthy([string] $name, [string] $label) { + $watch = [Diagnostics.Stopwatch]::StartNew() + $first = $null + $last = $null for ($attempt = 0; $attempt -lt 40; $attempt++) { - & $zmx get $name *> $null - if ($LASTEXITCODE -eq 0) { - return + $remaining = [int][Math]::Min(5000, 10000 - $watch.ElapsedMilliseconds) + if ($remaining -le 0) { break } + $result = Invoke-TerminalOwnedCommand $zmx @("get", $name) "health" $remaining + if ($null -eq $first) { $first = $result } + $last = $result + if ($watch.ElapsedMilliseconds -ge 10000) { + Throw-TerminalHealthFailure "$label exceeded its shared health deadline" $first $last } - Start-Sleep -Milliseconds 250 + if ($result.ExitCode -eq 0) { return } + Write-Host "TERMINAL_HEALTH_PENDING attempt=$attempt exit=$($result.ExitCode)" + Start-Sleep -Milliseconds ([Math]::Min(250, [Math]::Max(0, 10000 - $watch.ElapsedMilliseconds))) } - throw "$label did not become reachable" + Throw-TerminalHealthFailure "$label did not become reachable" $first $last } -function Assert-PinnedCleanWorktree( - [string] $root, - [string] $expectedSha, - [string] $label -) { - if (-not (Test-Path -LiteralPath (Join-Path $root ".git"))) { - throw "$label provider root is not a Git worktree: $root" - } - $status = @(git -C $root status --porcelain --untracked-files=all) - if ($LASTEXITCODE -ne 0) { - throw "$label provider status failed" - } - if ($status.Count -ne 0) { - throw "$label provider worktree is dirty; use a clean pinned worktree or immutable artifact" - } - Assert-Equal (git -C $root rev-parse HEAD) $expectedSha "$label pin" +function Write-TerminalDiagnostic([string] $label, [object] $text) { + if ($text -isnot [string]) { throw "Actual diagnostic text is unavailable: $label" } + $maximum = 2048 + $excerpt = if ($text.Length -gt $maximum) { $text.Substring(0, $maximum) } else { $text } + Write-Host ("TERMINAL_DIAGNOSTIC=" + ([ordered]@{ label=$label; originalCharacters=$text.Length + truncated=($text.Length -gt $maximum); text=$excerpt } | ConvertTo-Json -Compress)) } -function Record-TestOwnedSessions { - foreach ($name in $names) { - [void] $ownedSessionNames.Add($name) - foreach ($processId in @(Get-ZmxSessionProcessIds $name)) { - [void] $ownedProcessIds.Add($processId) - } +function Throw-TerminalHealthFailure([string] $message, $first, $last) { + $primary = [InvalidOperationException]::new($message) + try { + if ($null -eq $first -or $null -eq $last) { throw "Health command returned no completed result" } + $primary.Data["TerminalHealthFirst"] = $first + $primary.Data["TerminalHealthLast"] = $last + Write-TerminalDiagnostic "first-health-stderr" $first.StandardError + Write-TerminalDiagnostic "last-health-stderr" $last.StandardError + } catch { + $primary.Data["TerminalHealthDiagnosticFailure"] = $_.Exception } + throw $primary } -function Write-OwnedResourceMetrics([string] $phase) { - $script:metricSequence++ - $metrics = @($ownedProcessIds | ForEach-Object { - $p = Get-Process -Id $_ -ErrorAction SilentlyContinue - if ($p) { - [pscustomobject]@{ - pid = $_ - role = if ($p.ProcessName -match "zmx") { "zmx" } elseif ($_.Equals($script:gateProcess.Id)) { $resourceRole } else { $p.ProcessName } - handles = [int64]$p.HandleCount - privateBytes = [int64]$p.PrivateMemorySize64 - } - - } - }) - Write-Host ("PRODUCT_RESOURCE_METRICS_JSON=" + (@{ - snapshotId = "$sessionPrefix-$resourceRole-$script:metricSequence" - phase = $phase - sessions = @($ownedSessionNames) - processes = $metrics - } | ConvertTo-Json -Compress -Depth 5)) +function Send-TerminalText([string] $name, [string] $text) { + $result = Invoke-TerminalOwnedCommand $zmx @("send", $name, $text) "send" + if ($result.ExitCode -ne 0) { throw "Terminal send failed: $($result.StandardError)" } } function Invoke-GateProcess([string[]] $arguments, [string] $phase) { - $outputPrefix = Join-Path ([IO.Path]::GetTempPath()) ` - "graphcode-terminal-gate-$([guid]::NewGuid().ToString('N'))" - $stdoutPath = "$outputPrefix.stdout.log" - $stderrPath = "$outputPrefix.stderr.log" - [void] $gateOutputFiles.Add($stdoutPath) - [void] $gateOutputFiles.Add($stderrPath) - $script:gateProcess = Start-Process -FilePath $app ` - -ArgumentList $arguments ` - -NoNewWindow ` - -PassThru ` - -RedirectStandardOutput $stdoutPath ` - -RedirectStandardError $stderrPath - [void] $ownedProcessIds.Add($script:gateProcess.Id) - Start-Sleep -Milliseconds 250 - Record-TestOwnedSessions - Write-OwnedResourceMetrics $phase - $script:gateProcess.WaitForExit() - $exitCode = $script:gateProcess.ExitCode - $script:gateProcess.Dispose() - $script:gateProcess = $null - if ($exitCode -ne 0) { - $stdout = Get-Content -LiteralPath $stdoutPath -Raw -ErrorAction SilentlyContinue - $stderr = Get-Content -LiteralPath $stderrPath -Raw -ErrorAction SilentlyContinue - throw "terminal gate exited with code ${exitCode}: stdout=$stdout, stderr=$stderr" + if ($ownedSeeds.Count -ne 3) { throw "Terminal spike requires exactly three original owned seed roots" } + foreach ($seed in $ownedSeeds) { + $observation = $seed.child.Observe() + if ($observation.RootExited -or $observation.ActiveJobProcesses -eq 0) { + throw "Original terminal seed root is not live; no replacement session is admitted" + } } -} - -function Get-ZmxSessionProcessIds([string] $name) { - $ids = [System.Collections.Generic.List[int]]::new() - $escaped = [regex]::Escape($name) - foreach ($process in @( - Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | - Where-Object { - $_.Name -match "(?i)^zmx(?:\.exe)?$" -and - $_.CommandLine -and - $_.CommandLine -match $escaped + $start = New-TerminalStartInfo $app $arguments $repoRoot + $child = Start-TerminalOwnedChild $start "spike" 30000 + $completionStarted = $false + try { + Write-OwnedResourceMetrics $phase $child + $completionStarted = $true + $result = $child.CompleteAsync().GetAwaiter().GetResult() + if ($result.ExitCode -ne 0) { + throw "Terminal spike failed: exit=$($result.ExitCode); stdout=$($result.StandardOutput); stderr=$($result.StandardError)" + } + } catch { + $primary = $_.Exception + if (-not $completionStarted) { + try { + $proof = $child.StopAsync($primary).GetAwaiter().GetResult() + if (-not $proof.CanRelease -or -not $proof.ResourcesClosed) { + $script:cleanupUnproven = $true + throw "Pre-completion spike cleanup is unproved" } - )) { - $ids.Add([int] $process.ProcessId) + } + catch { $primary.Data["TerminalMetricCleanupFailure"] = $_.Exception } + } + throw } - return @($ids | Select-Object -Unique) } -function Assert-ZmxSessionAbsent([string] $name) { - for ($attempt = 0; $attempt -lt 20; $attempt++) { - $processIds = @(Get-ZmxSessionProcessIds $name) - if ($processIds.Count -eq 0) { - return - } - - Start-Sleep -Milliseconds 250 - } - $details = @($processIds | ForEach-Object { "pid=$_" }) -join "; " - throw "cleanup left zmx session '$name' running: $details" +function Write-OwnedResourceMetrics([string] $phase, $spike) { + if ($ownedSeeds.Count -ne 3) { throw "Terminal metrics require exactly three original owned seed roots" } + $script:metricSequence++ + $metrics = @(foreach ($entry in @(@{ child=$spike; role="winghostty" }) + + @($ownedSeeds | ForEach-Object { @{ child=$_.child; role="zmx" } })) { + $resources = $entry.child.ReadResources() + [ordered]@{ pid = $resources.ProcessId; creationFileTimeUtc = $resources.CreationFileTime + role = $entry.role; handles = $resources.Handles; privateBytes = $resources.PrivateBytes } + }) + Write-Host ("PRODUCT_RESOURCE_METRICS_JSON=" + ([ordered]@{ + snapshotId = "$sessionPrefix-$metricSequence"; phase = $phase; sessions = @($ownedSessionNames); processes = $metrics + sampleScope = "ACTUAL_ACTIVE_SPIKE_INVOCATION_BEFORE_COMPLETION_NOT_POST_TYPED_WORKLOAD" + } | ConvertTo-Json -Compress -Depth 4)) } -function Get-ProcessTreeIds([int[]] $roots) { - $all = @(Get-CimInstance Win32_Process -ErrorAction SilentlyContinue) - $ids = [Collections.Generic.HashSet[int]]::new() - foreach ($root in $roots) { [void] $ids.Add($root) } - $changed = $true - while ($changed) { - $changed = $false - foreach ($process in $all) { - if (-not $ids.Contains([int]$process.ProcessId) -and - $ids.Contains([int]$process.ParentProcessId)) { - [void] $ids.Add([int]$process.ProcessId) - $changed = $true - } +function Complete-TerminalSeeds([Exception] $primary) { + $errors = [Collections.Generic.List[Exception]]::new() + $tasks = [Collections.Generic.List[Threading.Tasks.Task]]::new() + $taskSeeds = [Collections.Generic.Dictionary[Threading.Tasks.Task, object]]::new() + foreach ($seed in $ownedSeeds) { + try { + $task = $seed.child.StopAsync($primary) + $tasks.Add($task) + $taskSeeds.Add($task, $seed) } + catch { $errors.Add($_.Exception) } + } + try { + if (-not [Threading.Tasks.Task]::WaitAll($tasks.ToArray(), 9000)) { + $script:cleanupUnproven = $true + throw "Original terminal seed cleanup exceeded its shared 9000 ms reserve" + } + } catch { $errors.Add($_.Exception) } + foreach ($task in $tasks) { + if (-not $task.IsCompleted) { $script:cleanupUnproven = $true; continue } + try { + $proof = $task.GetAwaiter().GetResult() + if (-not $proof.CanRelease -or -not $proof.ResourcesClosed) { + $script:cleanupUnproven = $true + throw "Terminal seed cleanup is unproved" + } + if ($primary) { + $seed = $taskSeeds[$task] + try { + if ($seed.child.StandardError.Status -ne [Threading.Tasks.TaskStatus]::RanToCompletion) { + throw "Original seed stderr completion is unavailable after cleanup" + } + Write-TerminalDiagnostic "original-seed-stderr-after-held-cleanup" ` + $seed.child.StandardError.GetAwaiter().GetResult() + } catch { $primary.Data["TerminalSeedDiagnosticFailure:$($task.Id)"] = $_.Exception } + } + } catch { $errors.Add($_.Exception) } + } + if ($env:GRAPHCODE_TERMINAL_GATE_INJECT_CLEANUP_FAILURE -eq "1") { + $errors.Add([InvalidOperationException]::new("injected cleanup failure")) + } + if ($errors.Count -gt 0) { + $failure = [AggregateException]::new("terminal gate cleanup failed", $errors.ToArray()) + if ($primary) { $primary.Data["TerminalCleanupFailure"] = $failure } + else { throw $failure } + } else { + Write-Host "TERMINAL_GATE_SEED_CLEANUP=verified roots=$($ownedSeeds.Count)" } - return @($ids) } -Assert-PinnedCleanWorktree $WinghosttyRoot $pins.winghostty.sha "Winghostty" -Assert-PinnedCleanWorktree $ZmxRoot $pins.zmx.sha "zmx" - try { + Assert-PinnedCleanWorktree $WinghosttyRoot $pins.winghostty.sha "Winghostty" + Assert-PinnedCleanWorktree $ZmxRoot $pins.zmx.sha "zmx" + $pwsh = Resolve-TerminalApplication "pwsh" + $ownerContracts = Invoke-TerminalOwnedCommand $pwsh @( + "-NoProfile", "-NonInteractive", "-File", (Join-Path $PSScriptRoot "Tests\TerminalGate.Tests.ps1"), + "-OwnedDirectoryContractsOnly") "owner-contracts" 60000 + if ($ownerContracts.ExitCode -ne 0 -or + $ownerContracts.StandardOutput -notmatch 'Terminal owned directory contracts: PASS executed=14 source/managed-only nativeCreates=0') { + throw "Canonical owner-at-creation contracts failed or executed an incomplete count" + } + $resourceContracts = Invoke-TerminalOwnedCommand $pwsh @( + "-NoProfile", "-NonInteractive", "-File", (Join-Path $PSScriptRoot "Tests\TerminalGate.Tests.ps1"), + "-ResourceCountersOnly") "resource-contracts" 60000 + if ($resourceContracts.ExitCode -ne 0 -or + $resourceContracts.StandardOutput -notmatch 'Terminal held resource counters: PASS executed=11 source/managed-only') { + throw "Canonical held-resource contracts failed or executed an incomplete count" + } if (-not $SkipBuild) { if (-not $SkipProviderBuild) { - Invoke-Native "Pinned provider artifacts" { - & (Join-Path $PSScriptRoot "provider-build.ps1") ` - -WinghosttyRoot $WinghosttyRoot ` - -ZmxRoot $ZmxRoot ` - -Zig0152 $Zig0152 ` - -Zig0160 $Zig0160 ` - -PinsPath (Join-Path $gateRoot "provider-pins.json") - } + $build = Invoke-TerminalOwnedCommand $pwsh @( + "-NoProfile", "-NonInteractive", "-File", (Join-Path $PSScriptRoot "provider-build.ps1"), + "-WinghosttyRoot", $WinghosttyRoot, "-ZmxRoot", $ZmxRoot, + "-Zig0152", $Zig0152, "-Zig0160", $Zig0160, + "-PinsPath", (Join-Path $gateRoot "provider-pins.json")) "provider-build" 1200000 + if ($build.ExitCode -ne 0) { throw "Provider build failed: $($build.StandardError)" } } - Invoke-Native "GraphCode terminal gate" { - Push-Location $gateRoot - try { - & $Zig0152 build ` - "-Dwinghostty-dir=$WinghosttyRoot" ` - "-Dwinghostty-lib=$wingLib" ` - -Doptimize=ReleaseSafe - } finally { Pop-Location } + $build = Invoke-TerminalOwnedCommand $Zig0152 @("build", "-Dwinghostty-dir=$WinghosttyRoot", + "-Dwinghostty-lib=$wingLib", "-Doptimize=ReleaseSafe") "spike-build" 600000 $gateRoot + if ($build.ExitCode -ne 0) { throw "Terminal gate build failed: $($build.StandardError)" } + $tests = Invoke-TerminalOwnedCommand $Zig0152 @("test", "src\AttachCleanup.zig") "attach-contracts" 60000 $gateRoot + if ($tests.ExitCode -ne 0 -or ($tests.StandardOutput + $tests.StandardError) -notmatch 'All [1-9][0-9]* tests passed\.') { + throw "Terminal held attach contracts failed or executed zero tests" } } - - if (-not (Test-Path $app)) { throw "terminal gate executable is missing: $app" } - if (-not (Test-Path $zmx)) { throw "zmx executable is missing: $zmx" } - + foreach ($path in @($app, $zmx)) { + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw "Terminal executable missing: $path" } + } $env:GRAPHCODE_ZMX = $zmx $env:GRAPHCODE_GATE_CWD = $repoRoot $env:GRAPHCODE_TERMINAL_SESSION_PREFIX = $sessionPrefix - Write-Host "terminal gate session prefix: $sessionPrefix" - try { - Invoke-Native "terminal gate first attach smoke" { - Invoke-GateProcess @("--smoke") "terminal-gate:typed-input" - } - Record-TestOwnedSessions - Write-OwnedResourceMetrics "terminal-gate:typed-input" - Invoke-Native "first-session health" { - Assert-ZmxSessionHealthy $names[0] "session A" - Assert-ZmxSessionHealthy $names[1] "session B" - } - Invoke-Native "session shell pwd/cwd" { - & $zmx send $names[0] "cd`r" - & $zmx send $names[1] "cd`r" - } - $expectedCwd = ([System.IO.Path]::GetFullPath($repoRoot)).TrimEnd("\") - Assert-HistoryContains $names[0] ` - $expectedCwd "session A cwd" - Assert-HistoryContains $names[1] ` - $expectedCwd "session B cwd" - Assert-HistoryContains $names[0] ` - "GraphCode typed output A" "typed A output" - Assert-HistoryContains $names[1] ` - "GraphCode typed output B" "typed B output" - Invoke-Native "seed persistent shell output" { - & $zmx send $names[0] "echo GraphCode persistent VT output A`r" - & $zmx send $names[1] "echo GraphCode persistent VT output B`r" - } - Assert-HistoryContains $names[0] ` - "GraphCode persistent VT output A" "first-session A history" - Assert-HistoryContains $names[1] ` - "GraphCode persistent VT output B" "first-session B history" - Invoke-Native "terminal gate independent restart attach smoke" { - Invoke-GateProcess @("--smoke") "terminal-gate:reconnect" - } - Record-TestOwnedSessions - Invoke-Native "restart-session health" { - Assert-ZmxSessionHealthy $names[0] "restart session A" - Assert-ZmxSessionHealthy $names[1] "restart session B" - } - Assert-HistoryContains $names[0] ` - "GraphCode persistent VT output A" "restart A history" - Assert-HistoryContains $names[1] ` - "GraphCode persistent VT output B" "restart B history" - Assert-HistoryContains $names[0] ` - "GraphCode typed output A" "restart typed A history" - Assert-HistoryContains $names[1] ` - "GraphCode typed output B" "restart typed B history" - Invoke-Native "terminal gate same-session attach smoke" { - Invoke-GateProcess @("--smoke", "--same-session") "terminal-gate:typed-input" - } - Record-TestOwnedSessions - Invoke-Native "same-session health" { - Assert-ZmxSessionHealthy $names[2] "shared session" - } - Invoke-Native "seed shared persistent shell output" { - & $zmx send $names[2] "echo GraphCode shared VT output`r" - } - Assert-HistoryContains $names[2] ` - "GraphCode shared VT output" "same-session history" - Invoke-Native "terminal gate same-session restart smoke" { - Invoke-GateProcess @("--smoke", "--same-session") "terminal-gate:reconnect" - } - Record-TestOwnedSessions - Assert-HistoryContains $names[2] ` - "GraphCode shared VT output" "same-session restart history" - if ($Stress) { - Invoke-Native "terminal gate destroy/recreate stress" { - Invoke-GateProcess @("--smoke", "--stress") "terminal-gate:stress" - } - Record-TestOwnedSessions - Invoke-Native "post-stress session health" { - Assert-ZmxSessionHealthy $names[0] "post-stress session A" - Assert-ZmxSessionHealthy $names[1] "post-stress session B" - } + $env:ZMX_DIR = Join-Path $artifactRoot "zmx" + $lease = Join-Path $env:ZMX_DIR ("ipc\" + ("s" * 52) + "\" + + ("0" * (2 * [Text.Encoding]::UTF8.GetByteCount($names[2]))) + ".endpoint.lease") + if ($lease.Length -gt 232) { + throw "Terminal owned provider path exceeds the 232-character lease budget; use an admitted shorter TEMP root" + } + [GraphCode.ValidationFixtureDirectoryNative]::CreateOwnedByCurrentUser($env:ZMX_DIR) + foreach ($name in $names) { + $start = New-TerminalStartInfo $zmx @("--daemon", $name, + (Join-Path ([Environment]::SystemDirectory) "cmd.exe"), "/Q") $repoRoot + $child = Start-TerminalOwnedChild $start "session-seed" (Get-TerminalRemaining 1800000) + $ownedSeeds.Add([pscustomobject]@{ name = $name; child = $child }) + [void]$ownedSessionNames.Add($name) + Assert-ZmxSessionHealthy $name "seed $name" + } + Invoke-Native "terminal gate first attach smoke" { Invoke-GateProcess @("--smoke") "terminal-gate:typed-input" } + Invoke-Native "first-session health" { + Assert-ZmxSessionHealthy $names[0] "session A"; Assert-ZmxSessionHealthy $names[1] "session B" + } + Invoke-Native "session shell pwd/cwd" { Send-TerminalText $names[0] "cd`r"; Send-TerminalText $names[1] "cd`r" } + $expectedCwd = [IO.Path]::GetFullPath($repoRoot).TrimEnd("\") + Assert-HistoryContains $names[0] $expectedCwd "session A cwd" + Assert-HistoryContains $names[1] $expectedCwd "session B cwd" + Assert-HistoryContains $names[0] "GraphCode typed output A" "typed A output" + Assert-HistoryContains $names[1] "GraphCode typed output B" "typed B output" + Invoke-Native "seed persistent shell output" { + Send-TerminalText $names[0] "echo GraphCode persistent VT output A`r" + Send-TerminalText $names[1] "echo GraphCode persistent VT output B`r" + } + Assert-HistoryContains $names[0] "GraphCode persistent VT output A" "first-session A history" + Assert-HistoryContains $names[1] "GraphCode persistent VT output B" "first-session B history" + Invoke-Native "terminal gate independent restart attach smoke" { Invoke-GateProcess @("--smoke") "terminal-gate:reconnect" } + Invoke-Native "restart-session health" { + Assert-ZmxSessionHealthy $names[0] "restart session A"; Assert-ZmxSessionHealthy $names[1] "restart session B" + } + Assert-HistoryContains $names[0] "GraphCode persistent VT output A" "restart A history" + Assert-HistoryContains $names[1] "GraphCode persistent VT output B" "restart B history" + Assert-HistoryContains $names[0] "GraphCode typed output A" "restart typed A history" + Assert-HistoryContains $names[1] "GraphCode typed output B" "restart typed B history" + Invoke-Native "terminal gate same-session attach smoke" { Invoke-GateProcess @("--smoke", "--same-session") "terminal-gate:typed-input" } + Invoke-Native "same-session health" { Assert-ZmxSessionHealthy $names[2] "shared session" } + Invoke-Native "seed shared persistent shell output" { Send-TerminalText $names[2] "echo GraphCode shared VT output`r" } + Assert-HistoryContains $names[2] "GraphCode shared VT output" "same-session history" + Invoke-Native "terminal gate same-session restart smoke" { Invoke-GateProcess @("--smoke", "--same-session") "terminal-gate:reconnect" } + Assert-HistoryContains $names[2] "GraphCode shared VT output" "same-session restart history" + if ($Stress) { + Invoke-Native "terminal gate destroy/recreate stress" { Invoke-GateProcess @("--smoke", "--stress") "terminal-gate:stress" } + Invoke-Native "post-stress session health" { + Assert-ZmxSessionHealthy $names[0] "post-stress session A"; Assert-ZmxSessionHealthy $names[1] "post-stress session B" } - Write-Host "Windows terminal gate smoke/stress: PASS" } +} catch { + $primaryFailure = $_.Exception + throw +} finally { + try { Complete-TerminalSeeds $primaryFailure } finally { - $cleanupFailures = [System.Collections.Generic.List[string]]::new() - foreach ($name in $names) { - foreach ($processId in @(Get-ZmxSessionProcessIds $name)) { - [void] $ownedSessionNames.Add($name) - [void] $ownedProcessIds.Add($processId) - } - } - $treeProcessIds = Get-ProcessTreeIds @($ownedProcessIds) - foreach ($processId in $treeProcessIds) { - [void] $ownedProcessIds.Add($processId) + foreach ($key in $priorEnvironment.Keys) { + [Environment]::SetEnvironmentVariable($key, $priorEnvironment[$key], "Process") } - foreach ($name in @($ownedSessionNames)) { - if (@(Get-ZmxSessionProcessIds $name).Count -ne 0) { - & $zmx kill $name *> $null - } - } - foreach ($processId in @($ownedProcessIds)) { - if (Get-Process -Id $processId -ErrorAction SilentlyContinue) { - Stop-Process -Id $processId -Force -ErrorAction SilentlyContinue - } - } - foreach ($name in @($ownedSessionNames)) { - try { - Assert-ZmxSessionAbsent $name - } catch { - $cleanupFailures.Add($_.Exception.Message) - } - } - if ($env:GRAPHCODE_TERMINAL_GATE_INJECT_CLEANUP_FAILURE -eq "1") { - $cleanupFailures.Add("injected cleanup failure") - } - if ($cleanupFailures.Count -ne 0) { - throw "terminal gate cleanup failed: $($cleanupFailures -join '; ')" + if ($cleanupUnproven -or [GraphCode.Validation.Ownership.OwnedChildSupervisor]::RequiresLifetimeHold) { + Write-Host "GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven" + [Console]::Out.Flush() + if ($cleanupUnproven) { [Threading.Thread]::Sleep([Threading.Timeout]::Infinite) } + $null = [GraphCode.Validation.Ownership.OwnedChildSupervisor]::HoldRetainedResourcesAsync().GetAwaiter().GetResult() } } } -finally { - foreach ($path in $gateOutputFiles) { - Remove-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue - } - Remove-Item Env:GRAPHCODE_ZMX -ErrorAction SilentlyContinue - Remove-Item Env:GRAPHCODE_GATE_CWD -ErrorAction SilentlyContinue - Remove-Item Env:GRAPHCODE_TERMINAL_SESSION_PREFIX -ErrorAction SilentlyContinue -} - +if ($stageCount -ne $(if ($Stress) { 12 } else { 10 })) { throw "Terminal stage execution count differs" } +Write-Host "TERMINAL_GATE_STAGES_EXECUTED=$stageCount" +Write-Host "Windows terminal gate smoke/stress: PASS" exit 0 diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index 9d86074c8..18d9e334e 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -6,6 +6,10 @@ param( [switch]$SidebarParityOnly, [switch]$UnwiredRecoveryOnly, [switch]$CycleHierarchyOnly, + [switch]$RestoredTerminalInputOnly, + [string] $Daemon = "", + [string] $Cli = "", + [string] $RestorationRoot = "", [string] $DeleteSelectFailureReceipt = $env:GRAPHCODE_UIA_DELETE_SELECT_FAILURE_RECEIPT ) @@ -23,7 +27,9 @@ foreach ($requiredType in @( & (Join-Path $PSScriptRoot "Import-CompiledValidationAssembly.ps1") ` -AssemblyPath $validationAssemblyPath -RequiredType $requiredType | Out-Null } -Import-Module -Name (Join-Path $PSScriptRoot 'UiCaptureDriver.psm1') -ErrorAction Stop -WarningAction SilentlyContinue +if (-not $RestoredTerminalInputOnly) { + Import-Module -Name (Join-Path $PSScriptRoot 'UiCaptureDriver.psm1') -ErrorAction Stop -WarningAction SilentlyContinue +} $script:uiaAttributionDirectory = $null $script:uiaAttributionSequence = 0 @@ -166,6 +172,523 @@ function Require([bool] $condition, [string] $message) { if (-not $condition) { throw $message } } +# Observation guards only: roster order must come from the persisted layout, +# never a screen-coordinate sort or a focus-setting helper. +function Assert-UiaUnrepairedTerminalFocus($snapshot, [int] $processId, [string] $selectedIdentity) { + Require ($processId -gt 0 -and -not [string]::IsNullOrWhiteSpace($selectedIdentity)) ` + "Unrepaired terminal input requires an exact current pane identity" + Require ($snapshot.foreground -is [bool] -and $snapshot.foreground -and + $snapshot.focusedProcessId -eq $processId -and + $snapshot.focusedIdentity -ceq $selectedIdentity) ` + "Unrepaired terminal input refused: the selected owned pane is not the actual foreground focus" + $panes = @($snapshot.panes) + Require ($panes.Count -ge 2) "Unrepaired terminal input requires a nontrivial pane topology" + $identities = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + foreach ($pane in $panes) { + Require (-not [string]::IsNullOrWhiteSpace($pane.identity) -and + $identities.Add([string]$pane.identity) -and $pane.processId -eq $processId -and + $pane.parentProcessId -eq $processId -and + $pane.visible -is [bool] -and $pane.visible -and + $pane.focused -is [bool]) "Unrepaired terminal input refused: invalid or foreign pane topology" + } + Require ($panes[0].identity -cne $selectedIdentity) ` + "Unrepaired terminal input requires a nonzero selected pane" + $selected = @($panes | Where-Object { $_.identity -ceq $selectedIdentity }) + $focused = @($panes | Where-Object { $_.focused }) + Require ($selected.Count -eq 1 -and $selected[0].focused -and $focused.Count -eq 1) ` + "Unrepaired terminal input refused: selected pane does not exclusively own keyboard focus" +} + +function Assert-UiaTerminalChallengeOutput( + [string[]] $before, [string[]] $after, [int] $selectedIndex, + [string] $nonce, [long] $expectedArithmetic +) { + Require ($before.Count -ge 2 -and $after.Count -eq $before.Count -and + $selectedIndex -gt 0 -and $selectedIndex -lt $before.Count -and + $nonce -cmatch '^[0-9a-f]{24}$') "Terminal challenge requires an exact nonzero pane and fresh nonce" + $prefix = "GC_${nonce}_" + Require (@($before | Where-Object { $_.Contains($prefix) }).Count -eq 0) ` + "Terminal challenge nonce was already present before input" + for ($index = 0; $index -lt $after.Count; $index++) { + if ($index -ne $selectedIndex) { + Require (-not $after[$index].Contains($prefix)) "Terminal challenge reached a nonselected pane" + } + } + return Read-UiaTerminalComputedMarker $after[$selectedIndex] $nonce $expectedArithmetic +} + +function Read-UiaTerminalComputedMarker([string] $text, [string] $nonce, [long] $expectedArithmetic) { + Require ($nonce -cmatch '^[0-9a-f]{24}$') "Terminal challenge requires a fresh nonce" + $prefix = "GC_${nonce}_" + $matches = [regex]::Matches($text, + '(?m)^' + [regex]::Escape($prefix) + '(?[1-9][0-9]*)_(?[0-9]+)\s*$') + Require ($matches.Count -eq 1) "Terminal challenge did not publish exactly one shell-computed output line" + $shellProcessId = 0L + $arithmetic = 0L + Require ([long]::TryParse($matches[0].Groups["pid"].Value, [ref]$shellProcessId) -and + [long]::TryParse($matches[0].Groups["value"].Value, [ref]$arithmetic) -and + $arithmetic -eq $expectedArithmetic) "Terminal challenge shell PID or arithmetic is invalid" + return $shellProcessId +} + +function Assert-UiaRestorationPeerCapability { + # Integrated main + unchanged M3 observer method; historical runtime evidence stays historical. + $qualifiedPeerSourceSha256 = "35B3985DDF7C7EAAC6360AE90917BF143DA633CF03E687F5214AE4CD89C1F297" + Require ($qualifiedPeerSourceSha256 -cmatch '^[0-9A-F]{64}$') ` + "M2 preflight refused before launch: qualified shared pipe-peer source intake is pending" + $module = Join-Path $PSScriptRoot "UiCaptureDriver.psm1" + Require ((Get-FileHash -LiteralPath $module -Algorithm SHA256).Hash -ceq $qualifiedPeerSourceSha256) ` + "M2 shared pipe-peer source differs from its qualified intake" + Require (-not ("CapNative" -as [type])) "M2 requires a fresh process, not a previously loaded native helper" + Import-Module $module -ErrorAction Stop + $peerType = "CapNative" -as [type] + Require ($null -ne $peerType -and $null -ne $peerType.GetMethod("PipeServerProcessId", + [type[]]@([Microsoft.Win32.SafeHandles.SafePipeHandle]))) ` + "M2 requires the qualified shared PipeServerProcessId(SafePipeHandle) method" + Initialize-CapUiaClient -AssemblyPath $validationAssemblyPath +} + +function Get-UiaRestorationRemaining($state, [int] $maximumMilliseconds = 5000) { + $remaining = 1200000L - 90000L - $state.clock.ElapsedMilliseconds + Require ($remaining -gt 0) "M2 work budget exhausted; the 90-second cleanup reserve is protected" + return [int][Math]::Min($maximumMilliseconds, $remaining) +} + +function New-UiaRestorationState([string] $root, [string] $shell, [string] $daemon, [string] $cli, [string] $zmx) { + Require ([IO.Path]::IsPathFullyQualified($root)) "M2 requires an absolute, new owned restoration root" + foreach ($path in @($shell, $daemon, $cli, $zmx)) { + Require ([IO.Path]::IsPathFullyQualified($path) -and (Test-Path -LiteralPath $path -PathType Leaf)) ` + "M2 requires explicit existing shell, daemon, CLI and pinned zmx executable paths" + } + $null = Assert-UiaProviderPathBudget $root (Join-Path $root "local") "graphcode" $null $null + & (Join-Path $PSScriptRoot "Import-CompiledValidationAssembly.ps1") ` + -AssemblyPath (Join-Path ([IO.Path]::GetDirectoryName($validationAssemblyPath)) "ValidationFixtureBindings.dll") ` + -RequiredType "GraphCode.ValidationFixtureDirectoryNative" | Out-Null + [GraphCode.ValidationFixtureDirectoryNative]::Create($root) + $state = [pscustomobject]@{ + root = $root; clock = [Diagnostics.Stopwatch]::StartNew(); scope = $null + launches = [Collections.Generic.List[object]]::new() + project = Join-Path $root "M2Project"; projectDirectory = Join-Path $root "M2Project" + support = Join-Path $root "support" + shell = $shell; daemon = $daemon; cli = $cli; zmx = $zmx + pipe = "graphcode-m2-" + [guid]::NewGuid().ToString("N") + environment = @{}; nodeId = ""; paneIds = @(); sessions = @(); seeds = @() + layoutPath = ""; failure = $null + } + foreach ($name in @("profile", "local", "roaming", "temp", "support", "zmx", "M2Project", "logs")) { + $null = New-Item -ItemType Directory -Path (Join-Path $root $name) -ErrorAction Stop + } + Write-Host "M2_OWNED_ROOT=$root" + $identities = [ordered]@{ + gateSourceSha256 = (Get-FileHash -LiteralPath (Join-Path $PSScriptRoot "uia-live-gate.ps1") -Algorithm SHA256).Hash + binaries = @($shell, $daemon, $cli, $zmx | ForEach-Object { + [ordered]@{ path = $_; sha256 = (Get-FileHash -LiteralPath $_ -Algorithm SHA256).Hash } + }) + limit = "Observed hashes only; the executor must independently bind these binaries to its admitted source/build." + } + [IO.File]::WriteAllText((Join-Path $root "logs\input-identities.json"), ($identities | ConvertTo-Json -Depth 4)) + $state.environment = @{ + SystemRoot = $env:SystemRoot; WINDIR = $env:WINDIR; USERPROFILE = Join-Path $root "profile" + HOME = Join-Path $root "profile"; LOCALAPPDATA = Join-Path $root "local" + APPDATA = Join-Path $root "roaming"; TEMP = Join-Path $root "temp"; TMP = Join-Path $root "temp" + PATH = "$([Environment]::SystemDirectory);$([Environment]::SystemDirectory)\WindowsPowerShell\v1.0;$PSHOME;$(Split-Path $daemon);$(Split-Path $shell);$(Split-Path $zmx)" + GRAPHCODE_SUPPORT_DIR = $state.support; GRAPHCODE_ZMX = $zmx; ZMX_DIR = Join-Path $root "zmx" + GRAPHCODE_DAEMON_PIPE = "\\.\pipe\$($state.pipe)"; GRAPHCODE_SOCKET = "\\.\pipe\$($state.pipe)" + } + [IO.File]::WriteAllText((Join-Path $state.support "onboarding-seen"), "seen") + Initialize-UiaRestorationProviderBinary $state + $state.scope = New-ShellProcessScope + return $state +} + +function Initialize-UiaRestorationProviderBinary($state) { + # Swift's ZmxLocator prefers an adjacent binary, then support\bin; it ignores PATH. + $expectedHash = (Get-FileHash -LiteralPath $state.zmx -Algorithm SHA256).Hash + foreach ($executable in @($state.daemon, $state.cli)) { + $adjacent = Join-Path (Split-Path -Parent $executable) "zmx.exe" + if (Test-Path -LiteralPath $adjacent -PathType Leaf) { + Require ((Get-FileHash -LiteralPath $adjacent -Algorithm SHA256).Hash -ceq $expectedHash) ` + "M2 adjacent Swift provider differs from the admitted pinned zmx bytes" + } + } + $directory = Join-Path $state.support "bin" + $null = New-Item -ItemType Directory -Path $directory -ErrorAction Stop + $destination = Join-Path $directory "zmx.exe" + [IO.File]::Copy($state.zmx, $destination, $false) + Require ((Get-FileHash -LiteralPath $destination -Algorithm SHA256).Hash -ceq $expectedHash) ` + "M2 private Swift provider copy differs from the admitted pinned zmx bytes" +} + +function Start-UiaRestorationProcess($state, [string] $file, [string[]] $arguments, [string] $stem) { + $null = Get-UiaRestorationRemaining $state + $info = [Diagnostics.ProcessStartInfo]::new($file) + $info.UseShellExecute = $false + $info.CreateNoWindow = $true + $info.RedirectStandardOutput = $true + $info.RedirectStandardError = $true + $info.WorkingDirectory = $state.projectDirectory + $info.Environment.Clear() + foreach ($name in $state.environment.Keys) { $info.Environment[$name] = [string]$state.environment[$name] } + foreach ($argument in $arguments) { $info.ArgumentList.Add($argument) } + $process = Start-ShellOwnedProcess $state.scope $info + $state.launches.Add([pscustomobject]@{ process = $process; stem = $stem }) + [GraphCodeUiaGateState]::RetainProcess($process) + return $process +} + +function Assert-UiaRestorationHeldRoot($state, $process) { + $runs = @($state.scope.Runs | Where-Object { [Object]::ReferenceEquals($_.Process, $process) }) + Require ($runs.Count -eq 1 -and -not $state.scope.Closed -and + $runs[0].ScopeAssigned -and $runs[0].LaunchAssigned -and -not $runs[0].Finalized -and + -not $process.HasExited -and + [GraphCode.Validation.Ownership.StandaloneProcessJob]::Contains($state.scope.Job, $runs[0].Handle) -and + [GraphCode.Validation.Ownership.StandaloneProcessJob]::Contains($runs[0].LaunchJob, $runs[0].Handle)) ` + "M2 original launch authority is no longer live in both retained jobs" +} + +function Confirm-UiaRestorationDaemonPeer($state, $daemonProcess) { + Assert-UiaRestorationHeldRoot $state $daemonProcess + $pipe = [IO.Pipes.NamedPipeClientStream]::new(".", $state.pipe, [IO.Pipes.PipeDirection]::InOut) + try { + $pipe.Connect((Get-UiaRestorationRemaining $state 15000)) + $observed = [CapNative]::PipeServerProcessId($pipe.SafePipeHandle) + Assert-UiaRestorationHeldRoot $state $daemonProcess + Require ($observed -eq $daemonProcess.Id) "M2 observer connected to a foreign daemon server" + return [ordered]@{ observedServerProcessId = $observed; scope = "observer connection only, not the app connection" } + } finally { $pipe.Dispose() } +} + +function Invoke-UiaRestorationCli($state, [string[]] $arguments, [string] $stem) { + return Invoke-UiaRestorationCommand $state $state.cli $arguments $stem +} + +function Invoke-UiaRestorationCommand($state, [string] $file, [string[]] $arguments, [string] $stem) { + $process = Start-UiaRestorationProcess $state $file $arguments $stem + Require ($process.WaitForExit((Get-UiaRestorationRemaining $state 15000))) "M2 CLI timed out: $stem" + Require ((Get-UiaRestorationRemaining $state 10000) -eq 10000) "M2 launch finalization would consume the cleanup reserve" + Complete-ShellOwnedProcess $state.scope $process + $output = Read-ShellOwnedOutput $state.scope $process + $errorText = Read-ShellOwnedOutput $state.scope $process -StandardError + Require ($process.ExitCode -eq 0) "M2 CLI failed: $stem exit=$($process.ExitCode) stderr=$errorText" + return [string]$output +} + +function Initialize-UiaRestorationSessions($state) { + $null = Invoke-UiaRestorationCli $state @("status", $state.project) "register-project" + $projects = @(Invoke-UiaRestorationCli $state @("projects", "--json") "canonical-project" | ConvertFrom-Json) + $project = @($projects | Where-Object { $_.name -ceq "M2Project" }) + Require ($project.Count -eq 1 -and -not [string]::IsNullOrWhiteSpace($project[0].path) -and + [IO.Path]::GetFullPath($project[0].path.Replace('/', '\')) -ieq $state.projectDirectory) ` + "M2 daemon did not return the exact owned project's canonical path" + $state.project = [string]$project[0].path + $created = Invoke-UiaRestorationCli $state @("node", "create", $state.project, + "--title", "M2Restore", "--type", "main") "create-loop" + $ids = [regex]::Matches($created, '(?m)^\s+([0-9A-Fa-f]{8}(?:-[0-9A-Fa-f]{4}){3}-[0-9A-Fa-f]{12})\s+.*\sM2Restore\s*$') + Require ($ids.Count -eq 1) "M2 CLI did not return exactly one real M2Restore node" + $state.nodeId = $ids[0].Groups[1].Value.ToUpperInvariant() + # A stopped attended node cannot launch an external agent if its seed disappears. + $stopped = Invoke-UiaRestorationCli $state @("node", "stop", $state.project, $state.nodeId) "stop-loop" + Require ([regex]::Matches($stopped, '(?m)^\s+' + [regex]::Escape($state.nodeId) + + '\s+stopped\s+.*\sM2Restore\s*$').Count -eq 1) "M2 daemon did not confirm the real loop stopped before seeding" + $state.paneIds = @($state.nodeId, [guid]::NewGuid().ToString("D")) + $state.sessions = @($state.paneIds | ForEach-Object { "graphcode-$_" }) + $state.seeds = @(for ($index = 0; $index -lt 2; $index++) { + $nonce = [guid]::NewGuid().ToString("N").Substring(0, 24) + $seedScript = Join-Path $state.root "seed-$index.cmd" + [IO.File]::WriteAllText($seedScript, + "@echo off`r`npowershell.exe -NoProfile -NonInteractive -Command `"`$v=17*19;Write-Output ('GC_${nonce}_'+`$PID+'_'+`$v)`"`r`n") + $process = Start-UiaRestorationProcess $state $state.zmx @("--daemon", $state.sessions[$index], + (Join-Path ([Environment]::SystemDirectory) "cmd.exe"), "/Q", "/K", $seedScript) "seed-$index" + [pscustomobject]@{ session = $state.sessions[$index]; nonce = $nonce; process = $process } + }) + $readyClock = [Diagnostics.Stopwatch]::StartNew() + $probe = 0 + do { + foreach ($seed in $state.seeds) { Assert-UiaRestorationHeldRoot $state $seed.process } + $probe++ + $listing = Invoke-UiaRestorationCommand $state $state.zmx @("ls") "seed-list-$probe" + $ready = $true + foreach ($session in $state.sessions) { + $rows = @($listing -split "`n" | Where-Object { ($_ -split '\s+') -ccontains "name=$session" }) + if ($rows.Count -ne 1 -or $rows[0] -match '(?:^|\s)(?:err|ended|exit_code)=') { $ready = $false } + } + if ($ready) { break } + Require ($readyClock.ElapsedMilliseconds -lt 15000) "M2 exact seeded sessions did not become live" + Start-Sleep -Milliseconds 100 + } while ($true) + $layouts = Join-Path $state.support "terminal-layouts" + $null = New-Item -ItemType Directory -Path (Join-Path $layouts "shell-sessions") -Force + $state.layoutPath = Join-Path $layouts "$($state.nodeId).json" + $layout = [ordered]@{ schemaVersion = 2; project = $state.project; loop = $state.nodeId + selectedTab = 0; tabs = @(@{ id = 1; direction = "horizontal"; focused = 0 + panes = @(@{ id = $state.paneIds[0]; agent = $true }, @{ id = $state.paneIds[1]; agent = $false }) }) } + [IO.File]::WriteAllText($state.layoutPath, ($layout | ConvertTo-Json -Depth 8 -Compress)) + [IO.File]::WriteAllText((Join-Path $layouts "shell-sessions\$($state.sessions[1])"), $state.nodeId) +} + +function Open-UiaRestorationWorkspace($state, [string] $stem) { + $process = Start-UiaRestorationProcess $state $state.shell @() $stem + $attempts = [Math]::Max(1, [Math]::Min(40, [Math]::Floor((Get-UiaRestorationRemaining $state 10000) / 250))) + $root = Wait-ForRootReconnect $process @([System.Windows.Automation.TreeWalker]::RawViewWalker) $attempts + $window = $process.MainWindowHandle + Require (Ensure-ShellForeground $window "M2 normal workspace activation" (Get-UiaRestorationRemaining $state)) ` + "M2 normal foreground activation was refused" + $walker = [System.Windows.Automation.TreeWalker]::RawViewWalker + foreach ($identity in @( + (Get-MultiProjectAutomationId "open-project" $state.project), + (Get-MultiProjectAutomationId "loop" $state.project $state.nodeId) + )) { + $attempts = [Math]::Max(1, [Math]::Min(40, [Math]::Floor((Get-UiaRestorationRemaining $state 10000) / 250))) + $root = Wait-ForRootReconnect $process @($walker) $attempts + $element = Find-FragmentByIdWithRetry $root $identity $walker + Require ($null -ne $element -and $element.Current.ProcessId -eq $process.Id -and + $element.Current.IsEnabled -and -not $element.Current.IsOffscreen) "M2 normal workspace action is unavailable: $identity" + $element.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() + } + return [pscustomobject]@{ process = $process; window = $window } +} + +function Read-UiaRestorationPanes($state, $app) { + Assert-UiaRestorationHeldRoot $state $app.process + foreach ($seed in $state.seeds) { Assert-UiaRestorationHeldRoot $state $seed.process } + $null = Get-UiaRestorationRemaining $state + $root = [System.Windows.Automation.AutomationElement]::FromHandle($app.window) + $condition = [System.Windows.Automation.PropertyCondition]::new( + [System.Windows.Automation.AutomationElement]::IsTextPatternAvailableProperty, $true) + $elements = @($root.FindAll([System.Windows.Automation.TreeScope]::Descendants, $condition) | + Where-Object { -not $_.Current.IsOffscreen -and $_.Current.BoundingRectangle.Width -gt 0 -and + $_.Current.BoundingRectangle.Height -gt 0 }) + Require ($elements.Count -eq 2) "M2 requires exactly two visible real terminal Text providers" + $snapshots = @(foreach ($element in $elements) { + $current = $element.Current + $parent = [System.Windows.Automation.TreeWalker]::ControlViewWalker.GetParent($element) + $text = [string]$element.GetCurrentPattern( + [System.Windows.Automation.TextPattern]::Pattern).DocumentRange.GetText(-1) + [pscustomobject]@{ + element = $element; text = $text; identity = Get-RuntimeIdentity $element + processId = $current.ProcessId; parentProcessId = if ($parent) { $parent.Current.ProcessId } else { 0 } + visible = $true; focused = $current.HasKeyboardFocus + window = [IntPtr]$current.NativeWindowHandle + windowClass = [GraphCodeUiaGateState]::WindowClass([IntPtr]$current.NativeWindowHandle) + } + }) + return @(Resolve-UiaRestorationSessionMarkers $state $snapshots $app.process.Id) +} + +function Resolve-UiaRestorationSessionMarkers($state, [object[]] $snapshots, [int] $processId) { + Require ($processId -gt 0 -and $snapshots.Count -eq 2 -and @($state.sessions).Count -eq 2 -and + @($state.seeds).Count -eq 2 -and + @($state.sessions | Sort-Object -Unique -CaseSensitive).Count -eq 2 -and + @($state.seeds.nonce | Sort-Object -Unique -CaseSensitive).Count -eq 2) ` + "M2 content binding requires two distinct seeded sessions, nonces and providers" + $mapped = [Collections.Generic.Dictionary[string, object]]::new([StringComparer]::Ordinal) + $identities = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + $windows = [Collections.Generic.HashSet[long]]::new() + foreach ($snapshot in $snapshots) { + Require ($snapshot.processId -eq $processId -and $snapshot.parentProcessId -eq $processId -and + $snapshot.window -ne [IntPtr]::Zero -and $snapshot.windowClass -ceq "WinghosttyEmbeddableSurface" -and + -not [string]::IsNullOrWhiteSpace($snapshot.identity) -and $identities.Add($snapshot.identity) -and + $windows.Add($snapshot.window.ToInt64())) "M2 terminal provider is foreign, duplicated or not a real Winghostty HWND" + $matches = @($state.seeds | Where-Object { $snapshot.text.Contains("GC_$($_.nonce)_") }) + Require ($matches.Count -gt 0) "M2 provider/session marker is not published yet" + Require ($matches.Count -eq 1 -and -not $mapped.ContainsKey($matches[0].session)) ` + "M2 provider/session content binding is ambiguous or duplicated" + $seed = $matches[0] + $witness = Read-UiaTerminalComputedMarker $snapshot.text $seed.nonce 323 + $mapped[$seed.session] = [pscustomobject]@{ + element = $snapshot.element; text = $snapshot.text; identity = $snapshot.identity + processId = $snapshot.processId; parentProcessId = $snapshot.parentProcessId + visible = $snapshot.visible; focused = $snapshot.focused + window = $snapshot.window; session = $seed.session; seedWitnessPid = $witness + } + } + return @($state.sessions | ForEach-Object { + Require ($mapped.ContainsKey($_)) "M2 expected session has no unique current provider" + $mapped[$_] + }) +} + +function Wait-UiaRestorationPanes($state, $app) { + $watch = [Diagnostics.Stopwatch]::StartNew() + do { + $null = Get-UiaRestorationRemaining $state + try { return @(Read-UiaRestorationPanes $state $app) } catch { + if ($_.Exception.Message -cnotin @( + "M2 requires exactly two visible real terminal Text providers", + "M2 provider/session marker is not published yet", + "Terminal challenge did not publish exactly one shell-computed output line" + ) -and -not (Test-RetryableUiaError $_)) { throw } + Write-Host "M2_PROVIDER_WAIT_REFUSAL=$($_.Exception.Message)" + if ($watch.ElapsedMilliseconds -ge 15000) { throw } + } + Start-Sleep -Milliseconds 100 + } while ($true) +} + +function Assert-UiaRestorationSavedSelection($state) { + $layout = Get-Content -LiteralPath $state.layoutPath -Raw | ConvertFrom-Json + Require ($layout.schemaVersion -eq 2 -and $layout.project -ceq $state.project -and + $layout.loop -ceq $state.nodeId -and $layout.selectedTab -eq 0 -and + @($layout.tabs).Count -eq 1 -and $layout.tabs[0].focused -eq 1 -and + @($layout.tabs[0].panes).Count -eq 2 -and + $layout.tabs[0].panes[0].id -ceq $state.paneIds[0] -and + $layout.tabs[0].panes[1].id -ceq $state.paneIds[1] -and + $layout.tabs[0].panes[0].agent -is [bool] -and $layout.tabs[0].panes[0].agent -and + $layout.tabs[0].panes[1].agent -is [bool] -and -not $layout.tabs[0].panes[1].agent) ` + "M2 normal first-window selection was not persisted to the expected pane/session layout" +} + +function Select-UiaRestorationInitialPane($state, $app) { + $panes = @(Wait-UiaRestorationPanes $state $app) + $panes[1].element.SetFocus() + $watch = [Diagnostics.Stopwatch]::StartNew() + do { + $null = Get-UiaRestorationRemaining $state + $layout = Get-Content -LiteralPath $state.layoutPath -Raw | ConvertFrom-Json + if ($layout.tabs[0].focused -eq 1) { break } + Require ($watch.ElapsedMilliseconds -lt 5000) "M2 initial UI selection did not persist" + Start-Sleep -Milliseconds 50 + } while ($true) + Assert-UiaRestorationSavedSelection $state +} + +function Close-UiaRestorationWindow($state, $app) { + Assert-UiaRestorationHeldRoot $state $app.process + Require ([GraphCodeUiaGateState]::PostCommand($app.window, 0x5002)) "M2 normal tray Exit command was rejected" + Require ($app.process.WaitForExit((Get-UiaRestorationRemaining $state))) "M2 original shell did not exit" + Require ($app.process.ExitCode -eq 0) "M2 original shell failed during normal close" + Require ((Get-UiaRestorationRemaining $state 10000) -eq 10000) "M2 shell finalization would consume the cleanup reserve" + Complete-ShellOwnedProcess $state.scope $app.process + Assert-UiaRestorationSavedSelection $state + foreach ($seed in $state.seeds) { Assert-UiaRestorationHeldRoot $state $seed.process } +} + +function Assert-UiaRestorationObservedFocus($state, $app, $panes, [string] $expectedIdentity) { + $null = Get-UiaRestorationRemaining $state + $focused = [System.Windows.Automation.AutomationElement]::FocusedElement + $snapshot = [pscustomobject]@{ + foreground = [GraphCodeUiaGateState]::IsForegroundWindow($app.window) + focusedProcessId = if ($focused) { $focused.Current.ProcessId } else { 0 } + focusedIdentity = if ($focused) { Get-RuntimeIdentity $focused } else { "" } + panes = $panes + } + Assert-UiaUnrepairedTerminalFocus $snapshot $app.process.Id $expectedIdentity + [GraphCodeUiaGateState]::RequireOwnedKeyboardInput($app.window, $app.process.Id, $panes[1].window) +} + +function Invoke-UiaRestorationFreshInput($state, $app) { + Assert-UiaRestorationSavedSelection $state + $panes = @(Wait-UiaRestorationPanes $state $app) + $identity = $panes[1].identity + Assert-UiaRestorationObservedFocus $state $app $panes $identity + $before = @($panes | ForEach-Object { $_.text }) + $nonce = [guid]::NewGuid().ToString("N").Substring(0, 24) + $command = "powershell.exe -NoProfile -NonInteractive -Command `"`$v=17*19;Write-Output ('GC_${nonce}_'+`$PID+'_'+`$v)`"" + $counts = [GraphCodeUiaGateState]::SendUnicodeInput($app.window, $app.process.Id, $panes[1].window, $command) + Require ($counts.Count -eq 2 -and $counts[0] -eq 2 * $command.Length -and $counts[1] -eq $counts[0]) ` + "M2 guarded Unicode SendInput count differs" + $panes = @(Read-UiaRestorationPanes $state $app) + Assert-UiaRestorationObservedFocus $state $app $panes $identity + Require ([GraphCodeUiaGateState]::SendKeyInput($app.window, $app.process.Id, $panes[1].window, 0x0D, 1) -eq 1) ` + "M2 guarded Enter SendInput count differs" + $watch = [Diagnostics.Stopwatch]::StartNew() + do { + $panes = @(Read-UiaRestorationPanes $state $app) + Assert-UiaRestorationObservedFocus $state $app $panes $identity + if ($panes[1].text -match ('(?m)^GC_' + $nonce + '_[1-9][0-9]*_[0-9]+\s*$')) { + $witness = Assert-UiaTerminalChallengeOutput $before @($panes | ForEach-Object { $_.text }) 1 $nonce 323 + return [ordered]@{ session = $state.sessions[1]; paneId = $state.paneIds[1] + providerIdentity = $identity; providerHwnd = $panes[1].window.ToInt64() + nonce = $nonce; computedPidDataWitness = $witness; arithmetic = 323; focusRepaired = $false } + } + Require ($watch.ElapsedMilliseconds -lt 5000) "M2 fresh shell-computed selected-pane output did not arrive" + Start-Sleep -Milliseconds 50 + } while ($true) +} + +function Close-UiaRestorationState($state) { + if ($null -eq $state) { return } + $cleanupClock = [Diagnostics.Stopwatch]::StartNew() + try { + Close-ShellProcessScope $state.scope + foreach ($launch in $state.launches) { + foreach ($stream in @("stdout", "stderr")) { + $text = Read-ShellOwnedOutput $state.scope $launch.process -StandardError:($stream -ceq "stderr") + [IO.File]::WriteAllText((Join-Path $state.root "logs\$($launch.stem).$stream.log"), $text) + } + } + Require ($cleanupClock.ElapsedMilliseconds -lt 90000) "M2 cleanup exceeded its 90-second reserve" + } catch { + $cleanupFailure = $_ + if ($state.failure) { $state.failure.Exception.Data["M2CleanupFailure"] = $cleanupFailure.Exception } + $partialFailure = $null + if (-not $state.scope.Closed) { + try { Save-UiaRestorationPartialOutput $state $cleanupClock } catch { + $partialFailure = $_ + $cleanupFailure.Exception.Data["M2PartialCaptureFailure"] = $_.Exception + Write-Host "M2_PARTIAL_CAPTURE_FAILURE=$($_.Exception.Message)" + } + } + if ((ShellProcessOwnership\Test-ShellOwnershipUnproven $cleanupFailure) -or + ($partialFailure -and (ShellProcessOwnership\Test-ShellOwnershipUnproven $partialFailure)) -or + ($state.failure -and (ShellProcessOwnership\Test-ShellOwnershipUnproven $state.failure))) { + Wait-UiaRestorationUnknown $state + } + if ($state.failure) { + Write-Host "M2_CLEANUP_FAILURE_RETAINED=$($cleanupFailure.Exception.Message)" + return + } + throw $cleanupFailure + } + if ($state.failure -and (ShellProcessOwnership\Test-ShellOwnershipUnproven $state.failure)) { + Wait-UiaRestorationUnknown $state + } +} + +function Save-UiaRestorationPartialOutput($state, $cleanupClock) { + foreach ($launch in $state.launches) { + do { + Require ($cleanupClock.ElapsedMilliseconds -lt 90000) "M2 partial capture exhausted the cleanup reserve" + $batch = Receive-ShellOwnedOutput $state.scope $launch.process -MaximumCharacters 65536 + [IO.File]::AppendAllText((Join-Path $state.root "logs\$($launch.stem).stdout.partial.log"), $batch.Output) + [IO.File]::AppendAllText((Join-Path $state.root "logs\$($launch.stem).stderr.partial.log"), $batch.Error) + } while ($batch.Output.Length -ge 65536 -or $batch.Error.Length -ge 65536) + } +} + +function Wait-UiaRestorationUnknown($state) { + Write-Host "GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven" + Write-Host "M2_UNKNOWN_LIFETIME_HOLD root=$($state.root)" + [Console]::Out.Flush() + [Threading.Thread]::Sleep([Threading.Timeout]::Infinite) +} + +function Invoke-UiaRestoredTerminalRoute([string] $shell, [string] $daemon, [string] $cli, [string] $zmx, [string] $root) { + Assert-UiaRestorationPeerCapability + $state = $null + $result = $null + try { + $state = New-UiaRestorationState $root $shell $daemon $cli $zmx + $daemonProcess = Start-UiaRestorationProcess $state $state.daemon @() "daemon" + $observer = Confirm-UiaRestorationDaemonPeer $state $daemonProcess + Initialize-UiaRestorationSessions $state + $first = Open-UiaRestorationWorkspace $state "first-shell" + Select-UiaRestorationInitialPane $state $first + Close-UiaRestorationWindow $state $first + $reopened = Open-UiaRestorationWorkspace $state "reopened-shell" + $input = Invoke-UiaRestorationFreshInput $state $reopened + $null = Confirm-UiaRestorationDaemonPeer $state $daemonProcess + Close-UiaRestorationWindow $state $reopened + $result = [ordered]@{ evidence = "M2_REAL_CLOSE_REOPEN_UNREPAIRED_SELECTED_PANE_INPUT" + observer = $observer; input = $input; root = $state.root + limit = "Functional only; observer connection is not the app pipe handle; no agent-backend, glyph or whole-parity claim." } + } catch { + if ($state) { $state.failure = $_ } + throw + } finally { Close-UiaRestorationState $state } + return $result +} + function Wait-UiaUnwiredRecoveryVisibility($root, $rawWalker, $process, $wire, $mark) { $wireId = [string]$wire.Current.AutomationId $markId = [string]$mark.Current.AutomationId @@ -5335,6 +5858,13 @@ if ($cardsPilotEnvironment.Contains("GRAPHCODE_CARDS_NATIVE_ACCEPTANCE_PLAN")) { return } +if ($RestoredTerminalInputOnly) { + Require (-not $SidebarParityOnly -and -not $UnwiredRecoveryOnly -and -not $CycleHierarchyOnly -and + $ArgumentList.Count -eq 0) "M2 real route cannot combine synthetic cases or arbitrary shell arguments" + Invoke-UiaRestoredTerminalRoute $Shell $Daemon $Cli $Zmx $RestorationRoot | ConvertTo-Json -Depth 8 -Compress + return +} + $oldZmx = [Environment]::GetEnvironmentVariable("GRAPHCODE_ZMX") $oldCwd = [Environment]::GetEnvironmentVariable("GRAPHCODE_GATE_CWD") $oldGate = [Environment]::GetEnvironmentVariable("GRAPHCODE_UIA_GATE") diff --git a/Tools/windows/validation-matrix.md b/Tools/windows/validation-matrix.md index 17407f51c..c5084b1e7 100644 --- a/Tools/windows/validation-matrix.md +++ b/Tools/windows/validation-matrix.md @@ -849,12 +849,153 @@ so the two run in parallel. - `Assert-ProviderCheckout.ps1` re-verifies each provider's pinned SHA and clean tree after a restore. +**Official gobject prefetch for the required Swift real-zmx test.** +`Initialize-GobjectPackageCache.ps1` downloads only official release asset +493157198 through GitHub's asset API using the existing read-only job token. +It requires 1,867,503 bytes and SHA-256 +`D572886E8672A46AC6C740E3FE3A1F1C9D9A1A9F09D59FD62328A41FCDB70196` +before the pinned Zig 0.16.0 performs a local, no-save fetch. Network and +fetch deadlines are 60 and 120 seconds respectively, without retries. + +Zig 0.16.0 requires a build-root context even for `fetch`, so this helper uses +a fresh private directory with a zero-byte `build.zig`, not a provider checkout. +The fetch must return exactly +`gobject-0.3.2-Skun7F6HogCMynX2JqeSHS7xr-8pK4ob-qRFIcEasVi3` +with complete original held-job cleanup. Only then does it publish its fresh +job-owned `ZIG_GLOBAL_CACHE_DIR`. Zig's normal provider build reads the +hash-addressed `p/HASH.tar.gz` there before a remote fetch and verifies the +package content again. No local cache is transferred, manifest or pin is +changed, or acquired build script is executed during prefetch. The existing +required `Test-RealZmxEmptyList.ps1` build/test remains unchanged and mandatory. +The Swift job compiles existing ownership bindings but does not execute another +validation runner. Verification receipts, private context writes and original +worker streams are retained on failure as well as success. The headless +helper announces and flushes the existing root-retention marker before an +unknown-ownership lifetime hold. Its managed propagation contract uses the +source-bound observer (`WindowsCiTopology.Tests.ps1 -RetentionPropagationOnly` +with `GRAPHCODE_VALIDATION_ASSEMBLY` set to the current canonical build); +it does not start a native lifetime hold. The headless +byte/hash, argument transport, refusal and job-order contracts run through +`pwsh -NoProfile -File Tools\windows\Tests\WindowsCiTopology.Tests.ps1`; +they do not establish a successful current-head provider build or XCTest run. + **Timeouts and cancellation.** - Every Windows job has a `timeout-minutes`. - A newer push to the same pull request cancels the older in-flight run. - Runs for any other event use a unique concurrency group, so they are never cancelled. +## Automatic selected-pane restoration qualification + +The observation guards `Assert-UiaUnrepairedTerminalFocus` and +`Assert-UiaTerminalChallengeOutput` in `uia-live-gate.ps1` have headless contracts +in the existing `Tests\TerminalGate.Tests.ps1` entry point (20 executed cases). +They reject absent, duplicate, foreign, background, stale, or nonexclusive focus, +require a nonzero selected pane in a **persisted-layout-ordered** roster, and +reject stale nonces, command echoes, duplicate output, wrong arithmetic, invalid +shell PIDs, or nonce output on another pane. The opt-in +`-RestoredTerminalInputOnly` route now calls these guards, with 25 additional +mock/source/managed-file adapter cases in that same headless entry point. + +**Integrated observer source binding, not a new native qualification:** +`Assert-UiaRestorationPeerCapability` binds `UiCaptureDriver.psm1` to SHA-256 +`35B3985DDF7C7EAAC6360AE90917BF143DA633CF03E687F5214AE4CD89C1F297`. +This integrates main's Wire merge `1aa4a225` with M3's `df0a80cc`, +`2379a614`, `8aec6ba1`, and `d621c494`; the read-only peer method is unchanged. +The previous module hash `B499EA0F` belongs to the historical M3/51c tuple, +not these integrated module bytes. Preflight checks +those source bytes, requires a fresh helper load, and requires the actual +`PipeServerProcessId(SafePipeHandle)` method. Different bytes still refuse +before resource creation; there is no command-line waiver. The default startup +imports the shared module normally; the opt-in route imports it only through +its preflight and then runs `Initialize-CapUiaClient` with its own current +source-bound assembly before any UIA/native route work. + +The coordinator qualified M3 source `d621c4948b5b5ee2172e4ab9ae6e8c9db9bf6857` +with its actual All-five receipt at `D:\np5b\s\report.json`, SHA-256 +`217703BE1ED8FFA0DE346EC062BB052DF21A30C04F2104384CEFC79E58361F4C`. +Its terminal receipt hash was +`09A0DC3A9B54F2B95EFDC488E64CC3144D08372CD3A722497FACEA0834ED2AD7`; +elapsed 3.443 seconds, all six cleanup booleans true, original held child PID +27920/generation 134384774974541102 matched both kernel queries, with three +matched payload bytes and preserved nonpipe error 6. This is the original +**M3 tuple**, not a rerun or M2 app/provider/daemon qualification. The earlier +`np5`/`8aec6ba1` failure remains failed. The imported test source hash is +`0328888460DB9F6E859C82C4B12D93A2DE15AA5DE1A6D2B1E1B02F309B01D9DF`. +That test hash is historical too; integrated tests also contain main's compiler, +hierarchy, Create, and image-buffer contracts. The compiled CaptureSuite retains +all prerequisites: peer 5, compiler 27, hierarchy 32, Create 15, image buffer 9. +The accepted restored-pane run at `51c71b9b` remains historical execution +evidence only. Main changes product and native image/window surfaces, so a new +integrated source-bound build and separately admitted functional rerun are +required before claiming this head is natively qualified. + +The normal default UIA gate sets synthetic fixture and +connection-failure flags and uses its identity-file/PID-reopen launcher. It +does not establish real-daemon/session restoration and is unchanged. The new +route returns before that default path and uses `ShellProcessOwnership.psm1` +suspended original launches and retained jobs, private profile/support/temp, +explicit child environments with no inherited UIA or synthetic workspace flags, +and normal production CLI commands. Since Swift's zmx locator ignores `PATH`, +the route stages the admitted provider bytes in its private support `bin` and +rejects a different provider beside either Swift executable. + +It registers its own project, obtains the canonical project path using +`projects --json`, creates/stops one attended main loop, then starts two real +pinned-zmx `--daemon` roots with separate held launch jobs and distinct +shell-computed nonce/PID/arithmetic markers. Stopping the loop prevents opening +it from launching an agent backend. The test prepares the existing schema-v2 +layout and shell ownership record with **pane zero initially selected**; this +setup is not evidence that the product created a split. Only the first window +uses the normal pane-focus action to select pane one and waits for the product +to persist it. The original shell exits through its normal tray Exit command; +its original root and attach job are finalized before a new held shell is +launched. Both separately owned seed roots must remain alive. + +Each opening uses normal project/loop UIA actions. Every current owned +Winghostty provider is mapped to its session by actual TextPattern marker +content, never screen order or a generic ID. The persisted pane/session order +then identifies the expected selected provider. After reopen, actual UIA and +native keyboard focus must already match it exclusively. There are no pane +SetFocus calls, pane clicks, setters, or forced foreground recovery after +reopen. Guarded `SendUnicodeInput`/`SendKeyInput` require the retained shell, +foreground HWND and exact current pane HWND; a fresh shell-computed response +must appear on that provider and not the other one. Printed PIDs are data +witnesses, never process-control authority. The read-only peer query proves +only the observer connection's server, not the shell's separate pipe handle. + +Coordinator/executor invocation **after** own source-bound guard/build custody, +source/binary admission and a serialized native lane: + +```powershell +pwsh -NoProfile -File Tools\windows\uia-live-gate.ps1 ` + -RestoredTerminalInputOnly ` + -Shell (Join-Path $PWD 'graphcode-windows\zig-out\bin\graphcode-windows.exe') ` + -Daemon (Join-Path $daemonRuntime 'graphcoded.exe') ` + -Cli (Join-Path $daemonRuntime 'graphcode.exe') ` + -Zmx (Join-Path $env:GRAPHCODE_ZMX_ROOT 'zig-out\bin\zmx.exe') ` + -RestorationRoot D:\gc-m2-restore +``` + +`$daemonRuntime` is the current worktree's staged Swift release runtime returned +by the existing canonical build, with its matching runtime DLLs. The restoration +root must be a new, short, private absolute path. The executor must establish +binary provenance against the admitted source and provider pins; recorded input +hashes alone do not prove which source compiled a binary. + +Work shares the original 20-minute case clock and protects a 90-second cleanup +reserve. Existing bounded waits are clamped to that clock; UIA/OS calls do not +acquire a kernel scheduling guarantee from a stopwatch. Cleanup uses the +original scope/job API to require zero owned survivors and pipe EOF, retains +independent stdout/stderr logs for each original launch, and holds lifetime +authority indefinitely on UNKNOWN instead of discovering/killing by PID or +name. On incomplete teardown, buffered output is retained as separate +`.stdout.partial.log` and `.stderr.partial.log` files, without an EOF claim. +Success evidence is emitted only after cleanup completes. Failures and setup +artifacts remain in the owned root. Headless passing +cases are not native input, persistence, provider, glyph, agent-backend, or +whole-parity qualification; the native result remains pending. + ## Winghostty fork `Tools/windows/bootstrap.ps1` checks out the public provider at the exact commit in diff --git a/investigation/spikes/windows-terminal-gate/README.md b/investigation/spikes/windows-terminal-gate/README.md index 8596328b1..60230243c 100644 --- a/investigation/spikes/windows-terminal-gate/README.md +++ b/investigation/spikes/windows-terminal-gate/README.md @@ -76,11 +76,54 @@ The gate never calls GraphCode canvas/sidebar code and does not duplicate daemon orchestration; the existing Windows daemon remains an independent service. Each attach child is spawned with `GRAPHCODE_GATE_CWD`; the first-session smoke sends the shell's `cd`/pwd query and verifies the repository root. -Cleanup checks every `zmx kill` exit, confirms the named registrations and -reported processes disappear, and fails the gate if cleanup is incomplete. +The canonical harness starts persistent sessions in separate suspended, +held-original non-breakaway jobs using the existing `OwnedChildSupervisor`. +Every spike, provider control/history command, pin query and build command +uses that same owned-launch API. Spike completion closes only its attach job; +the separately held zmx session roots survive the restart stages. Cleanup +terminates only those owned seed jobs and requires root exit, job emptiness, +both raw-stream EOFs and resource closure. Session names, CIM/ancestry and +process-image observations are never termination authority. `GRAPHCODE_TERMINAL_GATE_INJECT_CLEANUP_FAILURE=1` is a failure-injection contract used to prove cleanup errors cannot produce a green result. +The harness creates only its new artifact root and configured ZMX child with +`ValidationFixtureDirectoryNative.CreateOwnedByCurrentUser`. A creation-time +security descriptor assigns the token's current user as owner and grants +inheritable FullControl only to that user. Existing directory collisions or +reparse parents refuse; the created directory handle's owner is checked before +launch. There is no post-create SetACL, privilege enabling, or adoption of an +existing/shared directory. A mismatch preserves the new diagnostic path and +fails; read-back is not an atomic adversarial-race proof. The pinned provider's +current-user owner guard stays unchanged. + +Every control command has a finite deadline; history/health retries share their +original 10-second budget rather than resetting it per subprocess. Work is +bounded at 1800 seconds and seed cleanup shares a separate 9-second reserve. +The existing owned supervisor's cleanup jobs each retain their original +5-second deadline, started together. These limits do not turn OS scheduling +into a hard latency guarantee. + +The spike uses the already-held Windows `Child.id` handle for finite +`WaitForSingleObject` and `TerminateProcess` calls. It calls normal Child +finalization only after the original handle signals. Unknown attach exit +retains the Child/app and emits the existing lifetime-retention sentinel; +the supervisor observes it and refuses termination/resource closure. Final +teardown proves both attach exits before provider destruction/deinitialization +and runs before a successful return. Restart cannot launch a replacement after +failed retirement. Provider destruction/deinitialization failures remain errors. + +All independent raw stdout/stderr logs remain under the reported +`TERMINAL_GATE_ARTIFACT_ROOT`, including failure and partial output. The final +smoke/stress PASS is emitted only after cleanup. Resource diagnostics use the +existing supervisor's retained-root `ReadResources` API: handle count and +private bytes come from native queries on that original live handle, with +PID/generation/liveness guards before and after. The active spike and all three +held zmx roots are sampled before spike completion; snapshot IDs have a unique +monotonic sequence even when phase labels repeat. A failed/exited-root query +refuses the measurement, never returns zero counters or reopens a PID. +The sample label is active invocation, not a claim of post-typed-workload memory. + The provider owns the rendering/input/IME/clipboard/per-monitor-DPI/UIA semantics. The gate owns the caller-side renderer lifecycle, focus policy, per-surface attach transport, and a parsed VT cell snapshot that it submits @@ -101,6 +144,12 @@ GREEN after the Zig host, provider pins, and smoke harness were added. contract check. The harness sends shell commands through zmx, verifies `zmx history --vt` before and after independent and same-session restarts, and runs the destroy/recreate stress cycle. +`src\AttachCleanup.zig` supplies the pure retained-handle/restart/teardown +contracts. Canonical gate builds run this root with a positive test-count +assertion before the native workload; the existing PowerShell entry point +also checks the source-bound owned command and cleanup orchestration. +Pure/injected tests and a successful compile are not real-provider runtime +qualification of the repaired harness. At the accepted provider SHA, the Winghostty API lifecycle, input, pixel cell render, and three-process renderer stress contracts pass. GraphCode full diff --git a/investigation/spikes/windows-terminal-gate/src/AttachCleanup.zig b/investigation/spikes/windows-terminal-gate/src/AttachCleanup.zig new file mode 100644 index 000000000..738738861 --- /dev/null +++ b/investigation/spikes/windows-terminal-gate/src/AttachCleanup.zig @@ -0,0 +1,144 @@ +const std = @import("std"); + +pub fn stopWith(api: anytype, handle: anytype) !void { + if (try api.signaled(handle, 0)) return; + try api.terminate(handle); + const remaining = api.remaining(); + if (remaining == 0 or !try api.signaled(handle, remaining)) return error.AttachExitTimeout; +} + +pub fn cleanupWith(api: anytype) !void { + try api.stop(0); + try api.stop(1); + try api.destroy(0); + try api.destroy(1); + try api.deinitialize(); +} + +pub fn restartWith(api: anytype) !void { + try api.retire(); + try api.start(); +} + +const Fixture = struct { + exited: bool = false, + wait_failed: bool = false, + terminate_failed: bool = false, + timeout: bool = false, + budget: u32 = 5000, + terminated: usize = 0, + waits: usize = 0, + wait_failed_after_terminate: bool = false, + + fn signaled(self: *Fixture, handle: usize, milliseconds: u32) !bool { + try std.testing.expectEqual(@as(usize, 42), handle); + self.waits += 1; + if (self.wait_failed or (self.wait_failed_after_terminate and self.terminated != 0)) return error.AttachWaitFailed; + return self.exited or (milliseconds != 0 and !self.timeout); + } + fn terminate(self: *Fixture, handle: usize) !void { + try std.testing.expectEqual(@as(usize, 42), handle); + self.terminated += 1; + if (self.terminate_failed) return error.AttachTerminateFailed; + } + fn remaining(self: *Fixture) u32 { + return self.budget; + } +}; + +test "held attach already exited is not terminated" { + var api: Fixture = .{ .exited = true }; + try stopWith(&api, @as(usize, 42)); + try std.testing.expectEqual(@as(usize, 0), api.terminated); +} +test "held attach terminates and waits within finite budget" { + var api: Fixture = .{}; + try stopWith(&api, @as(usize, 42)); + try std.testing.expectEqual(@as(usize, 1), api.terminated); + try std.testing.expectEqual(@as(usize, 2), api.waits); +} +test "failed initial held wait refuses termination" { + var api: Fixture = .{ .wait_failed = true }; + try std.testing.expectError(error.AttachWaitFailed, stopWith(&api, @as(usize, 42))); + try std.testing.expectEqual(@as(usize, 0), api.terminated); +} +test "failed termination preserves concrete failure" { + var api: Fixture = .{ .terminate_failed = true }; + try std.testing.expectError(error.AttachTerminateFailed, stopWith(&api, @as(usize, 42))); + try std.testing.expectEqual(@as(usize, 1), api.waits); +} +test "unsignaled attach refuses finalization" { + var api: Fixture = .{ .timeout = true }; + try std.testing.expectError(error.AttachExitTimeout, stopWith(&api, @as(usize, 42))); +} +test "expired original deadline never starts a new wait budget" { + var api: Fixture = .{ .budget = 0 }; + try std.testing.expectError(error.AttachExitTimeout, stopWith(&api, @as(usize, 42))); + try std.testing.expectEqual(@as(usize, 1), api.waits); +} +test "failed post-termination wait cannot finalize child" { + var api: Fixture = .{ .wait_failed_after_terminate = true }; + try std.testing.expectError(error.AttachWaitFailed, stopWith(&api, @as(usize, 42))); +} + +const CleanupFixture = struct { + calls: usize = 0, + failure_at: ?usize = null, + pub fn stop(self: *CleanupFixture, index: usize) !void { + try std.testing.expectEqual(index, self.calls); + try self.advance(); + } + pub fn destroy(self: *CleanupFixture, index: usize) !void { + try std.testing.expectEqual(index + 2, self.calls); + try self.advance(); + } + pub fn deinitialize(self: *CleanupFixture) !void { + try std.testing.expectEqual(@as(usize, 4), self.calls); + try self.advance(); + } + fn advance(self: *CleanupFixture) !void { + if (self.failure_at == self.calls) return error.OriginalCleanupFailure; + self.calls += 1; + } +}; + +test "final cleanup proves both child exits before provider destruction and host deinit" { + var fixture: CleanupFixture = .{}; + try cleanupWith(&fixture); + try std.testing.expectEqual(@as(usize, 5), fixture.calls); +} +test "every final cleanup failure refuses success and keeps its first failure" { + for (0..5) |failure_at| { + var fixture: CleanupFixture = .{ .failure_at = failure_at }; + try std.testing.expectError(error.OriginalCleanupFailure, cleanupWith(&fixture)); + try std.testing.expectEqual(failure_at, fixture.calls); + } +} + +const RestartFixture = struct { + retire_failed: bool = false, + start_failed: bool = false, + starts: usize = 0, + pub fn retire(self: *RestartFixture) !void { + if (self.retire_failed) return error.OriginalRetireFailure; + } + pub fn start(self: *RestartFixture) !void { + self.starts += 1; + if (self.start_failed) return error.OriginalStartFailure; + } +}; +test "restart never launches a replacement after failed owned retirement" { + var fixture: RestartFixture = .{ .retire_failed = true }; + try std.testing.expectError(error.OriginalRetireFailure, restartWith(&fixture)); + try std.testing.expectEqual(@as(usize, 0), fixture.starts); +} +test "restart preserves start failure after proven retirement" { + var fixture: RestartFixture = .{ .start_failed = true }; + try std.testing.expectError(error.OriginalStartFailure, restartWith(&fixture)); + try std.testing.expectEqual(@as(usize, 1), fixture.starts); +} +test "restart starts exactly one replacement after successful retirement" { + var fixture: RestartFixture = .{}; + try restartWith(&fixture); + try std.testing.expectEqual(@as(usize, 1), fixture.starts); +} diff --git a/investigation/spikes/windows-terminal-gate/src/main.zig b/investigation/spikes/windows-terminal-gate/src/main.zig index c4fcea29a..e6080b9dc 100644 --- a/investigation/spikes/windows-terminal-gate/src/main.zig +++ b/investigation/spikes/windows-terminal-gate/src/main.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const AttachCleanup = @import("AttachCleanup.zig"); const c = @cImport({ @cDefine("_WIN32_WINNT", "0x0601"); @@ -526,10 +527,42 @@ fn startSession(app: *App, name: []const u8, index: usize) !void { app.surfaces[index].attach = child; } -fn waitAttachClient(slot: *SurfaceSlot) void { +const AttachExitApi = struct { + timer: std.time.Timer, + budget_ms: u32, + + pub fn signaled(_: *AttachExitApi, handle: anytype, milliseconds: u32) !bool { + return switch (c.WaitForSingleObject(@ptrCast(handle), milliseconds)) { + c.WAIT_OBJECT_0 => true, + c.WAIT_TIMEOUT => false, + else => { + std.debug.print("held attach WaitForSingleObject failed: Win32={d}\n", .{c.GetLastError()}); + return error.AttachWaitFailed; + }, + }; + } + pub fn terminate(_: *AttachExitApi, handle: anytype) !void { + if (c.TerminateProcess(@ptrCast(handle), 1) == 0) { + std.debug.print("held attach TerminateProcess failed: Win32={d}\n", .{c.GetLastError()}); + return error.AttachTerminateFailed; + } + } + pub fn remaining(self: *AttachExitApi) u32 { + const elapsed = self.timer.read() / std.time.ns_per_ms; + return if (elapsed >= self.budget_ms) 0 else self.budget_ms - @as(u32, @intCast(elapsed)); + } +}; + +fn waitAttachClient(slot: *SurfaceSlot) !void { + var api: AttachExitApi = .{ .timer = try std.time.Timer.start(), .budget_ms = 5000 }; + try waitAttachClientWith(slot, &api); +} + +fn waitAttachClientWith(slot: *SurfaceSlot, api: *AttachExitApi) !void { if (slot.attach) |*child| { - _ = child.kill() catch {}; - _ = child.wait() catch {}; + AttachCleanup.stopWith(api, child.id) catch |err| retainUnknownAttach(err); + // Child.wait's Windows wait is now on an already-signaled held handle. + _ = child.wait() catch |err| retainUnknownAttach(err); slot.attach = null; } } @@ -768,8 +801,8 @@ fn waitForInitialAttachOutput(app: *App, index: usize) !void { if (app.surfaces[index].output_seen) return; if (!attach_alive) { if (restarts == attach_restart_limit) return error.InitialAttachRestartLimit; - waitAttachClient(&app.surfaces[index]); - try startSession(app, sessionName(app, index), index); + var restart: RestartAttachApi = .{ .app = app, .index = index }; + try AttachCleanup.restartWith(&restart); restarts += 1; } std.Thread.sleep(100 * std.time.ns_per_ms); @@ -812,17 +845,20 @@ fn createWinghosttySurface(app: *App, index: usize) !void { fn createSurface(app: *App, index: usize) !void { try startSession(app, sessionName(app, index), index); createWinghosttySurface(app, index) catch |err| { - waitAttachClient(&app.surfaces[index]); + waitAttachClient(&app.surfaces[index]) catch |cleanup_err| { + std.debug.print("terminal create primary={s}; attach cleanup={s}\n", .{ @errorName(err), @errorName(cleanup_err) }); + retainUnknownAttach(cleanup_err); + }; return err; }; } -fn destroyWinghosttySurface(app: *App, index: usize) void { +fn destroyWinghosttySurface(app: *App, index: usize) !void { const slot = &app.surfaces[index]; if (slot.surface) |surface| { slot.destroying = true; rememberRetiredSurface(app, surface); - _ = c.winghostty_surface_destroy(surface); + if (c.winghostty_surface_destroy(surface) != c.WINGHOSTTY_OK) return error.SurfaceDestroyFailed; slot.surface = null; slot.destroyed = true; slot.destroying = false; @@ -831,14 +867,14 @@ fn destroyWinghosttySurface(app: *App, index: usize) void { // The gate creates two independent complete surfaces through // winghostty_host_create_surface_v2: surface A and surface B. -fn destroySurface(app: *App, index: usize) void { +fn destroySurface(app: *App, index: usize) !void { const slot = &app.surfaces[index]; - waitAttachClient(slot); - destroyWinghosttySurface(app, index); + try waitAttachClient(slot); + try destroyWinghosttySurface(app, index); } fn recreateSurface(app: *App, index: usize) !void { - destroyWinghosttySurface(app, index); + try destroyWinghosttySurface(app, index); app.recreate_count += 1; return createWinghosttySurface(app, index); } @@ -916,11 +952,22 @@ fn runInputContracts(app: *App) !void { } } +const RestartAttachApi = struct { + app: *App, + index: usize, + pub fn retire(self: *RestartAttachApi) !void { + try waitAttachClient(&self.app.surfaces[self.index]); + } + pub fn start(self: *RestartAttachApi) !void { + try startSession(self.app, sessionName(self.app, self.index), self.index); + } +}; + fn restartBrokenAttach(app: *App, index: usize) !void { const slot = &app.surfaces[index]; if (slot.attach_restarts == attach_restart_limit) return error.AttachRestartLimit; - waitAttachClient(slot); - try startSession(app, sessionName(app, index), index); + var restart: RestartAttachApi = .{ .app = app, .index = index }; + try AttachCleanup.restartWith(&restart); slot.attach_restarts += 1; slot.silent_attach_ticks = 0; } @@ -1114,15 +1161,33 @@ fn createWindow(app: *App) !void { _ = c.SetTimer(app.hwnd, timer_id, 100, null); } -fn cleanup(app: *App) void { - destroySurface(app, 0); - destroySurface(app, 1); - if (app.host) |host| { - _ = c.winghostty_host_deinitialize(host); - app.host = null; +fn retainUnknownAttach(failure: anyerror) noreturn { + std.debug.print("GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven attach={s}\n", .{@errorName(failure)}); + while (true) std.Thread.sleep(std.time.ns_per_s); +} + +const FinalCleanupApi = struct { + app: *App, + exit_api: AttachExitApi, + pub fn stop(self: *FinalCleanupApi, index: usize) !void { + waitAttachClientWith(&self.app.surfaces[index], &self.exit_api) catch |err| retainUnknownAttach(err); } + pub fn destroy(self: *FinalCleanupApi, index: usize) !void { + try destroyWinghosttySurface(self.app, index); + } + pub fn deinitialize(self: *FinalCleanupApi) !void { + if (self.app.host) |host| { + if (c.winghostty_host_deinitialize(host) != c.WINGHOSTTY_OK) return error.HostDeinitializeFailed; + self.app.host = null; + } + } +}; + +fn cleanup(app: *App) !void { + var api: FinalCleanupApi = .{ .app = app, .exit_api = .{ .timer = try std.time.Timer.start(), .budget_ms = 5000 } }; + try AttachCleanup.cleanupWith(&api); if (app.hwnd) |hwnd| { - if (c.IsWindow(hwnd) != 0) _ = c.DestroyWindow(hwnd); + if (c.IsWindow(hwnd) != 0 and c.DestroyWindow(hwnd) == 0) return error.WindowDestroyFailed; app.hwnd = null; } } @@ -1214,7 +1279,10 @@ pub fn main() !void { } try createWindow(app); - defer cleanup(app); + var cleanup_attempted = false; + errdefer if (!cleanup_attempted) cleanup(app) catch |err| { + std.debug.print("terminal gate cleanup failed while preserving primary error: {s}\n", .{@errorName(err)}); + }; if (c.winghostty_host_initialize(&app.host) != c.WINGHOSTTY_OK) { return error.WinghosttyHostInitializeFailed; } @@ -1224,4 +1292,7 @@ pub fn main() !void { app.ready = true; if (app.smoke) _ = c.PostMessageW(app.hwnd, wm_gate_tick, 0, 0); try messageLoop(app); + cleanup_attempted = true; + try cleanup(app); + if (app.callbacksAfterDestroy != 0) return error.CallbackAfterDestroy; }