From a60962dfc5bca979f95b32a12a8e5df22efc88c2 Mon Sep 17 00:00:00 2001 From: Charlie Masters Date: Mon, 27 Jul 2026 13:33:15 +0100 Subject: [PATCH 01/14] Spike Windows ARM64 desktop readiness --- .../actions/setup-windows-desktop/action.yml | 33 ++ .../actions/setup-windows-desktop/prepare.ps1 | 425 ++++++++++++++++++ .../windows-arm64-desktop-readiness-spike.yml | 36 ++ tests/e2e/test_windows_desktop.py | 75 ++++ tests/e2e/windows_desktop.py | 94 ++++ 5 files changed, 663 insertions(+) create mode 100644 .github/actions/setup-windows-desktop/action.yml create mode 100644 .github/actions/setup-windows-desktop/prepare.ps1 create mode 100644 .github/workflows/windows-arm64-desktop-readiness-spike.yml create mode 100644 tests/e2e/test_windows_desktop.py create mode 100644 tests/e2e/windows_desktop.py diff --git a/.github/actions/setup-windows-desktop/action.yml b/.github/actions/setup-windows-desktop/action.yml new file mode 100644 index 0000000..cd2ca5d --- /dev/null +++ b/.github/actions/setup-windows-desktop/action.yml @@ -0,0 +1,33 @@ +name: Setup Windows desktop +description: Normalize and prove the interactive Windows desktop used by Holo live E2E jobs. + +inputs: + expected-architecture: + description: Native runner architecture required by the job. + required: true + probe-apps: + description: Open and close the native app canaries before reporting readiness. + required: false + default: "false" + settle-seconds: + description: Seconds the normalized desktop must remain ready before the final probe. + required: false + default: "2" + +outputs: + artifact-path: + description: Directory containing readiness JSON and diagnostics. + value: ${{ steps.prepare.outputs.artifact-path }} + +runs: + using: composite + steps: + - name: Normalize and probe Windows desktop + id: prepare + shell: pwsh + env: + HOLO_WINDOWS_EXPECTED_ARCHITECTURE: ${{ inputs.expected-architecture }} + HOLO_WINDOWS_PROBE_APPS: ${{ inputs.probe-apps }} + HOLO_WINDOWS_SETTLE_SECONDS: ${{ inputs.settle-seconds }} + run: | + & "$env:GITHUB_ACTION_PATH/prepare.ps1" diff --git a/.github/actions/setup-windows-desktop/prepare.ps1 b/.github/actions/setup-windows-desktop/prepare.ps1 new file mode 100644 index 0000000..e5789b6 --- /dev/null +++ b/.github/actions/setup-windows-desktop/prepare.ps1 @@ -0,0 +1,425 @@ +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +$artifactDirectory = Join-Path $env:RUNNER_TEMP "holo-windows-desktop-readiness" +$readinessPath = Join-Path $artifactDirectory "windows-desktop-readiness.json" +$processesPath = Join-Path $artifactDirectory "processes.json" +$windowsPath = Join-Path $artifactDirectory "windows.json" +$appProbesPath = Join-Path $artifactDirectory "app-probes.json" +$screenshotPath = Join-Path $artifactDirectory "desktop-ready.png" +$initialWindowsPath = Join-Path $artifactDirectory "windows-before.json" +$initialProcessesPath = Join-Path $artifactDirectory "processes-before.json" +$normalizationLogPath = Join-Path $artifactDirectory "normalization.log" + +New-Item -ItemType Directory -Path $artifactDirectory -Force | Out-Null +"artifact-path=$artifactDirectory" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + +Start-Transcript -Path $normalizationLogPath -Force | Out-Null + +Add-Type -TypeDefinition @" +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using System.Text; + +namespace HoloE2E { + public sealed class WindowSnapshot { + public long Handle { get; set; } + public int ProcessId { get; set; } + public string Title { get; set; } = ""; + public string ClassName { get; set; } = ""; + public bool Visible { get; set; } + public bool Hung { get; set; } + } + + public static class NativeWindows { + private delegate bool EnumWindowsProc(IntPtr handle, IntPtr parameter); + + [DllImport("user32.dll")] + private static extern bool EnumWindows(EnumWindowsProc callback, IntPtr parameter); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern int GetWindowText(IntPtr handle, StringBuilder text, int maximumCount); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern int GetClassName(IntPtr handle, StringBuilder text, int maximumCount); + + [DllImport("user32.dll")] + private static extern uint GetWindowThreadProcessId(IntPtr handle, out uint processId); + + [DllImport("user32.dll")] + private static extern bool IsWindowVisible(IntPtr handle); + + [DllImport("user32.dll")] + private static extern bool IsHungAppWindow(IntPtr handle); + + [DllImport("user32.dll")] + private static extern IntPtr GetForegroundWindow(); + + [DllImport("user32.dll")] + private static extern bool SetForegroundWindow(IntPtr handle); + + [DllImport("user32.dll")] + private static extern bool ShowWindowAsync(IntPtr handle, int command); + + public static WindowSnapshot[] EnumerateWindows() { + var windows = new List(); + EnumWindows(delegate(IntPtr handle, IntPtr parameter) { + uint processId; + GetWindowThreadProcessId(handle, out processId); + var title = new StringBuilder(1024); + var className = new StringBuilder(256); + GetWindowText(handle, title, title.Capacity); + GetClassName(handle, className, className.Capacity); + windows.Add(new WindowSnapshot { + Handle = handle.ToInt64(), + ProcessId = checked((int)processId), + Title = title.ToString(), + ClassName = className.ToString(), + Visible = IsWindowVisible(handle), + Hung = IsHungAppWindow(handle) + }); + return true; + }, IntPtr.Zero); + return windows.ToArray(); + } + + public static long ForegroundHandle() { + return GetForegroundWindow().ToInt64(); + } + + public static bool Activate(long handle) { + var window = new IntPtr(handle); + ShowWindowAsync(window, 5); + return SetForegroundWindow(window); + } + } +} +"@ + +function Get-HoloWindowSnapshot { + $foregroundHandle = [HoloE2E.NativeWindows]::ForegroundHandle() + $windows = foreach ($window in [HoloE2E.NativeWindows]::EnumerateWindows()) { + $process = Get-Process -Id $window.ProcessId -ErrorAction SilentlyContinue + [pscustomobject]@{ + handle = "0x{0:X}" -f $window.Handle + handle_value = $window.Handle + process_id = $window.ProcessId + process_name = if ($null -ne $process) { $process.ProcessName } else { $null } + title = $window.Title + class_name = $window.ClassName + visible = $window.Visible + hung = $window.Hung + foreground = $window.Handle -eq $foregroundHandle + } + } + return @($windows) +} + +function Write-HoloJson { + param( + [Parameter(Mandatory = $true)] [object] $Value, + [Parameter(Mandatory = $true)] [string] $Path + ) + $Value | ConvertTo-Json -Depth 6 | Set-Content -Path $Path -Encoding utf8 +} + +function Write-HoloProcessSnapshot { + param([Parameter(Mandatory = $true)] [string] $Path) + $processes = Get-Process | + Sort-Object ProcessName, Id | + Select-Object ProcessName, Id, MainWindowTitle, Responding + Write-HoloJson -Value @($processes) -Path $Path +} + +function Set-HoloDwordPolicy { + param( + [Parameter(Mandatory = $true)] [string] $Path, + [Parameter(Mandatory = $true)] [string] $Name, + [Parameter(Mandatory = $true)] [int] $Value + ) + New-Item -Path $Path -Force | Out-Null + New-ItemProperty -Path $Path -Name $Name -PropertyType DWord -Value $Value -Force | Out-Null + Write-Host "Set policy $Path\$Name=$Value" +} + +function Stop-HoloFirstRunProcesses { + foreach ($processName in @("msedge", "SystemSettings", "wsl", "notepad", "CalculatorApp", "calc")) { + Get-Process -Name $processName -ErrorAction SilentlyContinue | + Stop-Process -Force -ErrorAction SilentlyContinue + } +} + +function Restart-HoloExplorer { + Get-Process -Name explorer -ErrorAction SilentlyContinue | + Stop-Process -Force -ErrorAction SilentlyContinue + Start-Sleep -Seconds 1 + Start-Process -FilePath "$env:WINDIR\explorer.exe" -ArgumentList "shell:desktop" +} + +function Set-HoloExplorerForeground { + $windows = Get-HoloWindowSnapshot + $candidate = $windows | + Where-Object { + $_.visible -and + $_.process_name -eq "explorer" -and + $_.class_name -in @("CabinetWClass", "Shell_TrayWnd", "Progman", "WorkerW") + } | + Sort-Object @{ Expression = { if ($_.class_name -eq "CabinetWClass") { 0 } else { 1 } } } | + Select-Object -First 1 + if ($null -ne $candidate) { + [void][HoloE2E.NativeWindows]::Activate([long]$candidate.handle_value) + } +} + +function Get-HoloDesktopState { + $windows = Get-HoloWindowSnapshot + $visibleWindows = @($windows | Where-Object { $_.visible }) + $foreground = $windows | Where-Object { $_.foreground } | Select-Object -First 1 + $explorerWindows = @( + $visibleWindows | + Where-Object { + $_.process_name -eq "explorer" -and + $_.class_name -in @("CabinetWClass", "Shell_TrayWnd", "Progman", "WorkerW") + } + ) + + $blockers = [System.Collections.Generic.List[string]]::new() + if ($visibleWindows | Where-Object { $_.title -match "(?i)choose privacy settings|privacy settings for your device" }) { + $blockers.Add("privacy_oobe") + } + if ($visibleWindows | Where-Object { $_.process_name -eq "msedge" }) { + $blockers.Add("edge_first_run") + } + if ( + (Get-Process -Name wsl -ErrorAction SilentlyContinue) -or + ($visibleWindows | Where-Object { $_.title -match "(?i)Windows Subsystem for Linux|WSL update|install WSL" }) + ) { + $blockers.Add("wsl_prompt") + } + if ($explorerWindows.Count -eq 0) { + $blockers.Add("explorer_missing") + } elseif ($explorerWindows | Where-Object { $_.hung }) { + $blockers.Add("explorer_unresponsive") + } + if ($null -eq $foreground -or $foreground.process_name -ne "explorer") { + $blockers.Add("unexpected_foreground_app") + } + + [pscustomobject]@{ + blockers = @($blockers | Select-Object -Unique) + windows = $windows + foreground_process = if ($null -ne $foreground) { $foreground.process_name } else { $null } + foreground_title = if ($null -ne $foreground) { $foreground.title } else { $null } + explorer_responsive = $explorerWindows.Count -gt 0 -and -not ($explorerWindows | Where-Object { $_.hung }) + } +} + +function Wait-HoloDesktopReady { + param([int] $TimeoutSeconds = 120) + $deadline = [DateTimeOffset]::UtcNow.AddSeconds($TimeoutSeconds) + do { + Set-HoloExplorerForeground + Start-Sleep -Milliseconds 500 + $state = Get-HoloDesktopState + if ($state.blockers.Count -eq 0) { + return $state + } + Write-Host "Desktop blockers: $($state.blockers -join ', ')" + Start-Sleep -Seconds 2 + } while ([DateTimeOffset]::UtcNow -lt $deadline) + return $state +} + +function Save-HoloScreenshot { + param([Parameter(Mandatory = $true)] [string] $Path) + try { + Add-Type -AssemblyName System.Windows.Forms + Add-Type -AssemblyName System.Drawing + $bounds = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds + $bitmap = [System.Drawing.Bitmap]::new($bounds.Width, $bounds.Height) + try { + $graphics = [System.Drawing.Graphics]::FromImage($bitmap) + try { + $graphics.CopyFromScreen($bounds.Location, [System.Drawing.Point]::Empty, $bounds.Size) + } finally { + $graphics.Dispose() + } + $bitmap.Save($Path, [System.Drawing.Imaging.ImageFormat]::Png) + } finally { + $bitmap.Dispose() + } + return (Test-Path $Path) -and (Get-Item $Path).Length -gt 0 + } catch { + Write-Warning "Screenshot capture failed: $($_.Exception.GetType().Name): $($_.Exception.Message)" + return $false + } +} + +function Invoke-HoloAppProbe { + param( + [Parameter(Mandatory = $true)] [string] $Name, + [Parameter(Mandatory = $true)] [scriptblock] $Start, + [Parameter(Mandatory = $true)] [scriptblock] $WindowMatches + ) + $startedAt = [DateTimeOffset]::UtcNow + & $Start + $deadline = [DateTimeOffset]::UtcNow.AddSeconds(20) + do { + Start-Sleep -Milliseconds 500 + $matchingWindows = @(Get-HoloWindowSnapshot | Where-Object $WindowMatches) + if ($matchingWindows.Count -gt 0) { + return [pscustomobject]@{ + name = $Name + passed = $true + duration_s = [Math]::Round(([DateTimeOffset]::UtcNow - $startedAt).TotalSeconds, 3) + windows = $matchingWindows + } + } + } while ([DateTimeOffset]::UtcNow -lt $deadline) + return [pscustomobject]@{ + name = $Name + passed = $false + duration_s = [Math]::Round(([DateTimeOffset]::UtcNow - $startedAt).TotalSeconds, 3) + windows = @() + } +} + +function Invoke-HoloAppProbes { + $results = @() + $results += Invoke-HoloAppProbe -Name "Notepad" ` + -Start { Start-Process -FilePath "notepad.exe" } ` + -WindowMatches { $_.visible -and ($_.process_name -like "notepad*" -or $_.title -match "(?i)Notepad") } + Get-Process -Name notepad -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue + + $results += Invoke-HoloAppProbe -Name "Calculator" ` + -Start { Start-Process -FilePath "calc.exe" } ` + -WindowMatches { + $_.visible -and + ($_.process_name -in @("CalculatorApp", "ApplicationFrameHost") -or $_.title -match "(?i)Calculator") + } + foreach ($processName in @("CalculatorApp", "calc")) { + Get-Process -Name $processName -ErrorAction SilentlyContinue | + Stop-Process -Force -ErrorAction SilentlyContinue + } + + $results += Invoke-HoloAppProbe -Name "File Explorer" ` + -Start { Start-Process -FilePath "$env:WINDIR\explorer.exe" -ArgumentList "shell:desktop" } ` + -WindowMatches { + $_.visible -and + $_.process_name -eq "explorer" -and + $_.class_name -eq "CabinetWClass" -and + -not $_.hung + } + return @($results) +} + +function Write-HoloReadiness { + param( + [Parameter(Mandatory = $true)] [object] $State, + [Parameter(Mandatory = $true)] [string[]] $Blockers, + [Parameter(Mandatory = $true)] [bool] $ScreenshotAvailable + ) + Write-HoloJson -Value $State.windows -Path $windowsPath + Write-HoloProcessSnapshot -Path $processesPath + $payload = [ordered]@{ + schema_version = 1 + ready = $Blockers.Count -eq 0 + blockers = @($Blockers) + os_version = [System.Environment]::OSVersion.Version.ToString() + architecture = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToUpperInvariant() + foreground_process = $State.foreground_process + foreground_title = $State.foreground_title + explorer_responsive = [bool]$State.explorer_responsive + screenshot_path = if ($ScreenshotAvailable) { Split-Path -Leaf $screenshotPath } else { $null } + processes_path = Split-Path -Leaf $processesPath + windows_path = Split-Path -Leaf $windowsPath + app_probes_path = Split-Path -Leaf $appProbesPath + } + Write-HoloJson -Value $payload -Path $readinessPath + Write-Host ($payload | ConvertTo-Json -Depth 6) +} + +$finalState = $null +$finalBlockers = [System.Collections.Generic.List[string]]::new() +$screenshotAvailable = $false + +try { + Write-HoloJson -Value (Get-HoloWindowSnapshot) -Path $initialWindowsPath + Write-HoloProcessSnapshot -Path $initialProcessesPath + + Set-HoloDwordPolicy ` + -Path "HKLM:\SOFTWARE\Policies\Microsoft\Windows\OOBE" ` + -Name "DisablePrivacyExperience" ` + -Value 1 + Set-HoloDwordPolicy ` + -Path "HKCU:\SOFTWARE\Policies\Microsoft\Windows\OOBE" ` + -Name "DisablePrivacyExperience" ` + -Value 1 + Set-HoloDwordPolicy ` + -Path "HKLM:\SOFTWARE\Policies\Microsoft\Edge" ` + -Name "HideFirstRunExperience" ` + -Value 1 + + Stop-HoloFirstRunProcesses + Restart-HoloExplorer + $initialReadyState = Wait-HoloDesktopReady + + $probeApps = $env:HOLO_WINDOWS_PROBE_APPS -eq "true" + if ($probeApps) { + $appProbeResults = Invoke-HoloAppProbes + } else { + $appProbeResults = @() + } + Write-HoloJson -Value @($appProbeResults) -Path $appProbesPath + + Stop-HoloFirstRunProcesses + Restart-HoloExplorer + $settleSeconds = [int]$env:HOLO_WINDOWS_SETTLE_SECONDS + if ($settleSeconds -gt 0) { + Start-Sleep -Seconds $settleSeconds + } + $finalState = Wait-HoloDesktopReady + foreach ($blocker in $finalState.blockers) { + $finalBlockers.Add($blocker) + } + if ($probeApps -and ($appProbeResults | Where-Object { -not $_.passed })) { + $finalBlockers.Add("app_probe_failed") + } + + $observedArchitecture = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() + if ($observedArchitecture -ne $env:HOLO_WINDOWS_EXPECTED_ARCHITECTURE) { + $finalBlockers.Add("architecture_mismatch") + } + + $screenshotAvailable = Save-HoloScreenshot -Path $screenshotPath + if (-not $screenshotAvailable) { + $finalBlockers.Add("screenshot_unavailable") + } +} catch { + Write-Error -ErrorRecord $_ + $finalBlockers.Add("normalization_failed") + if ($null -eq $finalState) { + $finalState = Get-HoloDesktopState + } + if (-not (Test-Path $appProbesPath)) { + Write-HoloJson -Value @() -Path $appProbesPath + } + $screenshotAvailable = Save-HoloScreenshot -Path $screenshotPath + if (-not $screenshotAvailable) { + $finalBlockers.Add("screenshot_unavailable") + } +} finally { + if ($null -eq $finalState) { + $finalState = Get-HoloDesktopState + } + Write-HoloReadiness ` + -State $finalState ` + -Blockers @($finalBlockers | Select-Object -Unique) ` + -ScreenshotAvailable $screenshotAvailable + Stop-Transcript | Out-Null +} + +if ($finalBlockers.Count -gt 0) { + throw "Windows desktop readiness failed: $(@($finalBlockers | Select-Object -Unique) -join ', ')" +} diff --git a/.github/workflows/windows-arm64-desktop-readiness-spike.yml b/.github/workflows/windows-arm64-desktop-readiness-spike.yml new file mode 100644 index 0000000..69a6428 --- /dev/null +++ b/.github/workflows/windows-arm64-desktop-readiness-spike.yml @@ -0,0 +1,36 @@ +name: Windows ARM64 Desktop Readiness Spike + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + readiness: + name: readiness sample ${{ matrix.sample }} + strategy: + fail-fast: false + matrix: + sample: [1, 2, 3] + runs-on: windows-11-arm + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - name: Normalize and probe Windows ARM64 desktop + id: readiness + uses: ./.github/actions/setup-windows-desktop + with: + expected-architecture: ARM64 + probe-apps: "true" + settle-seconds: "30" + + - name: Upload Windows desktop readiness evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: windows-arm64-desktop-readiness-${{ github.run_id }}-sample-${{ matrix.sample }} + path: ${{ runner.temp }}/holo-windows-desktop-readiness/ + if-no-files-found: error + retention-days: 14 diff --git a/tests/e2e/test_windows_desktop.py b/tests/e2e/test_windows_desktop.py new file mode 100644 index 0000000..131c612 --- /dev/null +++ b/tests/e2e/test_windows_desktop.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from pydantic import ValidationError + +from .windows_desktop import WindowsDesktopBlocker, WindowsDesktopReadiness, load_windows_desktop_readiness + + +def _payload(**overrides: object) -> dict[str, object]: + payload: dict[str, object] = { + "schema_version": 1, + "ready": True, + "blockers": [], + "os_version": "10.0.26200", + "architecture": "ARM64", + "foreground_process": "explorer", + "foreground_title": "Desktop", + "explorer_responsive": True, + "screenshot_path": "desktop-ready.png", + "processes_path": "processes.json", + "windows_path": "windows.json", + "app_probes_path": "app-probes.json", + } + payload.update(overrides) + return payload + + +def test_ready_desktop_contract_has_no_blockers() -> None: + readiness = WindowsDesktopReadiness.model_validate(_payload()) + + assert readiness.ready is True + assert readiness.blockers == () + assert readiness.explorer_responsive is True + + +def test_failed_screenshot_is_an_explicit_blocker() -> None: + readiness = WindowsDesktopReadiness.model_validate( + _payload( + ready=False, + blockers=[WindowsDesktopBlocker.SCREENSHOT_UNAVAILABLE.value], + screenshot_path=None, + ) + ) + + assert readiness.ready is False + assert readiness.blockers == (WindowsDesktopBlocker.SCREENSHOT_UNAVAILABLE,) + + +@pytest.mark.parametrize( + "overrides", + [ + {"ready": True, "blockers": [WindowsDesktopBlocker.WSL_PROMPT.value]}, + {"ready": False, "blockers": []}, + {"ready": False, "blockers": [WindowsDesktopBlocker.WSL_PROMPT.value], "screenshot_path": None}, + { + "ready": False, + "blockers": [WindowsDesktopBlocker.SCREENSHOT_UNAVAILABLE.value], + "screenshot_path": "desktop-ready.png", + }, + ], +) +def test_inconsistent_readiness_evidence_is_rejected(overrides: dict[str, object]) -> None: + with pytest.raises(ValidationError): + WindowsDesktopReadiness.model_validate(_payload(**overrides)) + + +def test_readiness_loader_rejects_wrong_runner_architecture(tmp_path: Path) -> None: + path = tmp_path / "windows-desktop-readiness.json" + path.write_text(json.dumps(_payload(architecture="AMD64")), encoding="utf-8") + + with pytest.raises(ValueError, match="expected 'ARM64', observed 'AMD64'"): + load_windows_desktop_readiness(path, expected_architecture="ARM64") diff --git a/tests/e2e/windows_desktop.py b/tests/e2e/windows_desktop.py new file mode 100644 index 0000000..0bf9c7a --- /dev/null +++ b/tests/e2e/windows_desktop.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +import json +from enum import StrEnum +from pathlib import Path +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, model_validator + + +class WindowsDesktopBlocker(StrEnum): + """A condition that makes a hosted Windows desktop unsafe for a live task.""" + + PRIVACY_OOBE = "privacy_oobe" + EDGE_FIRST_RUN = "edge_first_run" + WSL_PROMPT = "wsl_prompt" + EXPLORER_MISSING = "explorer_missing" + EXPLORER_UNRESPONSIVE = "explorer_unresponsive" + SCREENSHOT_UNAVAILABLE = "screenshot_unavailable" + UNEXPECTED_FOREGROUND_APP = "unexpected_foreground_app" + APP_PROBE_FAILED = "app_probe_failed" + ARCHITECTURE_MISMATCH = "architecture_mismatch" + NORMALIZATION_FAILED = "normalization_failed" + + +class WindowsDesktopReadiness(BaseModel): + """Versioned evidence emitted before a Windows live E2E task starts.""" + + model_config = ConfigDict(extra="forbid") + + schema_version: Literal[1] = Field(description="Readiness artifact schema version.", examples=[1]) + ready: bool = Field(description="Whether the desktop satisfies every readiness invariant.", examples=[True]) + blockers: tuple[WindowsDesktopBlocker, ...] = Field( + description="Exact conditions preventing model execution.", + examples=[[]], + ) + os_version: str = Field(description="Windows version reported by the runner.", examples=["10.0.26200"]) + architecture: str = Field(description="Native process architecture.", examples=["ARM64"]) + foreground_process: str | None = Field( + description="Foreground process name after normalization.", + examples=["explorer"], + ) + foreground_title: str | None = Field( + description="Foreground top-level window title after normalization.", + examples=["Desktop"], + ) + explorer_responsive: bool = Field( + description="Whether a visible Explorer shell window exists and is responsive.", + examples=[True], + ) + screenshot_path: str | None = Field( + description="Readiness screenshot path, or null when screenshot capture itself failed.", + examples=["desktop-ready.png"], + ) + processes_path: str = Field( + description="Path to the bounded process snapshot.", + examples=["processes.json"], + ) + windows_path: str = Field( + description="Path to the top-level window snapshot.", + examples=["windows.json"], + ) + app_probes_path: str = Field( + description="Path to the native application probe results.", + examples=["app-probes.json"], + ) + + @model_validator(mode="after") + def validate_consistency(self) -> WindowsDesktopReadiness: + if self.ready == bool(self.blockers): + raise ValueError("ready must be true exactly when blockers is empty") + if self.screenshot_path is None and WindowsDesktopBlocker.SCREENSHOT_UNAVAILABLE not in self.blockers: + raise ValueError("screenshot_path may be null only for screenshot_unavailable") + if self.screenshot_path is not None and WindowsDesktopBlocker.SCREENSHOT_UNAVAILABLE in self.blockers: + raise ValueError("screenshot_unavailable requires a null screenshot_path") + return self + + def require_architecture(self, expected_architecture: str) -> WindowsDesktopReadiness: + if self.architecture.casefold() != expected_architecture.casefold(): + raise ValueError( + f"Windows desktop architecture mismatch: expected {expected_architecture!r}, " + f"observed {self.architecture!r}" + ) + return self + + +def load_windows_desktop_readiness( + path: Path, + *, + expected_architecture: str, +) -> WindowsDesktopReadiness: + payload = json.loads(path.read_text(encoding="utf-8")) + readiness = WindowsDesktopReadiness.model_validate(payload) + return readiness.require_architecture(expected_architecture) From 66b1db14badf030f06196ef3bf9220d83532fae3 Mon Sep 17 00:00:00 2001 From: Charlie Masters Date: Mon, 27 Jul 2026 13:33:55 +0100 Subject: [PATCH 02/14] Route readiness spike through full E2E workflow --- .github/workflows/holo-full-e2e.yml | 33 +++++++++++++++++ .../windows-arm64-desktop-readiness-spike.yml | 36 ------------------- 2 files changed, 33 insertions(+), 36 deletions(-) delete mode 100644 .github/workflows/windows-arm64-desktop-readiness-spike.yml diff --git a/.github/workflows/holo-full-e2e.yml b/.github/workflows/holo-full-e2e.yml index a0b1933..199af68 100644 --- a/.github/workflows/holo-full-e2e.yml +++ b/.github/workflows/holo-full-e2e.yml @@ -17,6 +17,10 @@ on: description: "Run with fast mode (--fast: one screenshot, no thinking, smaller uploads)" type: boolean default: false + readiness_only: + description: "Temporary three-run Windows ARM64 desktop readiness spike" + type: boolean + default: false pull_request: types: [labeled] @@ -25,8 +29,37 @@ permissions: id-token: write jobs: + windows-arm64-readiness-spike: + name: Windows ARM64 readiness sample ${{ matrix.sample }} + if: github.event_name == 'workflow_dispatch' && github.event.inputs.readiness_only == 'true' + strategy: + fail-fast: false + matrix: + sample: [1, 2, 3] + runs-on: windows-11-arm + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - name: Normalize and probe Windows ARM64 desktop + uses: ./.github/actions/setup-windows-desktop + with: + expected-architecture: ARM64 + probe-apps: "true" + settle-seconds: "30" + + - name: Upload Windows desktop readiness evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: windows-arm64-desktop-readiness-${{ github.run_id }}-sample-${{ matrix.sample }} + path: ${{ runner.temp }}/holo-windows-desktop-readiness/ + if-no-files-found: error + retention-days: 14 + plan: name: plan full-e2e matrix + if: github.event_name != 'workflow_dispatch' || github.event.inputs.readiness_only != 'true' runs-on: ubuntu-latest outputs: matrix: ${{ steps.matrix.outputs.result }} diff --git a/.github/workflows/windows-arm64-desktop-readiness-spike.yml b/.github/workflows/windows-arm64-desktop-readiness-spike.yml deleted file mode 100644 index 69a6428..0000000 --- a/.github/workflows/windows-arm64-desktop-readiness-spike.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: Windows ARM64 Desktop Readiness Spike - -on: - workflow_dispatch: - -permissions: - contents: read - -jobs: - readiness: - name: readiness sample ${{ matrix.sample }} - strategy: - fail-fast: false - matrix: - sample: [1, 2, 3] - runs-on: windows-11-arm - timeout-minutes: 15 - steps: - - uses: actions/checkout@v4 - - - name: Normalize and probe Windows ARM64 desktop - id: readiness - uses: ./.github/actions/setup-windows-desktop - with: - expected-architecture: ARM64 - probe-apps: "true" - settle-seconds: "30" - - - name: Upload Windows desktop readiness evidence - if: always() - uses: actions/upload-artifact@v4 - with: - name: windows-arm64-desktop-readiness-${{ github.run_id }}-sample-${{ matrix.sample }} - path: ${{ runner.temp }}/holo-windows-desktop-readiness/ - if-no-files-found: error - retention-days: 14 From 064069ba93746705c0ecabef0290336ad0d1e9fb Mon Sep 17 00:00:00 2001 From: Charlie Masters Date: Mon, 27 Jul 2026 13:39:19 +0100 Subject: [PATCH 03/14] Close hosted Windows first-run surfaces --- .../actions/setup-windows-desktop/prepare.ps1 | 106 ++++++++++++------ 1 file changed, 70 insertions(+), 36 deletions(-) diff --git a/.github/actions/setup-windows-desktop/prepare.ps1 b/.github/actions/setup-windows-desktop/prepare.ps1 index e5789b6..14030e8 100644 --- a/.github/actions/setup-windows-desktop/prepare.ps1 +++ b/.github/actions/setup-windows-desktop/prepare.ps1 @@ -62,6 +62,9 @@ namespace HoloE2E { [DllImport("user32.dll")] private static extern bool ShowWindowAsync(IntPtr handle, int command); + [DllImport("user32.dll")] + private static extern bool PostMessage(IntPtr handle, uint message, IntPtr wordParameter, IntPtr longParameter); + public static WindowSnapshot[] EnumerateWindows() { var windows = new List(); EnumWindows(delegate(IntPtr handle, IntPtr parameter) { @@ -93,6 +96,14 @@ namespace HoloE2E { ShowWindowAsync(window, 5); return SetForegroundWindow(window); } + + public static bool Hide(long handle) { + return ShowWindowAsync(new IntPtr(handle), 0); + } + + public static bool Close(long handle) { + return PostMessage(new IntPtr(handle), 0x0010, IntPtr.Zero, IntPtr.Zero); + } } } "@ @@ -144,12 +155,24 @@ function Set-HoloDwordPolicy { } function Stop-HoloFirstRunProcesses { - foreach ($processName in @("msedge", "SystemSettings", "wsl", "notepad", "CalculatorApp", "calc")) { + foreach ($processName in @("msedge", "SystemSettings", "notepad", "CalculatorApp", "calc")) { Get-Process -Name $processName -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue } } +function Close-HoloBlockingWindows { + foreach ($window in Get-HoloWindowSnapshot | Where-Object { $_.visible }) { + if ($window.class_name -eq "Shell_OOBEProxy") { + Write-Host "Closing OOBE proxy window $($window.handle)" + [void][HoloE2E.NativeWindows]::Close([long]$window.handle_value) + } elseif ($window.process_name -eq "wsl") { + Write-Host "Hiding WSL provisioning console $($window.handle)" + [void][HoloE2E.NativeWindows]::Hide([long]$window.handle_value) + } + } +} + function Restart-HoloExplorer { Get-Process -Name explorer -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue @@ -185,25 +208,31 @@ function Get-HoloDesktopState { ) $blockers = [System.Collections.Generic.List[string]]::new() - if ($visibleWindows | Where-Object { $_.title -match "(?i)choose privacy settings|privacy settings for your device" }) { - $blockers.Add("privacy_oobe") + if ( + $visibleWindows | + Where-Object { + $_.class_name -eq "Shell_OOBEProxy" -or + $_.title -match "(?i)choose privacy settings|privacy settings for your device" + } + ) { + [void]$blockers.Add("privacy_oobe") } if ($visibleWindows | Where-Object { $_.process_name -eq "msedge" }) { - $blockers.Add("edge_first_run") + [void]$blockers.Add("edge_first_run") } - if ( - (Get-Process -Name wsl -ErrorAction SilentlyContinue) -or - ($visibleWindows | Where-Object { $_.title -match "(?i)Windows Subsystem for Linux|WSL update|install WSL" }) - ) { - $blockers.Add("wsl_prompt") + if ($visibleWindows | Where-Object { + $_.process_name -eq "wsl" -or + $_.title -match "(?i)Windows Subsystem for Linux|WSL update|install WSL" + }) { + [void]$blockers.Add("wsl_prompt") } if ($explorerWindows.Count -eq 0) { - $blockers.Add("explorer_missing") + [void]$blockers.Add("explorer_missing") } elseif ($explorerWindows | Where-Object { $_.hung }) { - $blockers.Add("explorer_unresponsive") + [void]$blockers.Add("explorer_unresponsive") } if ($null -eq $foreground -or $foreground.process_name -ne "explorer") { - $blockers.Add("unexpected_foreground_app") + [void]$blockers.Add("unexpected_foreground_app") } [pscustomobject]@{ @@ -219,6 +248,7 @@ function Wait-HoloDesktopReady { param([int] $TimeoutSeconds = 120) $deadline = [DateTimeOffset]::UtcNow.AddSeconds($TimeoutSeconds) do { + Close-HoloBlockingWindows Set-HoloExplorerForeground Start-Sleep -Milliseconds 500 $state = Get-HoloDesktopState @@ -317,7 +347,7 @@ function Invoke-HoloAppProbes { function Write-HoloReadiness { param( [Parameter(Mandatory = $true)] [object] $State, - [Parameter(Mandatory = $true)] [string[]] $Blockers, + [Parameter(Mandatory = $true)] [AllowEmptyCollection()] [string[]] $Blockers, [Parameter(Mandatory = $true)] [bool] $ScreenshotAvailable ) Write-HoloJson -Value $State.windows -Path $windowsPath @@ -341,7 +371,7 @@ function Write-HoloReadiness { } $finalState = $null -$finalBlockers = [System.Collections.Generic.List[string]]::new() +$finalBlockers = @() $screenshotAvailable = $false try { @@ -365,40 +395,44 @@ try { Restart-HoloExplorer $initialReadyState = Wait-HoloDesktopReady - $probeApps = $env:HOLO_WINDOWS_PROBE_APPS -eq "true" - if ($probeApps) { - $appProbeResults = Invoke-HoloAppProbes + if ($initialReadyState.blockers.Count -gt 0) { + $finalState = $initialReadyState + $finalBlockers += @($initialReadyState.blockers) + Write-HoloJson -Value @() -Path $appProbesPath } else { - $appProbeResults = @() - } - Write-HoloJson -Value @($appProbeResults) -Path $appProbesPath + $probeApps = $env:HOLO_WINDOWS_PROBE_APPS -eq "true" + if ($probeApps) { + $appProbeResults = Invoke-HoloAppProbes + } else { + $appProbeResults = @() + } + Write-HoloJson -Value @($appProbeResults) -Path $appProbesPath - Stop-HoloFirstRunProcesses - Restart-HoloExplorer - $settleSeconds = [int]$env:HOLO_WINDOWS_SETTLE_SECONDS - if ($settleSeconds -gt 0) { - Start-Sleep -Seconds $settleSeconds - } - $finalState = Wait-HoloDesktopReady - foreach ($blocker in $finalState.blockers) { - $finalBlockers.Add($blocker) - } - if ($probeApps -and ($appProbeResults | Where-Object { -not $_.passed })) { - $finalBlockers.Add("app_probe_failed") + Stop-HoloFirstRunProcesses + Restart-HoloExplorer + $settleSeconds = [int]$env:HOLO_WINDOWS_SETTLE_SECONDS + if ($settleSeconds -gt 0) { + Start-Sleep -Seconds $settleSeconds + } + $finalState = Wait-HoloDesktopReady + $finalBlockers += @($finalState.blockers) + if ($probeApps -and ($appProbeResults | Where-Object { -not $_.passed })) { + $finalBlockers += "app_probe_failed" + } } $observedArchitecture = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() if ($observedArchitecture -ne $env:HOLO_WINDOWS_EXPECTED_ARCHITECTURE) { - $finalBlockers.Add("architecture_mismatch") + $finalBlockers += "architecture_mismatch" } $screenshotAvailable = Save-HoloScreenshot -Path $screenshotPath if (-not $screenshotAvailable) { - $finalBlockers.Add("screenshot_unavailable") + $finalBlockers += "screenshot_unavailable" } } catch { Write-Error -ErrorRecord $_ - $finalBlockers.Add("normalization_failed") + $finalBlockers += "normalization_failed" if ($null -eq $finalState) { $finalState = Get-HoloDesktopState } @@ -407,7 +441,7 @@ try { } $screenshotAvailable = Save-HoloScreenshot -Path $screenshotPath if (-not $screenshotAvailable) { - $finalBlockers.Add("screenshot_unavailable") + $finalBlockers += "screenshot_unavailable" } } finally { if ($null -eq $finalState) { From dad2c018fdd81f352609eba704cd82fc455aeeec Mon Sep 17 00:00:00 2001 From: Charlie Masters Date: Mon, 27 Jul 2026 13:41:53 +0100 Subject: [PATCH 04/14] Require Windows OOBE broker to exit --- .../actions/setup-windows-desktop/prepare.ps1 | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/.github/actions/setup-windows-desktop/prepare.ps1 b/.github/actions/setup-windows-desktop/prepare.ps1 index 14030e8..3dd3b07 100644 --- a/.github/actions/setup-windows-desktop/prepare.ps1 +++ b/.github/actions/setup-windows-desktop/prepare.ps1 @@ -155,7 +155,14 @@ function Set-HoloDwordPolicy { } function Stop-HoloFirstRunProcesses { - foreach ($processName in @("msedge", "SystemSettings", "notepad", "CalculatorApp", "calc")) { + foreach ($processName in @( + "msedge", + "SystemSettings", + "UserOOBEBroker", + "notepad", + "CalculatorApp", + "calc" + )) { Get-Process -Name $processName -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue } @@ -206,15 +213,16 @@ function Get-HoloDesktopState { $_.class_name -in @("CabinetWClass", "Shell_TrayWnd", "Progman", "WorkerW") } ) - - $blockers = [System.Collections.Generic.List[string]]::new() - if ( + $oobeWindows = @( $visibleWindows | Where-Object { $_.class_name -eq "Shell_OOBEProxy" -or $_.title -match "(?i)choose privacy settings|privacy settings for your device" } - ) { + ) + + $blockers = [System.Collections.Generic.List[string]]::new() + if ((Get-Process -Name UserOOBEBroker -ErrorAction SilentlyContinue) -or $oobeWindows.Count -gt 0) { [void]$blockers.Add("privacy_oobe") } if ($visibleWindows | Where-Object { $_.process_name -eq "msedge" }) { From 4091ef10c52f2637081630fae0e4e6fb147df174 Mon Sep 17 00:00:00 2001 From: Charlie Masters Date: Mon, 27 Jul 2026 13:44:20 +0100 Subject: [PATCH 05/14] Probe rendered Windows privacy experience --- .../actions/setup-windows-desktop/prepare.ps1 | 32 ++++++++++++++++++- tests/e2e/windows_desktop.py | 1 + 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/.github/actions/setup-windows-desktop/prepare.ps1 b/.github/actions/setup-windows-desktop/prepare.ps1 index 3dd3b07..a2fa5c9 100644 --- a/.github/actions/setup-windows-desktop/prepare.ps1 +++ b/.github/actions/setup-windows-desktop/prepare.ps1 @@ -107,6 +107,7 @@ namespace HoloE2E { } } "@ +Add-Type -AssemblyName UIAutomationClient function Get-HoloWindowSnapshot { $foregroundHandle = [HoloE2E.NativeWindows]::ForegroundHandle() @@ -157,6 +158,7 @@ function Set-HoloDwordPolicy { function Stop-HoloFirstRunProcesses { foreach ($processName in @( "msedge", + "ShellHost", "SystemSettings", "UserOOBEBroker", "notepad", @@ -187,6 +189,26 @@ function Restart-HoloExplorer { Start-Process -FilePath "$env:WINDIR\explorer.exe" -ArgumentList "shell:desktop" } +function Test-HoloPrivacyExperience { + try { + $root = [System.Windows.Automation.AutomationElement]::RootElement + $elements = $root.FindAll( + [System.Windows.Automation.TreeScope]::Descendants, + [System.Windows.Automation.Condition]::TrueCondition + ) + foreach ($element in $elements) { + $name = $element.Current.Name + if ($name -match "(?i)choose privacy settings for your device|privacy settings for your device") { + return $true + } + } + return $false + } catch { + Write-Warning "UI Automation desktop probe failed: $($_.Exception.GetType().Name): $($_.Exception.Message)" + return $null + } +} + function Set-HoloExplorerForeground { $windows = Get-HoloWindowSnapshot $candidate = $windows | @@ -220,11 +242,19 @@ function Get-HoloDesktopState { $_.title -match "(?i)choose privacy settings|privacy settings for your device" } ) + $privacyExperienceVisible = Test-HoloPrivacyExperience $blockers = [System.Collections.Generic.List[string]]::new() - if ((Get-Process -Name UserOOBEBroker -ErrorAction SilentlyContinue) -or $oobeWindows.Count -gt 0) { + if ( + (Get-Process -Name UserOOBEBroker -ErrorAction SilentlyContinue) -or + $oobeWindows.Count -gt 0 -or + $privacyExperienceVisible -eq $true + ) { [void]$blockers.Add("privacy_oobe") } + if ($null -eq $privacyExperienceVisible) { + [void]$blockers.Add("desktop_oracle_unavailable") + } if ($visibleWindows | Where-Object { $_.process_name -eq "msedge" }) { [void]$blockers.Add("edge_first_run") } diff --git a/tests/e2e/windows_desktop.py b/tests/e2e/windows_desktop.py index 0bf9c7a..93ac7df 100644 --- a/tests/e2e/windows_desktop.py +++ b/tests/e2e/windows_desktop.py @@ -21,6 +21,7 @@ class WindowsDesktopBlocker(StrEnum): APP_PROBE_FAILED = "app_probe_failed" ARCHITECTURE_MISMATCH = "architecture_mismatch" NORMALIZATION_FAILED = "normalization_failed" + DESKTOP_ORACLE_UNAVAILABLE = "desktop_oracle_unavailable" class WindowsDesktopReadiness(BaseModel): From 68e9e6cf1b1732a5aa6de1be1b881f40cf5f5dcb Mon Sep 17 00:00:00 2001 From: Charlie Masters Date: Mon, 27 Jul 2026 13:49:19 +0100 Subject: [PATCH 06/14] Remove Windows readiness spike routing --- .github/workflows/holo-full-e2e.yml | 33 ----------------------------- 1 file changed, 33 deletions(-) diff --git a/.github/workflows/holo-full-e2e.yml b/.github/workflows/holo-full-e2e.yml index 199af68..a0b1933 100644 --- a/.github/workflows/holo-full-e2e.yml +++ b/.github/workflows/holo-full-e2e.yml @@ -17,10 +17,6 @@ on: description: "Run with fast mode (--fast: one screenshot, no thinking, smaller uploads)" type: boolean default: false - readiness_only: - description: "Temporary three-run Windows ARM64 desktop readiness spike" - type: boolean - default: false pull_request: types: [labeled] @@ -29,37 +25,8 @@ permissions: id-token: write jobs: - windows-arm64-readiness-spike: - name: Windows ARM64 readiness sample ${{ matrix.sample }} - if: github.event_name == 'workflow_dispatch' && github.event.inputs.readiness_only == 'true' - strategy: - fail-fast: false - matrix: - sample: [1, 2, 3] - runs-on: windows-11-arm - timeout-minutes: 15 - steps: - - uses: actions/checkout@v4 - - - name: Normalize and probe Windows ARM64 desktop - uses: ./.github/actions/setup-windows-desktop - with: - expected-architecture: ARM64 - probe-apps: "true" - settle-seconds: "30" - - - name: Upload Windows desktop readiness evidence - if: always() - uses: actions/upload-artifact@v4 - with: - name: windows-arm64-desktop-readiness-${{ github.run_id }}-sample-${{ matrix.sample }} - path: ${{ runner.temp }}/holo-windows-desktop-readiness/ - if-no-files-found: error - retention-days: 14 - plan: name: plan full-e2e matrix - if: github.event_name != 'workflow_dispatch' || github.event.inputs.readiness_only != 'true' runs-on: ubuntu-latest outputs: matrix: ${{ steps.matrix.outputs.result }} From 0d2a1554066a494dc9caa94dbcf1387ba237f482 Mon Sep 17 00:00:00 2001 From: Charlie Masters Date: Mon, 27 Jul 2026 15:15:00 +0100 Subject: [PATCH 07/14] ci: complete Windows ARM privacy OOBE before e2e --- .../actions/setup-windows-desktop/prepare.ps1 | 55 +++++++++++++++++++ .github/workflows/holo-full-e2e.yml | 16 ++++++ 2 files changed, 71 insertions(+) diff --git a/.github/actions/setup-windows-desktop/prepare.ps1 b/.github/actions/setup-windows-desktop/prepare.ps1 index a2fa5c9..0716ff1 100644 --- a/.github/actions/setup-windows-desktop/prepare.ps1 +++ b/.github/actions/setup-windows-desktop/prepare.ps1 @@ -209,6 +209,60 @@ function Test-HoloPrivacyExperience { } } +function Complete-HoloPrivacyExperience { + $privacyExperienceVisible = Test-HoloPrivacyExperience + if ($privacyExperienceVisible -ne $true) { + Write-Host "Privacy OOBE is not visible; no semantic completion is required." + return $false + } + + Write-Host "Privacy OOBE is visible; looking for its enabled Accept button." + $root = [System.Windows.Automation.AutomationElement]::RootElement + $elements = $root.FindAll( + [System.Windows.Automation.TreeScope]::Descendants, + [System.Windows.Automation.Condition]::TrueCondition + ) + $acceptButton = $null + foreach ($element in $elements) { + if ( + $element.Current.ControlType -eq [System.Windows.Automation.ControlType]::Button -and + $element.Current.IsEnabled -and + $element.Current.Name -match "^(?i:accept)$" + ) { + $acceptButton = $element + break + } + } + if ($null -eq $acceptButton) { + Write-Warning "Privacy OOBE is visible but no enabled Accept button was exposed through UI Automation." + return $false + } + + try { + $invokePattern = $acceptButton.GetCurrentPattern( + [System.Windows.Automation.InvokePattern]::Pattern + ) + $invokePattern.Invoke() + Write-Host "Invoked the privacy OOBE Accept button through UI Automation." + } catch { + Write-Warning "Privacy OOBE Accept invocation failed: $($_.Exception.GetType().Name): $($_.Exception.Message)" + return $false + } + + $deadline = [DateTimeOffset]::UtcNow.AddSeconds(30) + do { + Start-Sleep -Milliseconds 500 + $privacyExperienceVisible = Test-HoloPrivacyExperience + if ($privacyExperienceVisible -eq $false) { + Write-Host "Privacy OOBE disappeared after semantic completion." + return $true + } + } while ([DateTimeOffset]::UtcNow -lt $deadline) + + Write-Warning "Privacy OOBE remained visible after its Accept button was invoked." + return $false +} + function Set-HoloExplorerForeground { $windows = Get-HoloWindowSnapshot $candidate = $windows | @@ -429,6 +483,7 @@ try { -Name "HideFirstRunExperience" ` -Value 1 + [void](Complete-HoloPrivacyExperience) Stop-HoloFirstRunProcesses Restart-HoloExplorer $initialReadyState = Wait-HoloDesktopReady diff --git a/.github/workflows/holo-full-e2e.yml b/.github/workflows/holo-full-e2e.yml index a0b1933..886ec29 100644 --- a/.github/workflows/holo-full-e2e.yml +++ b/.github/workflows/holo-full-e2e.yml @@ -136,6 +136,22 @@ jobs: echo "HOLO_E2E_REPORT_DIR=$RUNNER_TEMP/holo-full-e2e-report-${{ matrix.artifact_platform }}-${{ matrix.shard_index }}" } >> "$GITHUB_ENV" + - name: Prepare Windows ARM64 desktop + if: matrix.artifact_platform == 'windows-arm64' + uses: ./.github/actions/setup-windows-desktop + with: + expected-architecture: ARM64 + probe-apps: "true" + + - name: Upload Windows ARM64 desktop readiness + if: always() && matrix.artifact_platform == 'windows-arm64' + uses: actions/upload-artifact@v4 + with: + name: holo-windows-desktop-readiness-${{ github.run_id }}-shard-${{ matrix.shard_index }} + path: ${{ runner.temp }}/holo-windows-desktop-readiness/ + if-no-files-found: error + retention-days: 14 + - name: Log GitHub OIDC claims uses: actions/github-script@v7 with: From 6364da33f163ed82c16e0f4658f332b4bd3e9cb5 Mon Sep 17 00:00:00 2001 From: Charlie Masters Date: Mon, 27 Jul 2026 15:17:45 +0100 Subject: [PATCH 08/14] ci: walk Windows privacy OOBE to completion --- .../actions/setup-windows-desktop/prepare.ps1 | 127 +++++++++++------- 1 file changed, 80 insertions(+), 47 deletions(-) diff --git a/.github/actions/setup-windows-desktop/prepare.ps1 b/.github/actions/setup-windows-desktop/prepare.ps1 index 0716ff1..c8bbc09 100644 --- a/.github/actions/setup-windows-desktop/prepare.ps1 +++ b/.github/actions/setup-windows-desktop/prepare.ps1 @@ -158,9 +158,7 @@ function Set-HoloDwordPolicy { function Stop-HoloFirstRunProcesses { foreach ($processName in @( "msedge", - "ShellHost", "SystemSettings", - "UserOOBEBroker", "notepad", "CalculatorApp", "calc" @@ -172,10 +170,7 @@ function Stop-HoloFirstRunProcesses { function Close-HoloBlockingWindows { foreach ($window in Get-HoloWindowSnapshot | Where-Object { $_.visible }) { - if ($window.class_name -eq "Shell_OOBEProxy") { - Write-Host "Closing OOBE proxy window $($window.handle)" - [void][HoloE2E.NativeWindows]::Close([long]$window.handle_value) - } elseif ($window.process_name -eq "wsl") { + if ($window.process_name -eq "wsl") { Write-Host "Hiding WSL provisioning console $($window.handle)" [void][HoloE2E.NativeWindows]::Hide([long]$window.handle_value) } @@ -210,59 +205,96 @@ function Test-HoloPrivacyExperience { } function Complete-HoloPrivacyExperience { - $privacyExperienceVisible = Test-HoloPrivacyExperience - if ($privacyExperienceVisible -ne $true) { - Write-Host "Privacy OOBE is not visible; no semantic completion is required." - return $false - } - - Write-Host "Privacy OOBE is visible; looking for its enabled Accept button." - $root = [System.Windows.Automation.AutomationElement]::RootElement - $elements = $root.FindAll( - [System.Windows.Automation.TreeScope]::Descendants, - [System.Windows.Automation.Condition]::TrueCondition + $oobeWindows = @( + Get-HoloWindowSnapshot | + Where-Object { $_.visible -and $_.class_name -eq "Shell_OOBEProxy" } ) - $acceptButton = $null - foreach ($element in $elements) { - if ( - $element.Current.ControlType -eq [System.Windows.Automation.ControlType]::Button -and - $element.Current.IsEnabled -and - $element.Current.Name -match "^(?i:accept)$" - ) { - $acceptButton = $element - break - } - } - if ($null -eq $acceptButton) { - Write-Warning "Privacy OOBE is visible but no enabled Accept button was exposed through UI Automation." - return $false - } - - try { - $invokePattern = $acceptButton.GetCurrentPattern( - [System.Windows.Automation.InvokePattern]::Pattern - ) - $invokePattern.Invoke() - Write-Host "Invoked the privacy OOBE Accept button through UI Automation." - } catch { - Write-Warning "Privacy OOBE Accept invocation failed: $($_.Exception.GetType().Name): $($_.Exception.Message)" + if ($oobeWindows.Count -eq 0 -and (Test-HoloPrivacyExperience) -ne $true) { + Write-Host "Privacy OOBE proxy is absent; no semantic completion is required." return $false } - $deadline = [DateTimeOffset]::UtcNow.AddSeconds(30) + Write-Host "Privacy OOBE proxy is visible; completing its Next/Accept sequence through UI Automation." + $deadline = [DateTimeOffset]::UtcNow.AddSeconds(120) + $invocationCount = 0 do { - Start-Sleep -Milliseconds 500 + $oobeWindows = @( + Get-HoloWindowSnapshot | + Where-Object { $_.visible -and $_.class_name -eq "Shell_OOBEProxy" } + ) $privacyExperienceVisible = Test-HoloPrivacyExperience - if ($privacyExperienceVisible -eq $false) { - Write-Host "Privacy OOBE disappeared after semantic completion." + if ($oobeWindows.Count -eq 0 -and $privacyExperienceVisible -eq $false) { + Write-Host "Privacy OOBE proxy disappeared after semantic completion." return $true } - } while ([DateTimeOffset]::UtcNow -lt $deadline) - Write-Warning "Privacy OOBE remained visible after its Accept button was invoked." + try { + $root = [System.Windows.Automation.AutomationElement]::RootElement + $elements = $root.FindAll( + [System.Windows.Automation.TreeScope]::Descendants, + [System.Windows.Automation.Condition]::TrueCondition + ) + $navigationButton = $null + foreach ($wantedName in @("Accept", "Next")) { + foreach ($element in $elements) { + if ( + $element.Current.ControlType -eq [System.Windows.Automation.ControlType]::Button -and + $element.Current.IsEnabled -and + $element.Current.Name -match "^(?i:$wantedName)$" + ) { + $navigationButton = $element + break + } + } + if ($null -ne $navigationButton) { + break + } + } + if ($null -ne $navigationButton) { + $buttonName = $navigationButton.Current.Name + $invokePattern = $navigationButton.GetCurrentPattern( + [System.Windows.Automation.InvokePattern]::Pattern + ) + $invokePattern.Invoke() + $invocationCount += 1 + Write-Host "Invoked privacy OOBE button '$buttonName' through UI Automation." + Start-Sleep -Seconds 2 + continue + } + } catch { + Write-Warning "Privacy OOBE UI Automation pass failed: $($_.Exception.GetType().Name): $($_.Exception.Message)" + } + + Write-Host "Waiting for the privacy OOBE navigation controls to become available." + Start-Sleep -Seconds 2 + } while ( + [DateTimeOffset]::UtcNow -lt $deadline -and + $invocationCount -lt 10 + ) + + Write-Warning ( + "Privacy OOBE did not complete naturally after {0} semantic navigation invocation(s)." -f + $invocationCount + ) return $false } +function Assert-HoloPrivacyExperienceCompleted { + $oobeWindows = @( + Get-HoloWindowSnapshot | + Where-Object { $_.visible -and $_.class_name -eq "Shell_OOBEProxy" } + ) + $privacyExperienceVisible = Test-HoloPrivacyExperience + if ($oobeWindows.Count -gt 0 -or $privacyExperienceVisible -ne $false) { + throw ( + "Privacy OOBE completion could not be proven: proxy_windows={0}, semantic_visible={1}" -f + $oobeWindows.Count, + $privacyExperienceVisible + ) + } + Write-Host "Privacy OOBE natural completion is proven." +} + function Set-HoloExplorerForeground { $windows = Get-HoloWindowSnapshot $candidate = $windows | @@ -484,6 +516,7 @@ try { -Value 1 [void](Complete-HoloPrivacyExperience) + Assert-HoloPrivacyExperienceCompleted Stop-HoloFirstRunProcesses Restart-HoloExplorer $initialReadyState = Wait-HoloDesktopReady From bc2adedb820c229bdadfc1ed0f9d7c61466c45a5 Mon Sep 17 00:00:00 2001 From: Charlie Masters Date: Mon, 27 Jul 2026 15:23:35 +0100 Subject: [PATCH 09/14] ci: click through hosted Windows privacy OOBE --- .../actions/setup-windows-desktop/prepare.ps1 | 84 ++++++++++--------- 1 file changed, 44 insertions(+), 40 deletions(-) diff --git a/.github/actions/setup-windows-desktop/prepare.ps1 b/.github/actions/setup-windows-desktop/prepare.ps1 index c8bbc09..82b7609 100644 --- a/.github/actions/setup-windows-desktop/prepare.ps1 +++ b/.github/actions/setup-windows-desktop/prepare.ps1 @@ -65,6 +65,12 @@ namespace HoloE2E { [DllImport("user32.dll")] private static extern bool PostMessage(IntPtr handle, uint message, IntPtr wordParameter, IntPtr longParameter); + [DllImport("user32.dll")] + private static extern bool SetCursorPos(int x, int y); + + [DllImport("user32.dll")] + private static extern void mouse_event(uint flags, uint x, uint y, uint data, UIntPtr extraInfo); + public static WindowSnapshot[] EnumerateWindows() { var windows = new List(); EnumWindows(delegate(IntPtr handle, IntPtr parameter) { @@ -104,6 +110,15 @@ namespace HoloE2E { public static bool Close(long handle) { return PostMessage(new IntPtr(handle), 0x0010, IntPtr.Zero, IntPtr.Zero); } + + public static bool Click(int x, int y) { + if (!SetCursorPos(x, y)) { + return false; + } + mouse_event(0x0002, 0, 0, 0, UIntPtr.Zero); + mouse_event(0x0004, 0, 0, 0, UIntPtr.Zero); + return true; + } } } "@ @@ -214,7 +229,22 @@ function Complete-HoloPrivacyExperience { return $false } - Write-Host "Privacy OOBE proxy is visible; completing its Next/Accept sequence through UI Automation." + Add-Type -AssemblyName System.Windows.Forms + $screenBounds = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds + $navigationX = $screenBounds.Left + [Math]::Round($screenBounds.Width * 0.847) + $navigationY = $screenBounds.Top + [Math]::Round($screenBounds.Height * 0.854) + + $navigationMessage = ( + "Privacy OOBE proxy is visible; navigating its fixed bottom-right action at " + + "({0}, {1}) within screen bounds {2}x{3}." + ) -f + $navigationX, + $navigationY, + $screenBounds.Width, + $screenBounds.Height + Write-Host $navigationMessage + [void](Save-HoloScreenshot -Path (Join-Path $artifactDirectory "privacy-oobe-before.png")) + $deadline = [DateTimeOffset]::UtcNow.AddSeconds(120) $invocationCount = 0 do { @@ -228,52 +258,26 @@ function Complete-HoloPrivacyExperience { return $true } - try { - $root = [System.Windows.Automation.AutomationElement]::RootElement - $elements = $root.FindAll( - [System.Windows.Automation.TreeScope]::Descendants, - [System.Windows.Automation.Condition]::TrueCondition - ) - $navigationButton = $null - foreach ($wantedName in @("Accept", "Next")) { - foreach ($element in $elements) { - if ( - $element.Current.ControlType -eq [System.Windows.Automation.ControlType]::Button -and - $element.Current.IsEnabled -and - $element.Current.Name -match "^(?i:$wantedName)$" - ) { - $navigationButton = $element - break - } - } - if ($null -ne $navigationButton) { - break - } - } - if ($null -ne $navigationButton) { - $buttonName = $navigationButton.Current.Name - $invokePattern = $navigationButton.GetCurrentPattern( - [System.Windows.Automation.InvokePattern]::Pattern - ) - $invokePattern.Invoke() - $invocationCount += 1 - Write-Host "Invoked privacy OOBE button '$buttonName' through UI Automation." - Start-Sleep -Seconds 2 - continue - } - } catch { - Write-Warning "Privacy OOBE UI Automation pass failed: $($_.Exception.GetType().Name): $($_.Exception.Message)" + $oobeWindow = $oobeWindows | Select-Object -First 1 + [void][HoloE2E.NativeWindows]::Activate([long]$oobeWindow.handle_value) + Start-Sleep -Milliseconds 250 + if (-not [HoloE2E.NativeWindows]::Click($navigationX, $navigationY)) { + throw "Failed to inject the privacy OOBE navigation click at ($navigationX, $navigationY)." } - Write-Host "Waiting for the privacy OOBE navigation controls to become available." - Start-Sleep -Seconds 2 + $invocationCount += 1 + Write-Host "Injected privacy OOBE navigation click $invocationCount." + Start-Sleep -Seconds 3 + [void](Save-HoloScreenshot -Path ( + Join-Path $artifactDirectory "privacy-oobe-after-click-$invocationCount.png" + )) } while ( [DateTimeOffset]::UtcNow -lt $deadline -and - $invocationCount -lt 10 + $invocationCount -lt 6 ) Write-Warning ( - "Privacy OOBE did not complete naturally after {0} semantic navigation invocation(s)." -f + "Privacy OOBE did not complete naturally after {0} guarded navigation click(s)." -f $invocationCount ) return $false From 2a0857e6bb60a91cce7470779134d30176b7cd6a Mon Sep 17 00:00:00 2001 From: Charlie Masters Date: Mon, 27 Jul 2026 15:28:10 +0100 Subject: [PATCH 10/14] ci: gate Windows privacy state on visible UI --- .github/actions/setup-windows-desktop/prepare.ps1 | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/.github/actions/setup-windows-desktop/prepare.ps1 b/.github/actions/setup-windows-desktop/prepare.ps1 index 82b7609..598a183 100644 --- a/.github/actions/setup-windows-desktop/prepare.ps1 +++ b/.github/actions/setup-windows-desktop/prepare.ps1 @@ -174,6 +174,7 @@ function Stop-HoloFirstRunProcesses { foreach ($processName in @( "msedge", "SystemSettings", + "wsl", "notepad", "CalculatorApp", "calc" @@ -186,8 +187,10 @@ function Stop-HoloFirstRunProcesses { function Close-HoloBlockingWindows { foreach ($window in Get-HoloWindowSnapshot | Where-Object { $_.visible }) { if ($window.process_name -eq "wsl") { - Write-Host "Hiding WSL provisioning console $($window.handle)" - [void][HoloE2E.NativeWindows]::Hide([long]$window.handle_value) + Write-Host "Closing WSL provisioning console $($window.handle)" + [void][HoloE2E.NativeWindows]::Close([long]$window.handle_value) + Get-Process -Id $window.process_id -ErrorAction SilentlyContinue | + Stop-Process -Force -ErrorAction SilentlyContinue } } } @@ -335,11 +338,7 @@ function Get-HoloDesktopState { $privacyExperienceVisible = Test-HoloPrivacyExperience $blockers = [System.Collections.Generic.List[string]]::new() - if ( - (Get-Process -Name UserOOBEBroker -ErrorAction SilentlyContinue) -or - $oobeWindows.Count -gt 0 -or - $privacyExperienceVisible -eq $true - ) { + if ($oobeWindows.Count -gt 0 -or $privacyExperienceVisible -eq $true) { [void]$blockers.Add("privacy_oobe") } if ($null -eq $privacyExperienceVisible) { From 0c2cbd2eb40251e56e2fd110bffdf081f4ec48ba Mon Sep 17 00:00:00 2001 From: Charlie Masters Date: Mon, 27 Jul 2026 15:32:29 +0100 Subject: [PATCH 11/14] ci: allow an idle Windows desktop without foreground --- .github/actions/setup-windows-desktop/prepare.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/setup-windows-desktop/prepare.ps1 b/.github/actions/setup-windows-desktop/prepare.ps1 index 598a183..3e8c5d5 100644 --- a/.github/actions/setup-windows-desktop/prepare.ps1 +++ b/.github/actions/setup-windows-desktop/prepare.ps1 @@ -358,7 +358,7 @@ function Get-HoloDesktopState { } elseif ($explorerWindows | Where-Object { $_.hung }) { [void]$blockers.Add("explorer_unresponsive") } - if ($null -eq $foreground -or $foreground.process_name -ne "explorer") { + if ($null -ne $foreground -and $foreground.process_name -ne "explorer") { [void]$blockers.Add("unexpected_foreground_app") } From 8d8cde75894cc226fe2fb6815ca79f6095ff7b84 Mon Sep 17 00:00:00 2001 From: Charlie Masters Date: Mon, 27 Jul 2026 16:29:40 +0100 Subject: [PATCH 12/14] ci: prepare Windows desktop immediately before e2e --- .github/workflows/holo-full-e2e.yml | 35 +++++++++++++++------------ tests/test_publish_workflow_static.py | 9 +++++++ 2 files changed, 28 insertions(+), 16 deletions(-) diff --git a/.github/workflows/holo-full-e2e.yml b/.github/workflows/holo-full-e2e.yml index 886ec29..39aaead 100644 --- a/.github/workflows/holo-full-e2e.yml +++ b/.github/workflows/holo-full-e2e.yml @@ -136,22 +136,6 @@ jobs: echo "HOLO_E2E_REPORT_DIR=$RUNNER_TEMP/holo-full-e2e-report-${{ matrix.artifact_platform }}-${{ matrix.shard_index }}" } >> "$GITHUB_ENV" - - name: Prepare Windows ARM64 desktop - if: matrix.artifact_platform == 'windows-arm64' - uses: ./.github/actions/setup-windows-desktop - with: - expected-architecture: ARM64 - probe-apps: "true" - - - name: Upload Windows ARM64 desktop readiness - if: always() && matrix.artifact_platform == 'windows-arm64' - uses: actions/upload-artifact@v4 - with: - name: holo-windows-desktop-readiness-${{ github.run_id }}-shard-${{ matrix.shard_index }} - path: ${{ runner.temp }}/holo-windows-desktop-readiness/ - if-no-files-found: error - retention-days: 14 - - name: Log GitHub OIDC claims uses: actions/github-script@v7 with: @@ -358,6 +342,25 @@ jobs: curl -fsSL "https://raw.githubusercontent.com/${GITHUB_REPOSITORY}/${GITHUB_SHA}/install/install.sh" | bash "$HOLO_HOME/bin/holo" --help + # Run this after all dependency/bootstrap work so late startup surfaces, + # including the Windows 11 ARM WSL prompt, cannot contaminate the first + # model observation. + - name: Prepare Windows ARM64 desktop + if: matrix.artifact_platform == 'windows-arm64' + uses: ./.github/actions/setup-windows-desktop + with: + expected-architecture: ARM64 + probe-apps: "true" + + - name: Upload Windows ARM64 desktop readiness + if: always() && matrix.artifact_platform == 'windows-arm64' + uses: actions/upload-artifact@v4 + with: + name: holo-windows-desktop-readiness-${{ github.run_id }}-shard-${{ matrix.shard_index }} + path: ${{ runner.temp }}/holo-windows-desktop-readiness/ + if-no-files-found: error + retention-days: 14 + - name: List full e2e shard task ids shell: bash run: | diff --git a/tests/test_publish_workflow_static.py b/tests/test_publish_workflow_static.py index f975aa5..de75667 100644 --- a/tests/test_publish_workflow_static.py +++ b/tests/test_publish_workflow_static.py @@ -126,6 +126,15 @@ def test_windows_arm64_installer_and_full_e2e_scaffolding() -> None: assert "OPENSSL_DIR=" in openssl["run"] assert "OPENSSL_STATIC=1" in openssl["run"] + step_names = [step.get("name") for step in full_e2e["jobs"]["full-e2e"]["steps"]] + assert step_names.index("Install dependencies") < step_names.index("Prepare Windows ARM64 desktop") + assert step_names.index("Prepare Windows ARM64 desktop") < step_names.index( + "List full e2e shard task ids" + ) + assert step_names.index("Upload Windows ARM64 desktop readiness") < step_names.index( + "Run full live e2e suite" + ) + def test_windows_arm64_candidate_is_verified_and_consumed_before_merge() -> None: action = yaml.safe_load( From 2d30d4ec57cf2d9ebe3b73bd624f4b22b12ed03c Mon Sep 17 00:00:00 2001 From: Charlie Masters Date: Mon, 27 Jul 2026 17:01:07 +0100 Subject: [PATCH 13/14] style: format Windows readiness workflow test --- tests/test_publish_workflow_static.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/tests/test_publish_workflow_static.py b/tests/test_publish_workflow_static.py index de75667..d304ce2 100644 --- a/tests/test_publish_workflow_static.py +++ b/tests/test_publish_workflow_static.py @@ -128,12 +128,8 @@ def test_windows_arm64_installer_and_full_e2e_scaffolding() -> None: step_names = [step.get("name") for step in full_e2e["jobs"]["full-e2e"]["steps"]] assert step_names.index("Install dependencies") < step_names.index("Prepare Windows ARM64 desktop") - assert step_names.index("Prepare Windows ARM64 desktop") < step_names.index( - "List full e2e shard task ids" - ) - assert step_names.index("Upload Windows ARM64 desktop readiness") < step_names.index( - "Run full live e2e suite" - ) + assert step_names.index("Prepare Windows ARM64 desktop") < step_names.index("List full e2e shard task ids") + assert step_names.index("Upload Windows ARM64 desktop readiness") < step_names.index("Run full live e2e suite") def test_windows_arm64_candidate_is_verified_and_consumed_before_merge() -> None: From b7fa139b9706104130a018f3b3647aafbb2912ef Mon Sep 17 00:00:00 2001 From: Charlie Masters Date: Mon, 27 Jul 2026 17:11:33 +0100 Subject: [PATCH 14/14] ci: isolate Windows ARM64 installer smoke --- .github/workflows/ci.yml | 10 ++++++++-- tests/test_publish_workflow_static.py | 5 +++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a09a090..efa7089 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,6 +62,7 @@ jobs: run: | $ErrorActionPreference = "Stop" $runtimeSha = ((Get-Content shas/windows-arm64.txt).Trim() -split "=", 2)[1] + $installerPath = (Resolve-Path .\install\install.ps1).Path $hasCandidate = [bool]$env:HAI_RUNTIME_CANDIDATE_ARCHIVE if ($runtimeSha -match "^0{64}$" -and -not $hasCandidate) { $env:HOLO_INSTALL_SKIP_RUN_SETUP = "1" @@ -94,8 +95,13 @@ jobs: $env:HAI_AGENT_RUNTIME_DOWNLOAD_SHA256 = $env:HAI_RUNTIME_CANDIDATE_SHA256 } - & .\install\install.ps1 - & "$env:HOLO_HOME\bin\holo.exe" --help + Push-Location $env:RUNNER_TEMP + try { + & $installerPath + & "$env:HOLO_HOME\bin\holo.exe" --help + } finally { + Pop-Location + } } finally { if ($null -ne $server -and -not $server.HasExited) { Stop-Process -Id $server.Id -Force diff --git a/tests/test_publish_workflow_static.py b/tests/test_publish_workflow_static.py index d304ce2..ff691e9 100644 --- a/tests/test_publish_workflow_static.py +++ b/tests/test_publish_workflow_static.py @@ -103,6 +103,11 @@ def test_windows_arm64_installer_and_full_e2e_scaffolding() -> None: assert "UV_NO_BUILD" in rendered_installer assert "steps.dependency.outputs.manifest_path" in rendered_installer assert "steps.client.outputs.wheel_path" in rendered_installer + install_step = next( + step for step in installer["steps"] if step.get("name") == "Install from the pull request checkout" + ) + assert "Resolve-Path .\\install\\install.ps1" in install_step["run"] + assert "Push-Location $env:RUNNER_TEMP" in install_step["run"] assert "windows-11-arm" in ci["jobs"]["python"]["strategy"]["matrix"]["os"] full_e2e_path = ROOT / ".github/workflows/holo-full-e2e.yml"