diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml index 55cc4d14..e81e2f89 100644 --- a/.github/workflows/linux.yml +++ b/.github/workflows/linux.yml @@ -21,7 +21,7 @@ jobs: steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: - fetch-depth: ${{ github.event_name == 'pull_request' && 0 || 1 }} + fetch-depth: 0 - name: Test the changed-path classifier shell: bash run: bash Tools/ci/tests/classify-changes.test.sh diff --git a/.github/workflows/macos-shared-regression.yml b/.github/workflows/macos-shared-regression.yml index 090a5e7f..e9f18e01 100644 --- a/.github/workflows/macos-shared-regression.yml +++ b/.github/workflows/macos-shared-regression.yml @@ -18,7 +18,7 @@ jobs: steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: - fetch-depth: ${{ github.event_name == 'pull_request' && 0 || 1 }} + fetch-depth: 0 - name: Classify changed paths id: classify shell: bash diff --git a/.github/workflows/windows-hardening.yml b/.github/workflows/windows-hardening.yml index 66432c7d..d3912495 100644 --- a/.github/workflows/windows-hardening.yml +++ b/.github/workflows/windows-hardening.yml @@ -23,7 +23,7 @@ jobs: steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: - fetch-depth: ${{ github.event_name == 'pull_request' && 0 || 1 }} + fetch-depth: 0 - name: Classify changed paths id: classify shell: bash diff --git a/.github/workflows/windows-port-validation.yml b/.github/workflows/windows-port-validation.yml index deb914b0..02c1ae3d 100644 --- a/.github/workflows/windows-port-validation.yml +++ b/.github/workflows/windows-port-validation.yml @@ -18,7 +18,7 @@ jobs: steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: - fetch-depth: ${{ github.event_name == 'pull_request' && 0 || 1 }} + fetch-depth: 0 - name: Classify changed paths id: classify shell: bash @@ -64,3 +64,12 @@ jobs: - name: Run Windows port validation shell: pwsh run: ./Tools/windows/validate.ps1 -Task all -SkipTrayLive -SkipWslRemoteE2E + + - name: Retain UIA failure diagnostics + if: failure() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: uia-live-gate-diagnostics + path: ${{ runner.temp }}\gu-*\logs\*.json + if-no-files-found: warn + retention-days: 7 diff --git a/.github/workflows/windows-shell.yml b/.github/workflows/windows-shell.yml index b86e21e7..9b1cfca9 100644 --- a/.github/workflows/windows-shell.yml +++ b/.github/workflows/windows-shell.yml @@ -18,7 +18,7 @@ jobs: steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: - fetch-depth: ${{ github.event_name == 'pull_request' && 0 || 1 }} + fetch-depth: 0 - name: Classify changed paths id: classify shell: bash @@ -52,3 +52,12 @@ jobs: - name: Validate release packaging shell: pwsh run: ./Tools/windows/validate.ps1 -Task packaging + + - name: Retain UIA failure diagnostics + if: failure() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: uia-live-gate-diagnostics + path: ${{ runner.temp }}\gu-*\logs\*.json + if-no-files-found: warn + retention-days: 7 diff --git a/Tools/ci/classify-changes.sh b/Tools/ci/classify-changes.sh index 8646679b..88b8b0b4 100755 --- a/Tools/ci/classify-changes.sh +++ b/Tools/ci/classify-changes.sh @@ -110,15 +110,16 @@ for path in "${paths[@]}"; do esac # macOS shared Swift regression: the app, kit, daemon, CLI, the portable Swift - # package, Tuist/SwiftPM/lint configuration, submodules, and the scripts that - # make and the portable setup run. + # package, Tuist/SwiftPM/lint configuration, submodules, icon sources, and the + # scripts that make and the portable setup run. case "$path" in graphcode/* | GraphcodeKit/* | MailroomKit/* | graphcoded/* | graphcode-cli/* | \ investigation/spikes/swift-portable/* | \ Package.swift | Package.resolved | Project.swift | Tuist.swift | Tuist/* | \ Makefile | mise.toml | .swiftlint.yml | .swift-format | .gitattributes | \ .gitmodules | ThirdParty/* | scripts/* | Tools/portable-prepare.py | \ - Tools/zig-sdk-shim/* | .github/workflows/macos-shared-regression.yml) + Tools/icon/* | Tools/zig-sdk-shim/* | \ + .github/workflows/macos-shared-regression.yml) macos=true; matched=1 ;; esac @@ -133,9 +134,11 @@ for path in "${paths[@]}"; do [[ $matched -eq 1 ]] && continue - # Paths no gated suite reads. DCO and TDD-evidence still run on every PR. + # Paths no gated suite reads. The source screenshots are still privacy-scanned + # by the ungated investigation job. DCO and TDD-evidence run on every PR. case "$path" in *.md | docs/* | screenshots/* | investigation/contracts/* | \ + investigation/macos-parity-evidence/evidence/* | \ LICENSE | DCO | .env.example | .github/PULL_REQUEST_TEMPLATE.md | \ .github/ISSUE_TEMPLATE/* | .github/workflows/dco.yml | \ .github/workflows/tdd-evidence.yml) diff --git a/Tools/ci/tests/classify-changes.test.sh b/Tools/ci/tests/classify-changes.test.sh index e2181907..e9a9812a 100755 --- a/Tools/ci/tests/classify-changes.test.sh +++ b/Tools/ci/tests/classify-changes.test.sh @@ -59,6 +59,10 @@ expect "TDD evidence tooling validated by Windows suite" true false false \ Tools/tdd/Test-TddEvidence.ps1 expect "visual baseline manifest" true false false \ investigation/visual-baseline/manifest.json +expect "macOS icon source image" false true false \ + Tools/icon/icon-master-1024.png +expect "macOS reference screenshot only" false false false \ + investigation/macos-parity-evidence/evidence/empty-welcome-original.png expect "Windows release workflow" true false false \ .github/workflows/windows-release.yml @@ -122,8 +126,51 @@ expect "classifier tests" true true true \ expect "unknown path fails safe" true true true \ some-new-directory/build.sh +expect "unclassified root image fails safe" true true true \ + notes/diagram.png expect "empty change list fails safe" true true true +fixture="$(mktemp -d)" +git -C "$fixture" init -q +git -C "$fixture" config user.name "Classifier Test" +git -C "$fixture" config user.email "classifier@example.invalid" +git -C "$fixture" config core.autocrlf false +printf 'base\n' >"$fixture/README.md" +git -C "$fixture" add README.md +git -C "$fixture" commit -qm "base" +base="$(git -C "$fixture" rev-parse HEAD)" +printf 'documentation\n' >"$fixture/notes.md" +git -C "$fixture" add notes.md +git -C "$fixture" commit -qm "documentation" +head="$(git -C "$fixture" rev-parse HEAD)" +got="$( + cd "$fixture" && + GITHUB_OUTPUT='' bash "$classifier" --event pull_request --base "$base" --head "$head" 2>/dev/null | + flatten +)" +check "full-history docs-only diff is skipped" \ + "windows=false macos=false linux=false" "$got" +rm -rf "$fixture" + +# A PR path classifier must have the merge base locally; zero is a literal +# depth, not a falsy workflow-expression operand. +for workflow in \ + .github/workflows/linux.yml \ + .github/workflows/macos-shared-regression.yml \ + .github/workflows/windows-shell.yml \ + .github/workflows/windows-port-validation.yml \ + .github/workflows/windows-hardening.yml; do + depth="$(awk ' + /^ changes:/ { in_changes=1; next } + in_changes && /^ [^ ]/ { exit } + in_changes && /fetch-depth:/ { + sub(/^[[:space:]]*/, "") + print + } + ' "$here/../../../$workflow")" + check "$workflow changes checkout has full history" "fetch-depth: 0" "$depth" +done + # Non-PR events always get full validation, whatever changed. for event in push merge_group workflow_dispatch schedule; do got="$(printf 'README.md\n' | GITHUB_OUTPUT='' bash "$classifier" --event "$event" --stdin 2>/dev/null | flatten)" diff --git a/Tools/windows/Tests/ValidationRunner.Tests.ps1 b/Tools/windows/Tests/ValidationRunner.Tests.ps1 index 1b0f0d3f..8e3db31f 100644 --- a/Tools/windows/Tests/ValidationRunner.Tests.ps1 +++ b/Tools/windows/Tests/ValidationRunner.Tests.ps1 @@ -397,6 +397,12 @@ try { $windowsShellWorkflow = Get-Content (Join-Path $repoRoot ".github\workflows\windows-shell.yml") -Raw $windowsPortWorkflow = Get-Content ` (Join-Path $repoRoot ".github\workflows\windows-port-validation.yml") -Raw + foreach ($workflow in @($windowsShellWorkflow, $windowsPortWorkflow)) { + if ($workflow -notmatch + '(?s)if: failure\(\).*?actions/upload-artifact@.*?gu-\*.*?logs\\\*\.json') { + throw "RED: Windows CI does not retain failed UIA sandbox diagnostics as an artifact" + } + } foreach ($workflow in @($windowsWorkflow, $windowsShellWorkflow, $windowsPortWorkflow)) { if ($workflow -notmatch "compnerd/gha-setup-swift@397094e75494a93fa8d81db0268dbc8f5d6cf7c6" -or @@ -418,6 +424,10 @@ try { throw "RED: Windows shell CI does not invoke the shell task containing live UI Automation" } $runnerSource = Get-Content $runner -Raw + if ($runnerSource -notmatch + '(?s)WINDOWS_SHELL_PRE_UIA_PROCESS_SNAPSHOT=.*?Stop-Process -Id.*?WINDOWS_SHELL_PRE_UIA_CLEANUP=verified.*?Native UI Automation live gate') { + throw "RED: Windows shell validation does not snapshot and reap run-owned product processes before UIA" + } Test-ZigResolverDiagnostics $runnerSource Assert-ShellHostPrerequisite $runnerSource $contractCall = [regex]::Match($runnerSource, @@ -461,6 +471,163 @@ try { throw "RED: Windows shell validation does not execute the UI Automation live gate" } $uiaLiveGateSource = Get-Content (Join-Path $repoRoot "Tools\windows\uia-live-gate.ps1") -Raw + if ($uiaLiveGateSource -notmatch 'function Get-UiaStartupFileDiagnostic' -or + $uiaLiveGateSource -notmatch 'RedirectStandardOutput' -or + $uiaLiveGateSource -notmatch 'function Get-UiaPrelaunchDiagnostics' -or + $uiaLiveGateSource -notmatch 'prelaunch-diagnostics\.json' -or + $uiaLiveGateSource -notmatch 'startup-failure\.json') { + throw "RED: UIA startup failure does not capture both child streams, app log, and prelaunch host diagnostics" + } + $prelaunchDiagnostic = $uiaLiveGateSource.LastIndexOf('Get-UiaPrelaunchDiagnostics') + $shellLaunch = $uiaLiveGateSource.IndexOf('Start-Process -FilePath $Shell') + if ($prelaunchDiagnostic -lt 0 -or $shellLaunch -lt 0 -or + $prelaunchDiagnostic -gt $shellLaunch -or + $uiaLiveGateSource -notmatch 'GetCurrentWindowStationName|WindowStation' -or + $uiaLiveGateSource -notmatch 'GetCurrentDesktopName|DesktopName' -or + $uiaLiveGateSource -notmatch 'desktopHeap' -or + $uiaLiveGateSource -notmatch 'sessionId') { + throw "RED: UIA prelaunch diagnostics omit process, desktop heap, window station, or session evidence" + } + if ($uiaLiveGateSource -notmatch 'function Get-UiaOwnedProcessDescendants' -or + $uiaLiveGateSource -notmatch 'UIA_PROCESS_TREE_CLEANUP=verified') { + throw "RED: UIA teardown does not enumerate, reap, and verify all owned descendants" + } + $uiaTokens = $null + $uiaParseErrors = $null + $uiaAst = [Management.Automation.Language.Parser]::ParseInput( + $uiaLiveGateSource, [ref]$uiaTokens, [ref]$uiaParseErrors) + if ($uiaParseErrors.Count -ne 0) { + throw "RED: UIA live gate no longer parses after startup diagnostic changes" + } + foreach ($helperName in @( + "Protect-UiaStartupDiagnosticText", + "Get-UiaStartupDiagnosticValue", + "Get-UiaStartupFileDiagnostic", + "Get-UiaStartupImageHash", + "Write-UiaStartupFailureDiagnostic", + "Get-UiaPrelaunchDiagnostics", + "Get-UiaOwnedProcessDescendants", + "Stop-UiaOwnedProcessTrees" + )) { + $helper = $uiaAst.Find({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -eq $helperName + }, $true) + if ($null -eq $helper) { throw "RED: UIA startup capture helper is missing: $helperName" } + . ([scriptblock]::Create($helper.Extent.Text)) + } + $startupCapturePath = Join-Path $env:TEMP "uia-startup-capture-$PID.log" + try { + [IO.File]::WriteAllText($startupCapturePath, "loader failed`npassword=private-canary`n") + $capture = Get-UiaStartupFileDiagnostic $startupCapturePath "logs\shell-stderr.log" + if ($capture.state -ne "available" -or + $capture.content -notmatch "loader failed" -or + $capture.content -match "private-canary" -or + $capture.readBytes -ne $capture.lengthBytes) { + throw "RED: UIA startup capture does not retain bounded, redacted child output" + } + $truncatedCapture = Get-UiaStartupFileDiagnostic $startupCapturePath ` + "logs\shell-stderr.log" 8 + if ($truncatedCapture.state -ne "truncated" -or + $truncatedCapture.readBytes -ne 8 -or $truncatedCapture.lengthBytes -le 8) { + throw "RED: UIA startup capture does not bound oversized diagnostics" + } + $missingCapture = Get-UiaStartupFileDiagnostic ` + (Join-Path $env:TEMP "uia-missing-$PID.log") "logs\missing.log" + if ($missingCapture.state -ne "missing") { + throw "RED: UIA startup capture does not distinguish a missing child log" + } + $startupLogDirectory = Join-Path $env:TEMP "uia-startup-logs-$PID" + $startupSupportDirectory = Join-Path $env:TEMP "uia-startup-support-$PID" + New-Item -ItemType Directory -Path $startupLogDirectory -Force | Out-Null + New-Item -ItemType Directory -Path $startupSupportDirectory -Force | Out-Null + [IO.File]::WriteAllText( + (Join-Path $startupLogDirectory "shell-stderr.log"), + "loader failed`npassword=stderr-canary`n" + ) + [IO.File]::WriteAllText( + (Join-Path $startupLogDirectory "shell-stdout.log"), + "child output captured" + ) + [IO.File]::WriteAllText( + (Join-Path $startupSupportDirectory "graphcode-windows.log"), + "app initialization failed`nsecret=app-canary`n" + ) + Write-UiaStartupFailureDiagnostic (Get-Process -Id $PID) 0 ` + $startupCapturePath $env:TEMP $startupLogDirectory $startupSupportDirectory + $startupRecord = Get-Content -LiteralPath ` + (Join-Path $startupLogDirectory "startup-failure.json") -Raw | ConvertFrom-Json + if ($startupRecord.stderr.content -notmatch "loader failed" -or + $startupRecord.stdout.content -notmatch "child output captured" -or + $startupRecord.applicationLog.content -notmatch "app initialization failed" -or + $startupRecord.stderr.content -match "stderr-canary" -or + $startupRecord.applicationLog.content -match "app-canary") { + throw "RED: retained UIA startup report omits or exposes captured child and app output" + } + } finally { + Remove-Item -LiteralPath (Join-Path $env:TEMP "uia-startup-logs-$PID") ` + -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath (Join-Path $env:TEMP "uia-startup-support-$PID") ` + -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $startupCapturePath -Force -ErrorAction SilentlyContinue + } + $hostInfoClassAt = $uiaLiveGateSource.IndexOf("public static class GraphCodeUiaHostInfo") + if ($hostInfoClassAt -lt 0) { + throw "RED: UIA host context native diagnostics type is missing" + } + $hostInfoAddTypeAt = $uiaLiveGateSource.LastIndexOf( + 'Add-Type -TypeDefinition @"', $hostInfoClassAt + ) + $hostInfoBodyAt = $uiaLiveGateSource.IndexOf("`n", $hostInfoAddTypeAt) + 1 + $hostInfoCloseAt = $uiaLiveGateSource.IndexOf('"@', $hostInfoClassAt) + if ($hostInfoAddTypeAt -lt 0 -or $hostInfoBodyAt -le 0 -or + $hostInfoCloseAt -lt 0) { + throw "RED: UIA host context native diagnostics type is missing" + } + $hostInfoBody = $uiaLiveGateSource.Substring( + $hostInfoBodyAt, $hostInfoCloseAt - $hostInfoBodyAt + ).TrimEnd("`r", "`n") + Add-Type -TypeDefinition $hostInfoBody + $hostSessionId = [GraphCodeUiaHostInfo]::CurrentSessionId() + if ($hostSessionId -isnot [uint32]) { + throw "RED: UIA host context did not resolve the current Windows session" + } + $hostDiagnostics = Get-UiaPrelaunchDiagnostics + if ($hostDiagnostics.sessionId.state -ne "available" -or + $hostDiagnostics.currentProcessId -ne $PID -or + $hostDiagnostics.desktopHeap.state -ne "usage_unavailable") { + throw "RED: UIA host context diagnostics omitted explicit session or desktop-heap status" + } + $treeFixturePath = Join-Path $env:TEMP "uia-process-tree-$PID.ps1" + $treeRoot = $null + try { + [IO.File]::WriteAllText($treeFixturePath, @' +$start = [Diagnostics.ProcessStartInfo]::new((Join-Path $PSHOME "pwsh.exe")) +$start.ArgumentList.Add("-NoProfile") +$start.ArgumentList.Add("-Command") +$start.ArgumentList.Add("Start-Sleep -Seconds 60") +[void][Diagnostics.Process]::Start($start) +Start-Sleep -Seconds 60 +'@) + $treeRoot = Start-Process -FilePath (Join-Path $PSHOME "pwsh.exe") ` + -ArgumentList @("-NoProfile", "-File", $treeFixturePath) -PassThru + $treeObserved = $false + for ($attempt = 0; $attempt -lt 20 -and -not $treeObserved; $attempt++) { + Start-Sleep -Milliseconds 100 + $treeObserved = @(Get-UiaOwnedProcessDescendants @($treeRoot.Id)).Count -gt 0 + } + if (-not $treeObserved) { throw "RED: UIA owned-process traversal missed a controlled child" } + Stop-UiaOwnedProcessTrees @($treeRoot) + if (-not $treeRoot.HasExited) { + throw "RED: UIA owned-process teardown returned before the controlled root exited" + } + } finally { + if ($treeRoot -and -not $treeRoot.HasExited) { + Stop-UiaOwnedProcessTrees @($treeRoot) + } + Remove-Item -LiteralPath $treeFixturePath -Force -ErrorAction SilentlyContinue + } if ($uiaLiveGateSource -notmatch 'UIA_ROOT_ACCESS' -or $uiaLiveGateSource -notmatch 'UIA_UPDATE_DIALOG_DIAGNOSTICS' -or $uiaLiveGateSource -notmatch 'maxSandboxRootUtf16' -or diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index b3fd4e82..6949bc36 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -486,6 +486,54 @@ public static class GraphCodeUiaGateState { [System.Windows.Automation.AutomationEventArgs].Assembly.Location ) +Add-Type -TypeDefinition @" +using System; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text; +public static class GraphCodeUiaHostInfo { + private const int UOI_NAME = 2; + [DllImport("user32.dll")] + private static extern IntPtr GetProcessWindowStation(); + [DllImport("user32.dll")] + private static extern IntPtr GetThreadDesktop(uint threadId); + [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool GetUserObjectInformation( + IntPtr handle, int index, StringBuilder info, uint length, out uint needed + ); + [DllImport("kernel32.dll")] + private static extern uint GetCurrentThreadId(); + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool ProcessIdToSessionId(uint processId, out uint sessionId); + [DllImport("user32.dll")] + private static extern uint GetGuiResources(IntPtr process, uint flags); + private static string ObjectName(IntPtr handle) { + if (handle == IntPtr.Zero) return null; + var name = new StringBuilder(256); + uint needed; + if (!GetUserObjectInformation(handle, UOI_NAME, name, (uint)(name.Capacity * 2), out needed)) + return null; + return name.ToString(); + } + public static string CurrentWindowStationName() { + return ObjectName(GetProcessWindowStation()); + } + public static string CurrentDesktopName() { + return ObjectName(GetThreadDesktop(GetCurrentThreadId())); + } + public static uint CurrentSessionId() { + uint sessionId; + if (!ProcessIdToSessionId((uint)Process.GetCurrentProcess().Id, out sessionId)) + throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error()); + return sessionId; + } + public static uint GuiResourceCount(int processId, uint flags) { + using (var process = Process.GetProcessById(processId)) + return GetGuiResources(process.Handle, flags); + } +} +"@ + function Require([bool] $condition, [string] $message) { if (-not $condition) { throw $message } } @@ -1205,14 +1253,17 @@ function Assert-UiaSandboxPath([string] $sandbox, [string] $path) { return $candidate } -function Protect-UiaStartupDiagnosticText([string] $value) { +function Protect-UiaStartupDiagnosticText( + [string] $value, + [int] $maxCharacters = 1024 +) { $safe = [regex]::Replace($value, '(?im)^.*\b(?:GH_[A-Z0-9_]*|GITHUB_[A-Z0-9_]*|GIT_CONFIG_[A-Z0-9_]*|gh[pousr]_[A-Za-z0-9_]+|github_pat_[A-Za-z0-9_]+|authorization|password|secret|token|credential|api[_-]?key|bearer)\b.*$', '[redacted sensitive line]') $safe = [regex]::Replace($safe, '(?i)([a-z][a-z0-9+.-]*://)[^/\s]*@', '$1[redacted]@') $safe = [regex]::Replace($safe, '(?:gh[pousr]_[A-Za-z0-9_]+|github_pat_[A-Za-z0-9_]+)', '[redacted]') $safe = [regex]::Replace($safe, '[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]', '?') - return $safe.Substring(0, [Math]::Min($safe.Length, 1024)) + return $safe.Substring(0, [Math]::Min($safe.Length, $maxCharacters)) } function Get-UiaStartupDiagnosticValue([scriptblock] $readValue) { @@ -1228,6 +1279,203 @@ function Get-UiaStartupDiagnosticValue([scriptblock] $readValue) { } } +function Get-UiaStartupFileDiagnostic( + [string] $path, + [string] $relativeTarget, + [int] $maxBytes = 16384 +) { + $result = [ordered]@{ + state = "unavailable" + relativeTarget = $relativeTarget + content = "" + readBytes = 0 + limitBytes = $maxBytes + } + $stream = $null + try { + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { + $result.state = "missing" + return [pscustomobject]$result + } + $stream = [IO.File]::Open( + $path, + [IO.FileMode]::Open, + [IO.FileAccess]::Read, + [IO.FileShare]::ReadWrite -bor [IO.FileShare]::Delete + ) + $length = $stream.Length + $result.lengthBytes = $length + $count = [int][Math]::Min($length, $maxBytes) + $bytes = [byte[]]::new($count) + $stream.Position = $length - $count + $read = 0 + while ($read -lt $count) { + $received = $stream.Read($bytes, $read, $count - $read) + if ($received -le 0) { throw [IO.IOException]::new("Incomplete startup diagnostic read") } + $read += $received + } + $result.readBytes = $read + $result.state = if ($length -gt $maxBytes) { "truncated" } else { "available" } + $content = [Text.Encoding]::UTF8.GetString($bytes) + $result.content = Protect-UiaStartupDiagnosticText $content 8192 + } catch { + $result.state = "unavailable" + $result.errorType = $_.Exception.GetBaseException().GetType().Name + } finally { + if ($null -ne $stream) { $stream.Dispose() } + } + return [pscustomobject]$result +} + +function Get-UiaPrelaunchDiagnostics { + $processInventory = [ordered]@{ state = "available"; processes = @() } + try { + $processes = @(Get-CimInstance Win32_Process -Filter ` + "Name = 'graphcode-windows.exe' OR Name = 'graphcoded.exe'" -ErrorAction Stop) + $processInventory.processes = @($processes | Sort-Object ProcessId | ForEach-Object { + $candidateProcessId = [int]$_.ProcessId + [ordered]@{ + processId = $candidateProcessId + parentProcessId = [int]$_.ParentProcessId + name = $_.Name + executablePath = Protect-UiaStartupDiagnosticText ([string]$_.ExecutablePath) + creationDate = if ($_.CreationDate) { $_.CreationDate.ToUniversalTime().ToString("o") } else { $null } + sessionId = [int]$_.SessionId + userObjects = Get-UiaStartupDiagnosticValue { + [GraphCodeUiaHostInfo]::GuiResourceCount($candidateProcessId, 1) + } + gdiObjects = Get-UiaStartupDiagnosticValue { + [GraphCodeUiaHostInfo]::GuiResourceCount($candidateProcessId, 0) + } + } + }) + } catch { + $processInventory.state = "unavailable" + $processInventory.errorType = $_.Exception.GetBaseException().GetType().Name + } + + $desktopHeap = [ordered]@{ + state = "usage_unavailable" + detail = "Supported user-mode APIs do not expose current per-desktop heap usage; USER/GDI counts are recorded separately." + } + try { + $subsystems = Get-ItemPropertyValue ` + -LiteralPath "HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\SubSystems" ` + -Name Windows -ErrorAction Stop + $sharedSection = [regex]::Match([string]$subsystems, 'SharedSection=(\d+),(\d+),(\d+)') + if ($sharedSection.Success) { + $desktopHeap.sharedSectionConfiguration = $sharedSection.Value + } else { + $desktopHeap.configurationState = "SharedSection_not_found" + } + } catch { + $desktopHeap.configurationState = "unavailable" + $desktopHeap.configurationErrorType = $_.Exception.GetBaseException().GetType().Name + } + + return [ordered]@{ + processInventory = $processInventory + currentProcessId = $PID + sessionId = Get-UiaStartupDiagnosticValue { [GraphCodeUiaHostInfo]::CurrentSessionId() } + windowStation = Get-UiaStartupDiagnosticValue { [GraphCodeUiaHostInfo]::CurrentWindowStationName() } + desktop = Get-UiaStartupDiagnosticValue { [GraphCodeUiaHostInfo]::CurrentDesktopName() } + desktopHeap = $desktopHeap + } +} + +function Get-UiaOwnedProcessDescendants([int[]] $rootProcessIds) { + $all = @(Get-CimInstance Win32_Process -ErrorAction Stop) + $known = [Collections.Generic.HashSet[int]]::new() + foreach ($rootProcessId in $rootProcessIds) { [void]$known.Add($rootProcessId) } + $descendants = [Collections.Generic.List[object]]::new() + $depthById = @{} + foreach ($rootProcessId in $rootProcessIds) { $depthById[$rootProcessId] = 0 } + $changed = $true + while ($changed) { + $changed = $false + foreach ($candidate in $all) { + $processId = [int]$candidate.ProcessId + $parentProcessId = [int]$candidate.ParentProcessId + if ($known.Contains($processId) -or -not $known.Contains($parentProcessId)) { continue } + [void]$known.Add($processId) + $depthById[$processId] = $depthById[$parentProcessId] + 1 + $descendants.Add([pscustomobject]@{ + ProcessId = $processId + ParentProcessId = $parentProcessId + Name = [string]$candidate.Name + ExecutablePath = [string]$candidate.ExecutablePath + CreationDate = $candidate.CreationDate + Depth = $depthById[$processId] + }) + $changed = $true + } + } + return @($descendants | Sort-Object Depth -Descending) +} + +function Stop-UiaOwnedProcessTrees([Diagnostics.Process[]] $rootProcesses) { + $roots = @($rootProcesses | Where-Object { $null -ne $_ }) + if ($roots.Count -eq 0) { return } + $rootIds = @($roots | ForEach-Object { $_.Id }) + $descendants = @(Get-UiaOwnedProcessDescendants $rootIds) + Write-Host ("UIA_OWNED_PROCESS_TREE=" + (@($descendants | ForEach-Object { + [ordered]@{ + processId = $_.ProcessId + parentProcessId = $_.ParentProcessId + name = $_.Name + executablePath = Protect-UiaStartupDiagnosticText $_.ExecutablePath + depth = $_.Depth + } + } | ConvertTo-Json -Compress -Depth 4))) + foreach ($descendant in $descendants) { + $current = Get-CimInstance Win32_Process -Filter ` + "ProcessId = $($descendant.ProcessId)" -ErrorAction Stop + if ($null -eq $current) { continue } + if ([string]$current.Name -cne $descendant.Name -or + [string]$current.CreationDate -cne [string]$descendant.CreationDate) { + continue + } + $child = Get-Process -Id $descendant.ProcessId -ErrorAction SilentlyContinue + if ($child) { + try { + if (-not $child.HasExited) { $child.Kill() } + } catch [InvalidOperationException] { + if (-not $child.HasExited) { throw } + } + if (-not $child.WaitForExit(5000)) { + throw "Owned descendant process $($descendant.ProcessId) did not exit after termination" + } + $child.Dispose() + } + } + foreach ($root in $roots) { + try { + if (-not $root.HasExited) { $root.Kill() } + } catch [InvalidOperationException] { + if (-not $root.HasExited) { throw } + } + if (-not $root.HasExited) { + if (-not $root.WaitForExit(5000)) { + throw "Owned UIA shell process $($root.Id) did not exit after termination" + } + } + } + $remaining = @() + foreach ($descendant in $descendants) { + $current = Get-CimInstance Win32_Process -Filter ` + "ProcessId = $($descendant.ProcessId)" -ErrorAction Stop + if ($null -ne $current -and + [string]$current.Name -ceq $descendant.Name -and + [string]$current.CreationDate -ceq [string]$descendant.CreationDate) { + $remaining += $descendant.ProcessId + } + } + if ($remaining.Count -gt 0) { + throw "Owned UIA descendants remained after teardown: $($remaining -join ',')" + } + Write-Host "UIA_PROCESS_TREE_CLEANUP=verified roots=$($rootIds -join ',') descendants=$($descendants.Count)" +} + function Get-UiaStartupImageHash([string] $path, [scriptblock] $openRead) { $stream = $null $hash = $null @@ -1277,6 +1525,8 @@ function Write-UiaStartupFailureDiagnostic( [int] $exitCode, [string] $executable, [string] $workingDirectory, + [string] $logDirectory, + [string] $supportDirectory, [scriptblock] $openImage = { param($filePath) [IO.File]::Open($filePath, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read) @@ -1290,11 +1540,18 @@ function Write-UiaStartupFailureDiagnostic( executablePath = Protect-UiaStartupDiagnosticText $executable executableSha256 = Get-UiaStartupImageHash $executable $openImage workingDirectory = Protect-UiaStartupDiagnosticText $workingDirectory - stderr = @{ state = "ownership_unavailable"; content = "not_read"; readBytes = 0; relativeTarget = "logs\shell-stderr.log" } - stdout = @{ state = "not_captured" } - applicationLog = @{ state = "ownership_unavailable"; content = "not_read"; readBytes = 0; relativeTarget = "support\graphcode-windows.log" } - } - Write-Host ("UIA_STARTUP_FAILURE=" + ($record | ConvertTo-Json -Depth 5 -Compress)) + stderr = Get-UiaStartupFileDiagnostic (Join-Path $logDirectory "shell-stderr.log") "logs\shell-stderr.log" + stdout = Get-UiaStartupFileDiagnostic (Join-Path $logDirectory "shell-stdout.log") "logs\shell-stdout.log" + applicationLog = Get-UiaStartupFileDiagnostic ` + (Join-Path $supportDirectory "graphcode-windows.log") "support\graphcode-windows.log" + } + $json = $record | ConvertTo-Json -Depth 5 -Compress + [IO.File]::WriteAllText( + (Join-Path $logDirectory "startup-failure.json"), + $json, + [Text.UTF8Encoding]::new($false) + ) + Write-Host ("UIA_STARTUP_FAILURE=" + $json) } function Assert-UiaProviderPathBudget( @@ -1386,7 +1643,12 @@ $daemonCommandLogPath = $null $shellExecuteLogPath = $null $templateDirectory = $null try { - $sandboxPath = [IO.Path]::GetFullPath((Join-Path ([IO.Path]::GetTempPath()) ` + $tempRoot = if ([string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)) { + [IO.Path]::GetTempPath() + } else { + $env:RUNNER_TEMP + } + $sandboxPath = [IO.Path]::GetFullPath((Join-Path $tempRoot ` ("gu-" + [guid]::NewGuid().ToString("N")))) New-Item -ItemType Directory -Path $sandboxPath -ErrorAction Stop | Out-Null $sandboxCreated = $true @@ -1440,12 +1702,21 @@ try { $policyDirectory = Assert-UiaSandboxPath $sandboxPath (Join-Path $fixtureProjectPath ".graphcode") $policyPath = Assert-UiaSandboxPath $sandboxPath (Join-Path $policyDirectory "worktree-policy.json") $shellErrorPath = Assert-UiaSandboxPath $sandboxPath (Join-Path $logDirectory "shell-stderr.log") + $shellOutputPath = Assert-UiaSandboxPath $sandboxPath (Join-Path $logDirectory "shell-stdout.log") + $prelaunchDiagnostics = Get-UiaPrelaunchDiagnostics + $prelaunchJson = $prelaunchDiagnostics | ConvertTo-Json -Depth 8 -Compress + [IO.File]::WriteAllText( + (Join-Path $logDirectory "prelaunch-diagnostics.json"), + $prelaunchJson, + [Text.UTF8Encoding]::new($false) + ) + Write-Host ("UIA_PRELAUNCH_DIAGNOSTICS=" + $prelaunchJson) if ($ArgumentList.Count -gt 0) { $process = Start-Process -FilePath $Shell -ArgumentList $ArgumentList -PassThru -WindowStyle Normal ` - -RedirectStandardError $shellErrorPath + -RedirectStandardOutput $shellOutputPath -RedirectStandardError $shellErrorPath } else { $process = Start-Process -FilePath $Shell -PassThru -WindowStyle Normal ` - -RedirectStandardError $shellErrorPath + -RedirectStandardOutput $shellOutputPath -RedirectStandardError $shellErrorPath } $root = $null @@ -1456,9 +1727,16 @@ try { $startupExitCode = $process.ExitCode try { Write-UiaStartupFailureDiagnostic $process $startupExitCode $Shell ` - (Get-Location).ProviderPath + (Get-Location).ProviderPath $logDirectory $settingsDirectory } catch { - try { Write-Host "UIA_STARTUP_DIAGNOSTIC_ERROR=secondary diagnostic collection or output failed" } catch {} + $diagnosticException = $_.Exception.GetBaseException() + try { + $detail = Protect-UiaStartupDiagnosticText ` + "$($diagnosticException.GetType().Name): $($diagnosticException.Message)" + Write-Host "UIA_STARTUP_DIAGNOSTIC_ERROR=$detail" + } catch { + try { Write-Host "UIA_STARTUP_DIAGNOSTIC_ERROR=diagnostic output failed" } catch {} + } } throw "shell exited with code $startupExitCode" } @@ -4432,28 +4710,7 @@ try { if ($focusEventRegistered) { [System.Windows.Automation.Automation]::RemoveAutomationFocusChangedEventHandler($focusHandler) } - if ($process) { - # The shell spawns zmx.exe subprocesses for its terminal backend, but - # Kill() only terminates the shell itself - Windows does not cascade to - # children. Left uncleaned, every aborted/crashed run (this gate or a - # concurrent one on a shared machine) leaks a zmx.exe that never exits, - # and those orphans accumulate across runs/sessions until UIA calls - # against the *current* shell start failing under the resulting - # foreground/process-token contention. Capture the shell's real children - # before killing it so we can reap them too. - $orphanCandidates = @(Get-CimInstance Win32_Process -Filter "ParentProcessId=$($process.Id) AND Name='zmx.exe'" -ErrorAction SilentlyContinue) - if (-not $process.HasExited) { - $process.Kill() - $process.WaitForExit() - } - foreach ($orphan in $orphanCandidates) { - Stop-Process -Id $orphan.ProcessId -Force -ErrorAction SilentlyContinue - } - } - if ($settingsProcess -and -not $settingsProcess.HasExited) { - $settingsProcess.Kill() - $settingsProcess.WaitForExit() - } + Stop-UiaOwnedProcessTrees @($process, $settingsProcess) } catch { $sandboxCleanupError = $_ if ($sandboxCreated) { Write-Host "UIA_FAILED_SANDBOX_RETAINED=$sandboxPath" } @@ -4502,7 +4759,9 @@ try { } else { $env:LOCALAPPDATA = $oldLocalAppData } if ($sandboxCreated) { Write-Host "UIA_SANDBOX_RETAINED=$sandboxPath" - Write-Host "UIA_SANDBOX_CLEANUP_UNVERIFIED=legacy process teardown does not verify all owned descendants; sandbox and logs retained regardless of assertion outcome" + if (-not $process -and -not $settingsProcess) { + Write-Host "UIA_PROCESS_TREE_CLEANUP=not_needed no UIA shell was launched" + } } } if ($sandboxCleanupError -and $null -eq $gateFailure) { throw $sandboxCleanupError } diff --git a/Tools/windows/validate.ps1 b/Tools/windows/validate.ps1 index d0744aa2..0b307b48 100644 --- a/Tools/windows/validate.ps1 +++ b/Tools/windows/validate.ps1 @@ -744,6 +744,24 @@ function Invoke-Task([string] $name) { } } "windows-shell" { + $uiaProductProcessBaseline = [Collections.Generic.HashSet[string]]::new( + [StringComparer]::OrdinalIgnoreCase + ) + $initialUiaProductProcesses = @(Get-CimInstance Win32_Process -Filter ` + "Name = 'graphcode-windows.exe' OR Name = 'graphcoded.exe'" -ErrorAction Stop) + foreach ($candidate in $initialUiaProductProcesses) { + $identity = "$([int]$candidate.ProcessId)|$($candidate.CreationDate.ToUniversalTime().ToString('o'))" + [void]$uiaProductProcessBaseline.Add($identity) + } + Write-Host ("WINDOWS_SHELL_PREVALIDATION_PRODUCT_PROCESSES=" + + (ConvertTo-Json -InputObject @($initialUiaProductProcesses | ForEach-Object { + [ordered]@{ + processId = [int]$_.ProcessId + name = $_.Name + executablePath = $_.ExecutablePath + sessionId = [int]$_.SessionId + } + }) -Compress -Depth 4)) $zig0152 = Resolve-ZigVersion "0.15.2" "GRAPHCODE_ZIG0152" $swift = Resolve-SwiftExecutable Initialize-SwiftEnvironment $swift @@ -830,6 +848,61 @@ function Invoke-Task([string] $name) { if (-not (Test-Path -LiteralPath $zmxExecutable -PathType Leaf)) { throw "zmx executable was not produced by the pinned shell build; workspace terminal UIA evidence requires it." } + $preUiaProcesses = @(Get-CimInstance Win32_Process -Filter ` + "Name = 'graphcode-windows.exe' OR Name = 'graphcoded.exe'" -ErrorAction Stop) + $preUiaSnapshot = @($preUiaProcesses | ForEach-Object { + $identity = "$([int]$_.ProcessId)|$($_.CreationDate.ToUniversalTime().ToString('o'))" + [ordered]@{ + processId = [int]$_.ProcessId + parentProcessId = [int]$_.ParentProcessId + name = $_.Name + executablePath = $_.ExecutablePath + creationDate = $_.CreationDate.ToUniversalTime().ToString("o") + sessionId = [int]$_.SessionId + existedBeforeValidation = $uiaProductProcessBaseline.Contains($identity) + } + }) + Write-Host ("WINDOWS_SHELL_PRE_UIA_PROCESS_SNAPSHOT=" + + (ConvertTo-Json -InputObject $preUiaSnapshot -Compress -Depth 4)) + $repositoryPrefix = ([IO.Path]::GetFullPath($repoRoot)).TrimEnd('\', '/') + + [IO.Path]::DirectorySeparatorChar + $ownedSurvivors = @($preUiaProcesses | Where-Object { + $identity = "$([int]$_.ProcessId)|$($_.CreationDate.ToUniversalTime().ToString('o'))" + $_.ExecutablePath -and + $_.ExecutablePath.StartsWith($repositoryPrefix, [StringComparison]::OrdinalIgnoreCase) -and + -not $uiaProductProcessBaseline.Contains($identity) + }) + foreach ($survivor in $ownedSurvivors) { + $current = Get-CimInstance Win32_Process -Filter ` + "ProcessId = $([int]$survivor.ProcessId)" -ErrorAction Stop + if ($null -eq $current -or + [string]$current.CreationDate -cne [string]$survivor.CreationDate) { + continue + } + Stop-Process -Id ([int]$survivor.ProcessId) -Force -ErrorAction Stop + } + $remainingSurvivors = @() + foreach ($survivor in $ownedSurvivors) { + for ($attempt = 0; $attempt -lt 30; $attempt++) { + $current = Get-CimInstance Win32_Process -Filter ` + "ProcessId = $([int]$survivor.ProcessId)" -ErrorAction Stop + if ($null -eq $current -or + [string]$current.CreationDate -cne [string]$survivor.CreationDate) { + break + } + Start-Sleep -Milliseconds 100 + } + $current = Get-CimInstance Win32_Process -Filter ` + "ProcessId = $([int]$survivor.ProcessId)" -ErrorAction Stop + if ($null -ne $current -and + [string]$current.CreationDate -ceq [string]$survivor.CreationDate) { + $remainingSurvivors += [int]$survivor.ProcessId + } + } + if ($remainingSurvivors.Count -gt 0) { + throw "Owned GraphCode processes survived pre-UIA cleanup: $($remainingSurvivors -join ',')" + } + Write-Host "WINDOWS_SHELL_PRE_UIA_CLEANUP=verified terminated=$($ownedSurvivors.Count)" Invoke-Native "Native UI Automation live gate" { & (Join-Path $repoRoot "Tools\windows\uia-live-gate.ps1") ` -Shell (Join-Path $repoRoot "graphcode-windows\zig-out\bin\graphcode-windows.exe") `