diff --git a/.github/actions/setup-hai-runtime-candidate/action.yml b/.github/actions/setup-hai-runtime-candidate/action.yml new file mode 100644 index 0000000..f5c2d96 --- /dev/null +++ b/.github/actions/setup-hai-runtime-candidate/action.yml @@ -0,0 +1,109 @@ +name: Set up HAI runtime candidate +description: Verify and expose a Windows ARM64 runtime artifact from a successful HAI Actions run. + +inputs: + github-token: + description: Read-only token with Actions access to hcompai/hai. + required: true + run-id: + description: Successful hcompai/hai workflow run containing the candidate artifacts. + required: true + expected-head-sha: + description: Exact HAI commit the candidate run must have built. + required: true + +outputs: + archive_path: + description: Verified Windows ARM64 runtime zip. + value: ${{ steps.verify.outputs.archive_path }} + binary_path: + description: Extracted hai-agent-runtime.exe. + value: ${{ steps.verify.outputs.binary_path }} + runtime_directory: + description: Extracted runtime directory added to PATH. + value: ${{ steps.verify.outputs.runtime_directory }} + sha256: + description: Verified runtime zip SHA-256. + value: ${{ steps.verify.outputs.sha256 }} + +runs: + using: composite + steps: + - name: Verify candidate run provenance + shell: pwsh + env: + GH_TOKEN: ${{ inputs.github-token }} + HAI_RUN_ID: ${{ inputs.run-id }} + EXPECTED_HEAD_SHA: ${{ inputs.expected-head-sha }} + run: | + $ErrorActionPreference = "Stop" + $headers = @{ + Accept = "application/vnd.github+json" + Authorization = "Bearer $env:GH_TOKEN" + "X-GitHub-Api-Version" = "2022-11-28" + } + $run = Invoke-RestMethod ` + -Headers $headers ` + -Uri "https://api.github.com/repos/hcompai/hai/actions/runs/$env:HAI_RUN_ID" + if ($run.status -ne "completed" -or $run.conclusion -ne "success") { + throw "HAI candidate run $env:HAI_RUN_ID is not successful: status=$($run.status), conclusion=$($run.conclusion)" + } + if ($run.head_sha -ne $env:EXPECTED_HEAD_SHA) { + throw "HAI candidate run $env:HAI_RUN_ID built $($run.head_sha), expected $env:EXPECTED_HEAD_SHA" + } + Write-Host "Verified successful HAI run $env:HAI_RUN_ID at $env:EXPECTED_HEAD_SHA" + + - name: Download Windows ARM64 runtime zip + uses: actions/download-artifact@v4 + with: + name: runtime-zip-windows-arm64 + path: ${{ runner.temp }}/hai-runtime-candidate/archive + github-token: ${{ inputs.github-token }} + repository: hcompai/hai + run-id: ${{ inputs.run-id }} + + - name: Download Windows ARM64 runtime SHA + uses: actions/download-artifact@v4 + with: + name: runtime-sha-windows-arm64 + path: ${{ runner.temp }}/hai-runtime-candidate/sha + github-token: ${{ inputs.github-token }} + repository: hcompai/hai + run-id: ${{ inputs.run-id }} + + - id: verify + name: Verify and extract Windows ARM64 runtime + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + $root = Join-Path $env:RUNNER_TEMP "hai-runtime-candidate" + $archives = @(Get-ChildItem (Join-Path $root "archive") -Filter "hai-agent-runtime-windows-arm64.zip") + $shaFiles = @(Get-ChildItem (Join-Path $root "sha") -Filter "windows-arm64.txt") + if ($archives.Count -ne 1 -or $shaFiles.Count -ne 1) { + throw "expected exactly one Windows ARM64 runtime zip and SHA file" + } + + $entry = (Get-Content -Raw $shaFiles[0].FullName).Trim() + $platform, $expectedSha = $entry -split "=", 2 + if ($platform -ne "windows-arm64" -or $expectedSha -notmatch "^[0-9a-f]{64}$") { + throw "invalid Windows ARM64 runtime SHA entry: $entry" + } + $actualSha = (Get-FileHash -Algorithm SHA256 $archives[0].FullName).Hash.ToLowerInvariant() + if ($actualSha -ne $expectedSha) { + throw "Windows ARM64 runtime artifact SHA mismatch: expected $expectedSha, got $actualSha" + } + + $runtimeDirectory = Join-Path $root "runtime" + Expand-Archive -Path $archives[0].FullName -DestinationPath $runtimeDirectory + $binaries = @(Get-ChildItem $runtimeDirectory -Filter "hai-agent-runtime.exe") + if ($binaries.Count -ne 1) { + throw "expected exactly one hai-agent-runtime.exe at the candidate archive root" + } + + Add-Content -Path $env:GITHUB_PATH -Value $runtimeDirectory + @( + "archive_path=$($archives[0].FullName)" + "binary_path=$($binaries[0].FullName)" + "runtime_directory=$runtimeDirectory" + "sha256=$actualSha" + ) >> $env:GITHUB_OUTPUT 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..3e8c5d5 --- /dev/null +++ b/.github/actions/setup-windows-desktop/prepare.ps1 @@ -0,0 +1,588 @@ +$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); + + [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) { + 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); + } + + 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); + } + + 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; + } + } +} +"@ +Add-Type -AssemblyName UIAutomationClient + +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 Close-HoloBlockingWindows { + foreach ($window in Get-HoloWindowSnapshot | Where-Object { $_.visible }) { + if ($window.process_name -eq "wsl") { + 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 + } + } +} + +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 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 Complete-HoloPrivacyExperience { + $oobeWindows = @( + Get-HoloWindowSnapshot | + Where-Object { $_.visible -and $_.class_name -eq "Shell_OOBEProxy" } + ) + if ($oobeWindows.Count -eq 0 -and (Test-HoloPrivacyExperience) -ne $true) { + Write-Host "Privacy OOBE proxy is absent; no semantic completion is required." + return $false + } + + 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 { + $oobeWindows = @( + Get-HoloWindowSnapshot | + Where-Object { $_.visible -and $_.class_name -eq "Shell_OOBEProxy" } + ) + $privacyExperienceVisible = Test-HoloPrivacyExperience + if ($oobeWindows.Count -eq 0 -and $privacyExperienceVisible -eq $false) { + Write-Host "Privacy OOBE proxy disappeared after semantic completion." + return $true + } + + $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)." + } + + $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 6 + ) + + Write-Warning ( + "Privacy OOBE did not complete naturally after {0} guarded navigation click(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 | + 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") + } + ) + $oobeWindows = @( + $visibleWindows | + Where-Object { + $_.class_name -eq "Shell_OOBEProxy" -or + $_.title -match "(?i)choose privacy settings|privacy settings for your device" + } + ) + $privacyExperienceVisible = Test-HoloPrivacyExperience + + $blockers = [System.Collections.Generic.List[string]]::new() + if ($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") + } + 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) { + [void]$blockers.Add("explorer_missing") + } elseif ($explorerWindows | Where-Object { $_.hung }) { + [void]$blockers.Add("explorer_unresponsive") + } + if ($null -ne $foreground -and $foreground.process_name -ne "explorer") { + [void]$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 { + Close-HoloBlockingWindows + 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)] [AllowEmptyCollection()] [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 = @() +$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 + + [void](Complete-HoloPrivacyExperience) + Assert-HoloPrivacyExperienceCompleted + Stop-HoloFirstRunProcesses + Restart-HoloExplorer + $initialReadyState = Wait-HoloDesktopReady + + if ($initialReadyState.blockers.Count -gt 0) { + $finalState = $initialReadyState + $finalBlockers += @($initialReadyState.blockers) + Write-HoloJson -Value @() -Path $appProbesPath + } else { + $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 + $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 += "architecture_mismatch" + } + + $screenshotAvailable = Save-HoloScreenshot -Path $screenshotPath + if (-not $screenshotAvailable) { + $finalBlockers += "screenshot_unavailable" + } +} catch { + Write-Error -ErrorRecord $_ + $finalBlockers += "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 += "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/ci.yml b/.github/workflows/ci.yml index d8355d4..efa7089 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,120 @@ permissions: contents: read jobs: + windows-arm64-installer: + name: fresh Windows ARM64 installer + runs-on: windows-11-arm + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v3 + with: + version: "0.9.18" + enable-cache: true + - id: runtime_candidate + name: Set up the pre-merge HAI runtime candidate + if: vars.HAI_WINDOWS_ARM64_CANDIDATE_RUN_ID != '' + uses: ./.github/actions/setup-hai-runtime-candidate + with: + github-token: ${{ secrets.HAI_ACTIONS_READ_TOKEN }} + run-id: ${{ vars.HAI_WINDOWS_ARM64_CANDIDATE_RUN_ID }} + expected-head-sha: ${{ vars.HAI_WINDOWS_ARM64_CANDIDATE_HEAD_SHA }} + - id: dependency + name: Build the distributable Windows ARM64 dependency + shell: pwsh + run: | + $root = "$env:RUNNER_TEMP\windows-arm64-installer-candidate" + .\scripts\build_windows_arm64_dependency_wheel.ps1 ` + -WheelDirectory "$root\dependencies" ` + -ManifestOutput "$root\install\manifest.json" ` + -WheelBaseUrl "$root\dependencies" + - id: client + name: Build the Holo wheel consumed by a customer install + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + $output = "$env:RUNNER_TEMP\windows-arm64-installer-candidate\holo" + uv build --wheel --out-dir $output + if ($LASTEXITCODE -ne 0) { + throw "failed to build the Holo wheel" + } + $wheels = @(Get-ChildItem -Path $output -Filter "holo_desktop_cli-*.whl") + if ($wheels.Count -ne 1) { + throw "expected exactly one Holo wheel, found $($wheels.Count)" + } + "wheel_path=$($wheels[0].FullName)" >> $env:GITHUB_OUTPUT + - name: Install from the pull request checkout + shell: pwsh + env: + HOLO_HOME: ${{ runner.temp }}\holo-installer-smoke + HOLO_INSTALL_MANIFEST_URL: ${{ steps.dependency.outputs.manifest_path }} + HOLO_INSTALL_PACKAGE: ${{ steps.client.outputs.wheel_path }} + UV_NO_BUILD: "1" + HAI_RUNTIME_CANDIDATE_ARCHIVE: ${{ steps.runtime_candidate.outputs.archive_path }} + HAI_RUNTIME_CANDIDATE_SHA256: ${{ steps.runtime_candidate.outputs.sha256 }} + 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" + Write-Host "Runtime artifact is pending; validating the native CLI installer only." + } + + $server = $null + try { + if ($hasCandidate) { + $archiveDirectory = Split-Path $env:HAI_RUNTIME_CANDIDATE_ARCHIVE + $archiveName = Split-Path $env:HAI_RUNTIME_CANDIDATE_ARCHIVE -Leaf + $python = (& uv python find).Trim() + $server = Start-Process ` + -FilePath $python ` + -ArgumentList "-m", "http.server", "18797", "--bind", "127.0.0.1" ` + -WorkingDirectory $archiveDirectory ` + -PassThru + for ($attempt = 1; $attempt -le 20; $attempt++) { + try { + Invoke-WebRequest "http://127.0.0.1:18797/$archiveName" -Method Head -TimeoutSec 1 | Out-Null + break + } catch { + if ($attempt -eq 20) { + throw "candidate runtime loopback server did not become ready" + } + Start-Sleep -Milliseconds 250 + } + } + $env:HAI_AGENT_RUNTIME_DOWNLOAD_URL = "http://127.0.0.1:18797/$archiveName" + $env:HAI_AGENT_RUNTIME_DOWNLOAD_SHA256 = $env:HAI_RUNTIME_CANDIDATE_SHA256 + } + + 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 + } + } + + if ($runtimeSha -notmatch "^0{64}$" -or $hasCandidate) { + $runtime = Get-ChildItem "$HOME\.holo\runtime\*\hai-agent-runtime.exe" | Select-Object -First 1 + if ($null -eq $runtime) { + throw "installer completed without a managed Windows ARM64 runtime" + } + } + - name: Upload the exact installer candidate + if: always() + uses: actions/upload-artifact@v4 + with: + name: windows-arm64-installer-candidate + path: ${{ runner.temp }}/windows-arm64-installer-candidate/ + if-no-files-found: warn + retention-days: 7 + linux-installer: name: fresh Linux installer runs-on: ubuntu-latest @@ -40,7 +154,7 @@ jobs: cd /tmp curl -fsSL file:///work/install/install.sh | bash \"\$HOLO_HOME/bin/holo\" --help >/dev/null - test -x \"\$HOME/.holo/runtime/0.1.9/hai-agent-runtime\" + test -x \"\$HOME/.holo/runtime/0.1.10/hai-agent-runtime\" " test -e /usr/include/linux/input.h ' @@ -49,7 +163,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest, windows-latest] + os: [ubuntu-latest, macos-latest, windows-latest, windows-11-arm] runs-on: ${{ matrix.os }} env: # Backstop for sub-calls that print before main() reconfigures stdout (cp1252 otherwise). @@ -60,6 +174,19 @@ jobs: - uses: astral-sh/setup-uv@v3 with: enable-cache: true + - name: Prepare OpenSSL for native Python dependencies + if: matrix.os == 'windows-11-arm' + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + $vcpkg = $env:VCPKG_INSTALLATION_ROOT + & "$vcpkg\vcpkg.exe" install openssl:arm64-windows-static-md + if ($LASTEXITCODE -ne 0) { + throw "vcpkg failed to install OpenSSL for Windows ARM64" + } + "VCPKG_ROOT=$vcpkg" >> $env:GITHUB_ENV + "OPENSSL_DIR=$vcpkg\installed\arm64-windows-static-md" >> $env:GITHUB_ENV + "OPENSSL_STATIC=1" >> $env:GITHUB_ENV - run: uv sync --all-groups - name: ruff (lint) run: uv run ruff check . diff --git a/.github/workflows/holo-full-e2e.yml b/.github/workflows/holo-full-e2e.yml index 64425b8..39aaead 100644 --- a/.github/workflows/holo-full-e2e.yml +++ b/.github/workflows/holo-full-e2e.yml @@ -11,6 +11,7 @@ on: - linux - darwin - win32 + - windows-arm64 default: all fast: description: "Run with fast mode (--fast: one screenshot, no thinking, smaller uploads)" @@ -37,18 +38,54 @@ jobs: result-encoding: string script: | const platforms = [ - { os: 'macos-14', platform: 'darwin', platform_name: 'macOS' }, - { os: 'windows-latest', platform: 'win32', platform_name: 'Windows' }, - { os: 'ubuntu-22.04', platform: 'linux', platform_name: 'Linux' }, + { + os: 'macos-14', + selector: 'darwin', + platform: 'darwin', + artifact_platform: 'darwin-arm64', + platform_name: 'macOS', + report_platform: 'macOS', + release_default: true, + }, + { + os: 'windows-latest', + selector: 'win32', + platform: 'win32', + artifact_platform: 'windows-x86_64', + platform_name: 'Windows x86_64', + report_platform: 'Windows', + release_default: true, + }, + { + os: 'windows-11-arm', + selector: 'windows-arm64', + platform: 'win32', + artifact_platform: 'windows-arm64', + platform_name: 'Windows ARM64', + report_platform: 'Windows', + // Promote this into `all` only after a real runtime SHA is pinned. + release_default: false, + }, + { + os: 'ubuntu-22.04', + selector: 'linux', + platform: 'linux', + artifact_platform: 'linux-x86_64', + platform_name: 'Linux', + report_platform: 'Linux', + release_default: true, + }, ]; const selected = context.payload.inputs?.platform || 'all'; - const allowed = new Set(['all', ...platforms.map(({ platform }) => platform)]); + const allowed = new Set(['all', ...platforms.map(({ selector }) => selector)]); if (!allowed.has(selected)) { core.setFailed(`Unsupported platform selection: ${selected}`); return JSON.stringify({ include: [] }); } const include = platforms - .filter(({ platform }) => selected === 'all' || platform === selected) + .filter(({ selector, release_default }) => ( + selected === 'all' ? release_default : selector === selected + )) .flatMap((entry) => Array.from({ length: 4 }, (_, shard_index) => ({ ...entry, shard_index, @@ -95,8 +132,8 @@ jobs: shell: bash run: | { - echo "HOLO_E2E_ARTIFACT_ROOT=$RUNNER_TEMP/holo-full-e2e-artifacts-${{ matrix.platform }}-${{ matrix.shard_index }}" - echo "HOLO_E2E_REPORT_DIR=$RUNNER_TEMP/holo-full-e2e-report-${{ matrix.platform }}-${{ matrix.shard_index }}" + echo "HOLO_E2E_ARTIFACT_ROOT=$RUNNER_TEMP/holo-full-e2e-artifacts-${{ matrix.artifact_platform }}-${{ matrix.shard_index }}" + echo "HOLO_E2E_REPORT_DIR=$RUNNER_TEMP/holo-full-e2e-report-${{ matrix.artifact_platform }}-${{ matrix.shard_index }}" } >> "$GITHUB_ENV" - name: Log GitHub OIDC claims @@ -264,6 +301,30 @@ jobs: with: enable-cache: true + - name: Set up the pre-merge HAI runtime candidate + if: matrix.artifact_platform == 'windows-arm64' && vars.HAI_WINDOWS_ARM64_CANDIDATE_RUN_ID != '' + uses: ./.github/actions/setup-hai-runtime-candidate + with: + github-token: ${{ secrets.HAI_ACTIONS_READ_TOKEN }} + run-id: ${{ vars.HAI_WINDOWS_ARM64_CANDIDATE_RUN_ID }} + expected-head-sha: ${{ vars.HAI_WINDOWS_ARM64_CANDIDATE_HEAD_SHA }} + + - name: Prepare OpenSSL for Windows ARM64 Python dependencies + if: matrix.artifact_platform == 'windows-arm64' + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + $vcpkg = $env:VCPKG_INSTALLATION_ROOT + & "$vcpkg\vcpkg.exe" install openssl:arm64-windows-static-md + if ($LASTEXITCODE -ne 0) { + throw "vcpkg failed to install OpenSSL for Windows ARM64" + } + @( + "VCPKG_ROOT=$vcpkg" + "OPENSSL_DIR=$(Join-Path $vcpkg 'installed\arm64-windows-static-md')" + "OPENSSL_STATIC=1" + ) | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + - name: Install dependencies run: uv sync --all-groups @@ -281,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: | @@ -320,7 +400,7 @@ jobs: -q task_status=$? uv run python -m tests.e2e._report "$HOLO_E2E_ARTIFACT_ROOT" \ - --platform "${{ matrix.platform_name }}" \ + --platform "${{ matrix.report_platform }}" \ --append-task-summary \ --task-id "$task_id" report_status=$? @@ -338,7 +418,7 @@ jobs: continue-on-error: true run: | uv run python -m tests.e2e._report "$HOLO_E2E_ARTIFACT_ROOT" \ - --platform "${{ matrix.platform_name }}" \ + --platform "${{ matrix.report_platform }}" \ --report-dir "$HOLO_E2E_REPORT_DIR" \ --expected-task-ids-file "$RUNNER_TEMP/holo-task-ids.txt" \ --append-step-summary @@ -367,7 +447,8 @@ jobs: if: always() && steps.check-full-e2e-artifact-redactions.outcome == 'success' uses: actions/upload-artifact@v4 with: - name: holo-full-e2e-${{ github.run_id }}-${{ runner.os }}-shard-${{ matrix.shard_index }} + # Architecture is required because both Windows variants report runner.os == Windows. + name: holo-full-e2e-${{ github.run_id }}-${{ matrix.artifact_platform }}-shard-${{ matrix.shard_index }} path: | ${{ env.HOLO_E2E_ARTIFACT_ROOT }} ${{ env.HOLO_E2E_REPORT_DIR }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 7d8bee3..ef4c463 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -37,6 +37,42 @@ jobs: run: | uv run python -c "import holo_desktop.cli; from holo_desktop import __version__; from holo_desktop.agent_client import AgentApiClient, SpawnConfig, ensure_running; print(__version__)" + windows-arm64-dependency: + if: startsWith(github.ref, 'refs/tags/v') + runs-on: windows-11-arm + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v3 + with: + version: "0.9.18" + enable-cache: true + - id: build + name: Build Windows ARM64 dependency wheel and release manifest + shell: pwsh + run: | + .\scripts\build_windows_arm64_dependency_wheel.ps1 ` + -WheelDirectory "$env:RUNNER_TEMP\windows-arm64-dependency\wheels" ` + -ManifestOutput "$env:RUNNER_TEMP\windows-arm64-dependency\install\manifest.json" + - name: Refuse an unpublished or mismatched dependency + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + $manifest = Get-Content -Raw "${{ steps.build.outputs.manifest_path }}" | ConvertFrom-Json + $dependency = @($manifest.supported_platforms."windows-arm64".dependency_wheels)[0] + if ([string]$dependency.url -eq "BUILD_AT_RELEASE" -or [string]$dependency.sha256 -match "^0{64}$") { + throw "release manifest still contains a Windows ARM64 dependency placeholder" + } + $actual = (Get-FileHash -Algorithm SHA256 "${{ steps.build.outputs.wheel_path }}").Hash.ToLowerInvariant() + if ($actual -ne [string]$dependency.sha256) { + throw "release manifest SHA does not match the built Windows ARM64 dependency wheel" + } + - uses: actions/upload-artifact@v4 + with: + name: windows-arm64-dependency + path: ${{ runner.temp }}/windows-arm64-dependency/ + if-no-files-found: error + retention-days: 7 + build: needs: quality runs-on: ubuntu-latest @@ -54,6 +90,15 @@ jobs: echo "tag v$tag does not match pyproject version $pkg" >&2 exit 1 fi + - name: assert every managed runtime artifact is published + if: startsWith(github.ref, 'refs/tags/v') + shell: bash + run: | + set -euo pipefail + if grep -H -E '=0{64}$' shas/*.txt; then + echo "A managed runtime platform still has a placeholder SHA; refusing a client release." >&2 + exit 1 + fi - run: uv build - uses: actions/upload-artifact@v4 with: @@ -63,7 +108,7 @@ jobs: retention-days: 7 publish-pypi: - needs: build + needs: [build, windows-arm64-dependency] if: startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest environment: release @@ -100,7 +145,7 @@ jobs: attestations: true release: - needs: publish-pypi + needs: [publish-pypi, windows-arm64-dependency] if: startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest permissions: @@ -111,13 +156,18 @@ jobs: with: name: dist path: dist/ + - uses: actions/download-artifact@v4 + with: + name: windows-arm64-dependency + path: release-assets/ - uses: softprops/action-gh-release@v2 with: files: | dist/* + release-assets/wheels/*.whl install/install.sh install/install.ps1 - install/manifest.json + release-assets/install/manifest.json fail_on_unmatched_files: true generate_release_notes: true draft: false @@ -134,6 +184,10 @@ jobs: INSTALLER_BASE_URL: https://install.hcompany.ai steps: - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + name: windows-arm64-dependency + path: ${{ runner.temp }}/windows-arm64-dependency/ - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@v4 with: @@ -143,13 +197,59 @@ jobs: shell: bash run: | set -euo pipefail + dependency_root="$RUNNER_TEMP/windows-arm64-dependency" + manifest="$dependency_root/install/manifest.json" + wheel="$(find "$dependency_root/wheels" -maxdepth 1 -type f -name '*.whl' -print -quit)" + test -n "$wheel" + + wheel_url="$(jq -r '.supported_platforms["windows-arm64"].dependency_wheels[0].url' "$manifest")" + expected_sha="$(jq -r '.supported_platforms["windows-arm64"].dependency_wheels[0].sha256' "$manifest")" + actual_sha="$(sha256sum "$wheel" | cut -d' ' -f1)" + if [ "$actual_sha" != "$expected_sha" ]; then + echo "Windows ARM64 dependency wheel SHA does not match the release manifest" >&2 + exit 1 + fi + + installer_prefix="${INSTALLER_BASE_URL%/}/" + case "$wheel_url" in + "$installer_prefix"*) wheel_key="${wheel_url#"$installer_prefix"}" ;; + *) + echo "Windows ARM64 dependency URL is outside $installer_prefix: $wheel_url" >&2 + exit 1 + ;; + esac + + if remote="$(aws s3api head-object --bucket "$INSTALLER_ASSETS_BUCKET" --key "$wheel_key" 2>/dev/null)"; then + remote_sha="$(jq -r '.Metadata.sha256 // ""' <<<"$remote")" + if [ "$remote_sha" != "$expected_sha" ]; then + echo "Immutable dependency object already exists with SHA '$remote_sha', expected '$expected_sha'" >&2 + exit 1 + fi + else + if ! aws s3api put-object \ + --bucket "$INSTALLER_ASSETS_BUCKET" \ + --key "$wheel_key" \ + --body "$wheel" \ + --content-type application/octet-stream \ + --cache-control "public, max-age=31536000, immutable" \ + --metadata "sha256=$expected_sha" \ + --if-none-match '*'; then + remote="$(aws s3api head-object --bucket "$INSTALLER_ASSETS_BUCKET" --key "$wheel_key")" + remote_sha="$(jq -r '.Metadata.sha256 // ""' <<<"$remote")" + if [ "$remote_sha" != "$expected_sha" ]; then + echo "Concurrent immutable dependency upload has SHA '$remote_sha', expected '$expected_sha'" >&2 + exit 1 + fi + fi + fi + aws s3 cp install/install.sh "s3://${INSTALLER_ASSETS_BUCKET}/install.sh" \ --content-type text/x-shellscript \ --cache-control "public, max-age=300" aws s3 cp install/install.ps1 "s3://${INSTALLER_ASSETS_BUCKET}/install.ps1" \ --content-type text/plain \ --cache-control "public, max-age=300" - aws s3 cp install/manifest.json "s3://${INSTALLER_ASSETS_BUCKET}/install/manifest.json" \ + aws s3 cp "$manifest" "s3://${INSTALLER_ASSETS_BUCKET}/install/manifest.json" \ --content-type application/json \ --cache-control "public, max-age=300" - name: Verify CDN endpoints @@ -158,7 +258,12 @@ jobs: set -euo pipefail curl -fsSL "${INSTALLER_BASE_URL}/install.sh" | head -n 5 curl -fsSL "${INSTALLER_BASE_URL}/install.ps1" | head -n 5 - curl -fsSL "${INSTALLER_BASE_URL}/install/manifest.json" | python -m json.tool >/dev/null + manifest="$(curl -fsSL "${INSTALLER_BASE_URL}/install/manifest.json")" + python -m json.tool <<<"$manifest" >/dev/null + wheel_url="$(jq -r '.supported_platforms["windows-arm64"].dependency_wheels[0].url' <<<"$manifest")" + expected_sha="$(jq -r '.supported_platforms["windows-arm64"].dependency_wheels[0].sha256' <<<"$manifest")" + actual_sha="$(curl -fsSL "$wheel_url" | sha256sum | cut -d' ' -f1)" + test "$actual_sha" = "$expected_sha" - name: Smoke Linux installer from CDN shell: bash env: @@ -182,3 +287,34 @@ jobs: done echo "Linux install from ${INSTALLER_BASE_URL}/install.sh failed after 11 attempts" >&2 exit 1 + + smoke-windows-arm64-installer-cdn: + needs: publish-installer-cdn + if: startsWith(github.ref, 'refs/tags/v') + runs-on: windows-11-arm + env: + INSTALLER_BASE_URL: https://install.hcompany.ai + steps: + - name: Smoke Windows ARM64 installer from CDN + shell: pwsh + env: + HOLO_HOME: ${{ runner.temp }}\holo-installer-home\.holo + UV_NO_BUILD: "1" + run: | + $ErrorActionPreference = "Stop" + $installer = Join-Path $env:RUNNER_TEMP "install.ps1" + for ($attempt = 1; $attempt -le 11; $attempt++) { + Remove-Item -Recurse -Force (Split-Path $env:HOLO_HOME) -ErrorAction SilentlyContinue + try { + Invoke-WebRequest "$env:INSTALLER_BASE_URL/install.ps1" -OutFile $installer + & $installer + & "$env:HOLO_HOME\bin\holo.exe" --help + exit 0 + } catch { + Write-Host "Windows ARM64 installer attempt $attempt/11 failed: $_" + if ($attempt -lt 11) { + Start-Sleep -Seconds 30 + } + } + } + throw "Windows ARM64 install from $env:INSTALLER_BASE_URL/install.ps1 failed after 11 attempts" diff --git a/install/install.ps1 b/install/install.ps1 index 9ba96c0..577a8b9 100644 --- a/install/install.ps1 +++ b/install/install.ps1 @@ -5,20 +5,32 @@ function Fail($Message) { exit 1 } +function Copy-OrDownloadArtifact($Source, $Destination) { + if ($Source.StartsWith("file://")) { + Copy-Item -Path $Source.Substring(7) -Destination $Destination -Force + } elseif (Test-Path $Source) { + Copy-Item -Path $Source -Destination $Destination -Force + } else { + Invoke-WebRequest -Uri $Source -OutFile $Destination + } +} + function Get-HoloWindowsPlatform { - $IsWindows = [System.Runtime.InteropServices.RuntimeInformation, mscorlib]::IsOSPlatform( + $RunningOnWindows = [System.Runtime.InteropServices.RuntimeInformation, mscorlib]::IsOSPlatform( [System.Runtime.InteropServices.OSPlatform, mscorlib]::Windows ) - if (-not $IsWindows) { + if (-not $RunningOnWindows) { Fail "Holo Desktop installer does not support this operating system from install.ps1. Use install.sh on macOS or Linux." } $Architecture = [System.Runtime.InteropServices.RuntimeInformation, mscorlib]::OSArchitecture.ToString() - if ($Architecture -ne "X64") { - Fail "Holo Desktop installer does not support Windows architecture '$Architecture'. V1 supports windows-x86_64 only." + switch ($Architecture) { + "X64" { return "windows-x86_64" } + "Arm64" { return "windows-arm64" } + default { + Fail "Holo Desktop installer does not support Windows architecture '$Architecture'. Supported architectures: X64, Arm64." + } } - - return "windows-x86_64" } $Platform = Get-HoloWindowsPlatform @@ -30,13 +42,7 @@ New-Item -ItemType Directory -Force -Path $TempRoot, (Join-Path $HoloHome "bin") try { $ManifestPath = Join-Path $TempRoot "manifest.json" - if ($ManifestUrl.StartsWith("file://")) { - Copy-Item -Path $ManifestUrl.Substring(7) -Destination $ManifestPath -Force - } elseif (Test-Path $ManifestUrl) { - Copy-Item -Path $ManifestUrl -Destination $ManifestPath -Force - } else { - Invoke-WebRequest -Uri $ManifestUrl -OutFile $ManifestPath - } + Copy-OrDownloadArtifact $ManifestUrl $ManifestPath $Manifest = Get-Content -Raw $ManifestPath | ConvertFrom-Json $Entry = $Manifest.supported_platforms.$Platform @@ -51,13 +57,7 @@ try { $PackageSpec = if ($env:HOLO_INSTALL_PACKAGE) { $env:HOLO_INSTALL_PACKAGE } else { "holo-desktop-cli==$HoloVersion" } $UvZip = Join-Path $TempRoot "uv.zip" - if ($UvUrl.StartsWith("file://")) { - Copy-Item -Path $UvUrl.Substring(7) -Destination $UvZip -Force - } elseif (Test-Path $UvUrl) { - Copy-Item -Path $UvUrl -Destination $UvZip -Force - } else { - Invoke-WebRequest -Uri $UvUrl -OutFile $UvZip - } + Copy-OrDownloadArtifact $UvUrl $UvZip $ActualSha256 = (Get-FileHash -Algorithm SHA256 -Path $UvZip).Hash.ToLowerInvariant() if ($ActualSha256 -ne $UvSha256.ToLowerInvariant()) { @@ -79,7 +79,68 @@ try { $env:UV_TOOL_BIN_DIR = Join-Path $HoloHome "bin" & $UvExe python install $PythonVersion --no-bin --no-registry - & $UvExe tool install $PackageSpec --python $PythonVersion --force --reinstall-package holo-desktop-cli + if ($LASTEXITCODE -ne 0) { + Fail "uv failed to install Python $PythonVersion" + } + + $PythonExe = (& $UvExe python find --managed-python $PythonVersion).Trim() + if ($LASTEXITCODE -ne 0 -or -not (Test-Path $PythonExe)) { + Fail "uv installed Python $PythonVersion but could not resolve its executable" + } + $PythonMachine = (& $PythonExe -c "import platform; print(platform.machine())").Trim().ToUpperInvariant() + if ($LASTEXITCODE -ne 0) { + Fail "installed Python could not report its machine architecture" + } + $ExpectedPythonMachine = switch ($Platform) { + "windows-x86_64" { "AMD64" } + "windows-arm64" { "ARM64" } + default { Fail "installer has no Python architecture contract for $Platform" } + } + if ($PythonMachine -ne $ExpectedPythonMachine) { + Fail "installed Python architecture '$PythonMachine' does not match $Platform (expected $ExpectedPythonMachine)" + } + + $BinaryDependencyArgs = @() + if ($Platform -eq "windows-arm64") { + $WheelDirectory = Join-Path $TempRoot "wheels" + New-Item -ItemType Directory -Force -Path $WheelDirectory | Out-Null + $Dependencies = @($Entry.dependency_wheels) + if ($Dependencies.Count -eq 0) { + Fail "installer manifest does not contain Windows ARM64 dependency wheels" + } + foreach ($Dependency in $Dependencies) { + $DependencyName = [string]$Dependency.name + $DependencyUrl = [string]$Dependency.url + $DependencySha256 = [string]$Dependency.sha256 + if (-not $DependencyName -or -not $DependencyUrl -or $DependencyUrl -eq "BUILD_AT_RELEASE") { + Fail "installer manifest has an unpublished Windows ARM64 dependency wheel" + } + if ($DependencySha256 -notmatch "^[0-9a-fA-F]{64}$" -or $DependencySha256 -match "^0{64}$") { + Fail "installer manifest has an invalid SHA for Windows ARM64 dependency '$DependencyName'" + } + + $DependencyFileName = if ($DependencyUrl -match "^[a-zA-Z][a-zA-Z0-9+.-]*://") { + Split-Path ([System.Uri]$DependencyUrl).AbsolutePath -Leaf + } else { + Split-Path $DependencyUrl -Leaf + } + if (-not $DependencyFileName.EndsWith(".whl")) { + Fail "Windows ARM64 dependency '$DependencyName' URL does not name a wheel: $DependencyUrl" + } + $DependencyPath = Join-Path $WheelDirectory $DependencyFileName + Copy-OrDownloadArtifact $DependencyUrl $DependencyPath + $ActualDependencySha256 = (Get-FileHash -Algorithm SHA256 -Path $DependencyPath).Hash.ToLowerInvariant() + if ($ActualDependencySha256 -ne $DependencySha256.ToLowerInvariant()) { + Fail "sha256 mismatch for Windows ARM64 dependency '$DependencyName': expected $DependencySha256, got $ActualDependencySha256" + } + } + $BinaryDependencyArgs = @("--find-links", $WheelDirectory, "--no-build") + } + + & $UvExe tool install $PackageSpec --python $PythonExe --force --reinstall-package holo-desktop-cli @BinaryDependencyArgs + if ($LASTEXITCODE -ne 0) { + Fail "uv failed to install $PackageSpec with $PythonExe" + } $BinDir = Join-Path $HoloHome "bin" $UserPath = [Environment]::GetEnvironmentVariable("Path", "User") @@ -93,7 +154,10 @@ try { } if ($env:HOLO_INSTALL_SKIP_RUN_SETUP -ne "1") { - & $UvExe run --with $PackageSpec python -m holo_desktop.installer_bootstrap --yes + & $UvExe run --python $PythonExe --with $PackageSpec @BinaryDependencyArgs python -m holo_desktop.installer_bootstrap --yes + if ($LASTEXITCODE -ne 0) { + Fail "Holo Desktop runtime setup failed" + } } Write-Host "" diff --git a/install/manifest.json b/install/manifest.json index ef4e035..efec47e 100644 --- a/install/manifest.json +++ b/install/manifest.json @@ -14,6 +14,19 @@ "uv_url": "https://github.com/astral-sh/uv/releases/download/0.9.18/uv-x86_64-pc-windows-msvc.zip", "uv_sha256": "28cbe5d30907a774bfe27a517a39b494ec6f7d3816bda8bbf6f9645490449182" }, + "windows-arm64": { + "runtime": "managed", + "uv_url": "https://github.com/astral-sh/uv/releases/download/0.9.18/uv-aarch64-pc-windows-msvc.zip", + "uv_sha256": "fadb43ba13091f44e1786fc3967e65c7786d86192aa205d718307c649927cfc2", + "dependency_wheels": [ + { + "name": "cryptography", + "version": "48.0.0", + "url": "BUILD_AT_RELEASE", + "sha256": "0000000000000000000000000000000000000000000000000000000000000000" + } + ] + }, "linux-x86_64": { "runtime": "managed", "uv_url": "https://github.com/astral-sh/uv/releases/download/0.9.18/uv-x86_64-unknown-linux-gnu.tar.gz", diff --git a/scripts/build_windows_arm64_dependency_wheel.ps1 b/scripts/build_windows_arm64_dependency_wheel.ps1 new file mode 100644 index 0000000..74ec68b --- /dev/null +++ b/scripts/build_windows_arm64_dependency_wheel.ps1 @@ -0,0 +1,96 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$WheelDirectory, + + [Parameter(Mandatory = $true)] + [string]$ManifestOutput, + + [string]$WheelBaseUrl = "https://install.hcompany.ai/wheels/windows-arm64" +) + +$ErrorActionPreference = "Stop" + +$RepoRoot = Split-Path $PSScriptRoot -Parent +$ManifestSource = Join-Path $RepoRoot "install\manifest.json" +$Manifest = Get-Content -Raw $ManifestSource | ConvertFrom-Json +$WindowsArm64 = $Manifest.supported_platforms."windows-arm64" +$Dependencies = @($WindowsArm64.dependency_wheels) + +if ($Dependencies.Count -ne 1 -or [string]$Dependencies[0].name -ne "cryptography") { + throw "Windows ARM64 manifest must contain exactly one cryptography dependency wheel" +} + +$Dependency = $Dependencies[0] +$DependencyName = [string]$Dependency.name +$DependencyVersion = [string]$Dependency.version +$PythonVersion = [string]$Manifest.python_version +$PythonSelector = "cpython-$PythonVersion-windows-aarch64-none" +$VcpkgRoot = $env:VCPKG_INSTALLATION_ROOT + +if (-not $VcpkgRoot -or -not (Test-Path (Join-Path $VcpkgRoot "vcpkg.exe"))) { + throw "VCPKG_INSTALLATION_ROOT does not point to a vcpkg installation" +} + +New-Item -ItemType Directory -Force -Path $WheelDirectory | Out-Null +if (Get-ChildItem -Path $WheelDirectory -Filter "*.whl" -ErrorAction SilentlyContinue) { + throw "wheel output directory must not already contain wheel files: $WheelDirectory" +} + +& (Join-Path $VcpkgRoot "vcpkg.exe") install openssl:arm64-windows-static-md +if ($LASTEXITCODE -ne 0) { + throw "vcpkg failed to install OpenSSL for Windows ARM64" +} + +$env:VCPKG_ROOT = $VcpkgRoot +$env:OPENSSL_DIR = Join-Path $VcpkgRoot "installed\arm64-windows-static-md" +$env:OPENSSL_STATIC = "1" + +& uv python install $PythonSelector --no-bin --no-registry +if ($LASTEXITCODE -ne 0) { + throw "uv failed to install Python $PythonSelector" +} + +& uv run --no-project --python $PythonSelector --with pip python -m pip wheel ` + "$DependencyName==$DependencyVersion" ` + --no-deps ` + --no-binary $DependencyName ` + --no-cache-dir ` + --wheel-dir $WheelDirectory +if ($LASTEXITCODE -ne 0) { + throw "failed to build $DependencyName $DependencyVersion for Windows ARM64" +} + +$Wheels = @(Get-ChildItem -Path $WheelDirectory -Filter "$DependencyName-*-win_arm64.whl") +if ($Wheels.Count -ne 1) { + $Found = @(Get-ChildItem -Path $WheelDirectory -Filter "*.whl" | ForEach-Object Name) -join ", " + throw "expected one Windows ARM64 $DependencyName wheel, found: $Found" +} + +$Wheel = $Wheels[0] +$WheelSha256 = (Get-FileHash -Algorithm SHA256 -Path $Wheel.FullName).Hash.ToLowerInvariant() +$Dependency.url = if ($WheelBaseUrl -match "^https?://") { + "$($WheelBaseUrl.TrimEnd('/'))/$DependencyName/$DependencyVersion/$($Wheel.Name)" +} else { + Join-Path $WheelBaseUrl $Wheel.Name +} +$Dependency.sha256 = $WheelSha256 + +$ManifestParent = Split-Path $ManifestOutput -Parent +if ($ManifestParent) { + New-Item -ItemType Directory -Force -Path $ManifestParent | Out-Null +} +$Manifest | ConvertTo-Json -Depth 10 | Set-Content -Path $ManifestOutput -Encoding utf8 + +if ($env:GITHUB_OUTPUT) { + @( + "wheel_path=$($Wheel.FullName)" + "wheel_name=$($Wheel.Name)" + "wheel_sha256=$WheelSha256" + "manifest_path=$ManifestOutput" + ) | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append +} + +Write-Host "wheel_path=$($Wheel.FullName)" +Write-Host "wheel_sha256=$WheelSha256" +Write-Host "manifest_path=$ManifestOutput" diff --git a/shas/darwin-arm64.txt b/shas/darwin-arm64.txt index 484fd17..667d659 100644 --- a/shas/darwin-arm64.txt +++ b/shas/darwin-arm64.txt @@ -1 +1 @@ -darwin-arm64=c1a8415f0d1e05cd01bca2f2f55b7807d4865e2a838b31aa483c7be5d3791c2c +darwin-arm64=2a08aca6bc3201920cd8671631f09b254693b343b59a448136daa48634afd5c7 diff --git a/shas/linux-x86_64.txt b/shas/linux-x86_64.txt index 8b714ae..2aa97f2 100644 --- a/shas/linux-x86_64.txt +++ b/shas/linux-x86_64.txt @@ -1 +1 @@ -linux-x86_64=3ef09e1706b100ead1d40384bd319d5b98754f151713226627d7e7a0ee0e30a3 +linux-x86_64=bf50ab53f6c47e7b3d5dad0856d5511ddd934ed444039b561edc612fd4fda42a diff --git a/shas/windows-arm64.txt b/shas/windows-arm64.txt new file mode 100644 index 0000000..396dfa4 --- /dev/null +++ b/shas/windows-arm64.txt @@ -0,0 +1 @@ +windows-arm64=454ee9a06eb7ca0287206a5324917e289699341934e34ebc6e6b884b08a15265 diff --git a/shas/windows-x86_64.txt b/shas/windows-x86_64.txt index 250676c..b2ea626 100644 --- a/shas/windows-x86_64.txt +++ b/shas/windows-x86_64.txt @@ -1 +1 @@ -windows-x86_64=b8ddaf46f24503602fc3d8d8351dfeb15aa7a7b5f1c8fc17d3843249a1ccb523 +windows-x86_64=1e45306906c24e1e949259c6d2998496d17a26320737344d2e2a31bf656d7219 diff --git a/src/holo_desktop/agent_client/runtime_install.py b/src/holo_desktop/agent_client/runtime_install.py index 1338c5e..5093b74 100644 --- a/src/holo_desktop/agent_client/runtime_install.py +++ b/src/holo_desktop/agent_client/runtime_install.py @@ -25,12 +25,12 @@ logger = logging.getLogger(__name__) -PINNED_RUNTIME_VERSION = "0.1.9" +PINNED_RUNTIME_VERSION = "0.1.10" RUNTIME_DIR = Path.home() / ".holo" / "runtime" # Artifacts live under an immutable, version-scoped prefix, so a CDN edge can never serve stale bytes. RUNTIME_CDN_BASE = "https://assets.hcompanyprod.fr/hai-agent-runtime" BINARY_NAME = "hai-agent-runtime.exe" if os.name == "nt" else "hai-agent-runtime" -# Guard value: published manifest entries must never use it, since every download would fail verification. +# Marks a manifest platform whose artifact is not published yet; installs refuse it before any download. PLACEHOLDER_SHA256 = "0" * 64 _DOWNLOAD_TIMEOUT = httpx.Timeout(30.0, read=600.0) # Generous ceiling (the runtime is hundreds of MB); guards against a lying/absent Content-Length filling the disk. @@ -63,20 +63,21 @@ def _artifact(filename: str, sha256: str) -> RuntimeArtifact: MANIFEST: dict[str, RuntimeArtifact] = { "darwin-arm64": _artifact( "hai-agent-runtime-darwin-arm64.zip", - "c1a8415f0d1e05cd01bca2f2f55b7807d4865e2a838b31aa483c7be5d3791c2c", + "2a08aca6bc3201920cd8671631f09b254693b343b59a448136daa48634afd5c7", ), "windows-x86_64": _artifact( "hai-agent-runtime-windows-x86_64.zip", - "b8ddaf46f24503602fc3d8d8351dfeb15aa7a7b5f1c8fc17d3843249a1ccb523", + "1e45306906c24e1e949259c6d2998496d17a26320737344d2e2a31bf656d7219", ), "linux-x86_64": _artifact( "hai-agent-runtime-linux-x86_64.zip", - "3ef09e1706b100ead1d40384bd319d5b98754f151713226627d7e7a0ee0e30a3", + "bf50ab53f6c47e7b3d5dad0856d5511ddd934ed444039b561edc612fd4fda42a", + ), + # Placeholder digest (bump_runtime.py needs a hex literal anchor); the release pipeline fills it in. + "windows-arm64": _artifact( + "hai-agent-runtime-windows-arm64.zip", + "454ee9a06eb7ca0287206a5324917e289699341934e34ebc6e6b884b08a15265", ), -} - -UNIMPLEMENTED_PLATFORMS: dict[str, str] = { - "darwin-x86_64": "hai-agent-runtime is not published for macOS Intel yet", } @@ -105,19 +106,12 @@ def pinned_artifact(*, settings: RuntimeInstallSettings) -> RuntimeArtifact: ) return RuntimeArtifact(url=settings.download_url, sha256=settings.download_sha256) key = platform_key() - if key in UNIMPLEMENTED_PLATFORMS: + artifact = MANIFEST.get(key) + if artifact is None or artifact.sha256 == PLACEHOLDER_SHA256: raise RuntimeArtifactUnavailable( - f"{UNIMPLEMENTED_PLATFORMS[key]}; put hai-agent-runtime on PATH, " + f"hai-agent-runtime is not published for {key} yet; put hai-agent-runtime on PATH, " f"or set {DOWNLOAD_URL_ENV} + {DOWNLOAD_SHA256_ENV} to a trusted build" ) - artifact = MANIFEST.get(key) - if artifact is None: - raise RuntimeError(f"no hai-agent-runtime release artifact for platform {key}") - if artifact.sha256 == PLACEHOLDER_SHA256: - raise RuntimeError( - f"hai-agent-runtime v{PINNED_RUNTIME_VERSION} has no published artifact for {key} yet; " - f"put hai-agent-runtime on PATH, or set {DOWNLOAD_URL_ENV} + {DOWNLOAD_SHA256_ENV} to a trusted build" - ) return artifact 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..93ac7df --- /dev/null +++ b/tests/e2e/windows_desktop.py @@ -0,0 +1,95 @@ +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" + DESKTOP_ORACLE_UNAVAILABLE = "desktop_oracle_unavailable" + + +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) diff --git a/tests/test_bump_runtime.py b/tests/test_bump_runtime.py index 264f13b..a3c16d0 100644 --- a/tests/test_bump_runtime.py +++ b/tests/test_bump_runtime.py @@ -16,31 +16,40 @@ NEW_DARWIN = "a" * 64 NEW_WINDOWS = "b" * 64 NEW_LINUX = "c" * 64 -# A full bump must cover every published platform in the manifest. -ALL_SHAS = {"darwin-arm64": NEW_DARWIN, "windows-x86_64": NEW_WINDOWS, "linux-x86_64": NEW_LINUX} +NEW_WINDOWS_ARM = "d" * 64 +ALL_PLATFORM_SHAS = { + "darwin-arm64": NEW_DARWIN, + "windows-x86_64": NEW_WINDOWS, + "linux-x86_64": NEW_LINUX, + "windows-arm64": NEW_WINDOWS_ARM, +} def test_apply_bump_rewrites_version_and_digests() -> None: - bump = RuntimeBump(version="0.2.0", shas=dict(ALL_SHAS)) + bump = RuntimeBump(version="0.2.0", shas=ALL_PLATFORM_SHAS) result = apply_bump(REAL_SOURCE, bump) assert 'PINNED_RUNTIME_VERSION = "0.2.0"' in result - assert NEW_DARWIN in result and NEW_WINDOWS in result and NEW_LINUX in result + assert all(sha in result for sha in ALL_PLATFORM_SHAS.values()) # The version literal must not appear duplicated in any artifact URL (URLs are derived). assert result.count('PINNED_RUNTIME_VERSION = "') == 1 - # Untouched platform digests of the original must be gone (both were replaced). - assert "f4085285a9722730408fd5e5dfc36672809ba60250552cf701a5e1198bdc2427" not in result + # A placeholder digest must be replaceable like any other (windows-arm64 pre-release). + assert runtime_install.PLACEHOLDER_SHA256 not in result def test_apply_bump_keeps_each_digest_with_its_platform() -> None: - bump = RuntimeBump(version="0.2.0", shas=dict(ALL_SHAS)) + bump = RuntimeBump(version="0.2.0", shas=dict(ALL_PLATFORM_SHAS)) result = apply_bump(REAL_SOURCE, bump) darwin_idx = result.index("hai-agent-runtime-darwin-arm64.zip") windows_idx = result.index("hai-agent-runtime-windows-x86_64.zip") - assert result.index(NEW_DARWIN, darwin_idx) < result.index(NEW_WINDOWS, windows_idx) - # The darwin digest must sit between the darwin filename and the windows entry. + linux_idx = result.index("hai-agent-runtime-linux-x86_64.zip") + windows_arm_idx = result.index("hai-agent-runtime-windows-arm64.zip") + # Each digest must sit directly after its own filename literal. assert darwin_idx < result.index(NEW_DARWIN) < windows_idx + assert windows_idx < result.index(NEW_WINDOWS) < linux_idx + assert linux_idx < result.index(NEW_LINUX) < windows_arm_idx + assert windows_arm_idx < result.index(NEW_WINDOWS_ARM) def test_apply_bump_rejects_non_hex_sha() -> None: @@ -57,10 +66,13 @@ def test_apply_bump_raises_when_platform_filename_absent() -> None: def test_apply_bump_requires_a_sha_for_every_published_platform() -> None: - # darwin alone bumps the shared version but leaves windows pinned to a stale - # digest at the new version's URL — a guaranteed download verification failure. - bump = RuntimeBump(version="0.2.0", shas={"darwin-arm64": NEW_DARWIN}) - with pytest.raises(ValueError, match="windows-x86_64"): + # A partial bump leaves the omitted platforms pinned to a stale digest at + # the new version's URL — a guaranteed download verification failure. + bump = RuntimeBump( + version="0.2.0", + shas={"darwin-arm64": NEW_DARWIN, "windows-x86_64": NEW_WINDOWS, "linux-x86_64": NEW_LINUX}, + ) + with pytest.raises(ValueError, match="windows-arm64"): apply_bump(REAL_SOURCE, bump) diff --git a/tests/test_install_scripts_static.py b/tests/test_install_scripts_static.py index 27ff414..3b335f0 100644 --- a/tests/test_install_scripts_static.py +++ b/tests/test_install_scripts_static.py @@ -5,6 +5,7 @@ import shutil import subprocess import sys +import tomllib from pathlib import Path import pytest @@ -15,13 +16,31 @@ def test_manifest_supports_only_v1_platforms_with_real_hashes() -> None: manifest = json.loads((INSTALL_DIR / "manifest.json").read_text()) - assert set(manifest["supported_platforms"]) == {"darwin-arm64", "windows-x86_64", "linux-x86_64"} + assert set(manifest["supported_platforms"]) == { + "darwin-arm64", + "windows-x86_64", + "windows-arm64", + "linux-x86_64", + } assert manifest["holo_version"] == "0.0.4" assert manifest["python_version"] == "3.12" for entry in manifest["supported_platforms"].values(): assert re.fullmatch(r"[0-9a-f]{64}", entry["uv_sha256"]) assert entry["uv_url"].startswith("https://") + dependencies = manifest["supported_platforms"]["windows-arm64"]["dependency_wheels"] + assert dependencies == [ + { + "name": "cryptography", + "version": "48.0.0", + "url": "BUILD_AT_RELEASE", + "sha256": "0" * 64, + } + ] + lock = tomllib.loads((ROOT / "uv.lock").read_text(encoding="utf-8")) + locked_cryptography = next(package for package in lock["package"] if package["name"] == "cryptography") + assert dependencies[0]["version"] == locked_cryptography["version"] + def test_install_sh_has_supported_and_unsupported_platform_paths() -> None: script = INSTALL_DIR / "install.sh" @@ -42,15 +61,24 @@ def test_install_sh_has_supported_and_unsupported_platform_paths() -> None: assert 'holo" setup' not in text -def test_install_ps1_targets_windows_x86_64_and_user_path() -> None: +def test_install_ps1_targets_supported_windows_architectures_and_user_path() -> None: text = (INSTALL_DIR / "install.ps1").read_text() + assert "$RunningOnWindows =" in text + assert "$IsWindows =" not in text assert "[Environment]::SetEnvironmentVariable" in text assert "windows-x86_64" in text + assert "windows-arm64" in text + assert "--managed-python" in text + assert "installed Python architecture" in text assert "https://install.hcompany.ai/install/manifest.json" in text assert "HOLO_INSTALL_SKIP_RUN_SETUP" in text assert "--no-bin" in text assert "--no-registry" in text assert "--reinstall-package holo-desktop-cli" in text + assert "--find-links" in text + assert "--no-build" in text + assert "dependency_wheels" in text + assert "sha256 mismatch for Windows ARM64 dependency" in text assert "python -m holo_desktop.installer_bootstrap --yes" in text assert 'holo.exe") setup' not in text @@ -76,7 +104,7 @@ def test_install_ps1_parses_when_powershell_is_available() -> None: "shell", [path for name in ("powershell", "pwsh") if (path := shutil.which(name))], ) -def test_install_ps1_detects_x64_with_psreadline_loaded(shell: str) -> None: +def test_install_ps1_detects_native_architecture_with_psreadline_loaded(shell: str) -> None: command = r""" $tokens = $null $errors = $null @@ -102,8 +130,14 @@ def test_install_ps1_detects_x64_with_psreadline_loaded(shell: str) -> None: Import-Module PSReadLine -ErrorAction Stop $platform = Get-HoloWindowsPlatform -if ($platform -ne 'windows-x86_64') { - throw "expected windows-x86_64, got '$platform'" +$architecture = [System.Runtime.InteropServices.RuntimeInformation, mscorlib]::OSArchitecture.ToString() +$expected = switch ($architecture) { + 'X64' { 'windows-x86_64' } + 'Arm64' { 'windows-arm64' } + default { throw "test has no expectation for Windows architecture '$architecture'" } +} +if ($platform -ne $expected) { + throw "expected $expected for $architecture, got '$platform'" } """ subprocess.run([shell, "-NoProfile", "-Command", command], cwd=ROOT, check=True) diff --git a/tests/test_publish_workflow_static.py b/tests/test_publish_workflow_static.py index f0adef0..ff691e9 100644 --- a/tests/test_publish_workflow_static.py +++ b/tests/test_publish_workflow_static.py @@ -22,10 +22,16 @@ def test_publish_workflow_uploads_installer_assets_after_release() -> None: assert "s3://${INSTALLER_ASSETS_BUCKET}/install.sh" in rendered assert "s3://${INSTALLER_ASSETS_BUCKET}/install.ps1" in rendered assert "s3://${INSTALLER_ASSETS_BUCKET}/install/manifest.json" in rendered + assert "windows-arm64-dependency" in rendered + assert "put-object" in rendered + assert "Metadata.sha256" in rendered assert "https://install.hcompany.ai" in rendered assert "--cache-control" in rendered assert "--content-type text/x-shellscript" in rendered assert "--content-type application/json" in rendered + upload_step = next(step for step in job["steps"] if step.get("name") == "Upload installer assets") + assert "--if-none-match '*'" in upload_step["run"] + assert "max-age=31536000, immutable" in upload_step["run"] smoke_step = next(step for step in job["steps"] if step.get("name") == "Smoke Linux installer from CDN") assert 'curl -fsSL "${INSTALLER_BASE_URL}/install.sh" | bash' in smoke_step["run"] assert '"$HOLO_HOME/bin/holo" --help' in smoke_step["run"] @@ -38,9 +44,150 @@ def test_release_job_checks_out_installer_assets_before_github_release() -> None assert steps[0]["uses"] == "actions/checkout@v4" release_step = steps[-1] assert release_step["uses"] == "softprops/action-gh-release@v2" + assert "release-assets/wheels/*.whl" in release_step["with"]["files"] assert "install/install.sh" in release_step["with"]["files"] assert "install/install.ps1" in release_step["with"]["files"] - assert "install/manifest.json" in release_step["with"]["files"] + assert "release-assets/install/manifest.json" in release_step["with"]["files"] + + +def test_release_builds_and_materializes_the_windows_arm64_dependency() -> None: + workflow = yaml.safe_load((ROOT / ".github/workflows/publish.yml").read_text(encoding="utf-8")) + wheel = workflow["jobs"]["windows-arm64-dependency"] + release = workflow["jobs"]["release"] + + assert wheel["if"] == "startsWith(github.ref, 'refs/tags/v')" + assert wheel["runs-on"] == "windows-11-arm" + rendered = yaml.safe_dump(wheel, sort_keys=True) + assert "build_windows_arm64_dependency_wheel.ps1" in rendered + assert "manifest_path" in rendered + assert "wheel_path" in rendered + assert "BUILD_AT_RELEASE" in rendered + assert "windows-arm64-dependency" in rendered + assert workflow["jobs"]["publish-pypi"]["needs"] == ["build", "windows-arm64-dependency"] + assert release["needs"] == ["publish-pypi", "windows-arm64-dependency"] + + build_script = (ROOT / "scripts/build_windows_arm64_dependency_wheel.ps1").read_text(encoding="utf-8") + assert '"cpython-$PythonVersion-windows-aarch64-none"' in build_script + assert '-Filter "$DependencyName-*-win_arm64.whl"' in build_script + + +def test_client_release_refuses_placeholder_runtime_and_smokes_windows_arm64() -> None: + workflow = yaml.safe_load((ROOT / ".github/workflows/publish.yml").read_text(encoding="utf-8")) + build_steps = workflow["jobs"]["build"]["steps"] + gate = next( + step for step in build_steps if step.get("name") == "assert every managed runtime artifact is published" + ) + assert gate["if"] == "startsWith(github.ref, 'refs/tags/v')" + assert "'=0{64}$' shas/*.txt" in gate["run"] + + smoke = workflow["jobs"]["smoke-windows-arm64-installer-cdn"] + assert smoke["needs"] == "publish-installer-cdn" + assert smoke["runs-on"] == "windows-11-arm" + rendered = yaml.safe_dump(smoke, sort_keys=True) + assert "https://install.hcompany.ai" in rendered + assert "install.ps1" in rendered + assert "holo.exe" in rendered + smoke_step = next(step for step in smoke["steps"] if step.get("name") == "Smoke Windows ARM64 installer from CDN") + assert smoke_step["env"]["UV_NO_BUILD"] == "1" + + +def test_windows_arm64_installer_and_full_e2e_scaffolding() -> None: + ci = yaml.safe_load((ROOT / ".github/workflows/ci.yml").read_text(encoding="utf-8")) + installer = ci["jobs"]["windows-arm64-installer"] + assert installer["runs-on"] == "windows-11-arm" + rendered_installer = yaml.safe_dump(installer, sort_keys=True) + assert "install.ps1" in rendered_installer + assert "windows-arm64.txt" in rendered_installer + assert "HOLO_INSTALL_SKIP_RUN_SETUP" in rendered_installer + assert "build_windows_arm64_dependency_wheel.ps1" in rendered_installer + 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" + full_e2e_source = full_e2e_path.read_text(encoding="utf-8") + assert "selector: 'windows-arm64'" in full_e2e_source + assert "os: 'windows-11-arm'" in full_e2e_source + assert "artifact_platform: 'windows-arm64'" in full_e2e_source + assert "release_default: false" in full_e2e_source + assert "matrix.artifact_platform" in full_e2e_source + + full_e2e = yaml.safe_load(full_e2e_source) + openssl = next( + step + for step in full_e2e["jobs"]["full-e2e"]["steps"] + if step.get("name") == "Prepare OpenSSL for Windows ARM64 Python dependencies" + ) + assert openssl["if"] == "matrix.artifact_platform == 'windows-arm64'" + assert openssl["shell"] == "pwsh" + assert "openssl:arm64-windows-static-md" in openssl["run"] + assert "VCPKG_ROOT=" in openssl["run"] + 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( + (ROOT / ".github/actions/setup-hai-runtime-candidate/action.yml").read_text(encoding="utf-8") + ) + assert set(action["inputs"]) == {"github-token", "run-id", "expected-head-sha"} + downloads = [step for step in action["runs"]["steps"] if step.get("uses") == "actions/download-artifact@v4"] + assert {step["with"]["name"] for step in downloads} == { + "runtime-zip-windows-arm64", + "runtime-sha-windows-arm64", + } + for step in downloads: + assert step["with"]["repository"] == "hcompai/hai" + assert step["with"]["github-token"] == "${{ inputs.github-token }}" + assert step["with"]["run-id"] == "${{ inputs.run-id }}" + + rendered_action = yaml.safe_dump(action, sort_keys=True) + assert "run.status -ne" in rendered_action + assert "run.conclusion -ne" in rendered_action + assert "run.head_sha -ne" in rendered_action + assert "Windows ARM64 runtime artifact SHA mismatch" in rendered_action + assert "hai-agent-runtime.exe" in rendered_action + assert "GITHUB_PATH" in rendered_action + + ci = yaml.safe_load((ROOT / ".github/workflows/ci.yml").read_text(encoding="utf-8")) + installer = ci["jobs"]["windows-arm64-installer"] + candidate = next(step for step in installer["steps"] if step.get("id") == "runtime_candidate") + assert candidate["if"] == "vars.HAI_WINDOWS_ARM64_CANDIDATE_RUN_ID != ''" + assert candidate["uses"] == "./.github/actions/setup-hai-runtime-candidate" + assert candidate["with"] == { + "github-token": "${{ secrets.HAI_ACTIONS_READ_TOKEN }}", + "run-id": "${{ vars.HAI_WINDOWS_ARM64_CANDIDATE_RUN_ID }}", + "expected-head-sha": "${{ vars.HAI_WINDOWS_ARM64_CANDIDATE_HEAD_SHA }}", + } + installer_run = next( + step for step in installer["steps"] if step.get("name") == "Install from the pull request checkout" + ) + assert "HAI_AGENT_RUNTIME_DOWNLOAD_URL" in installer_run["run"] + assert "HAI_AGENT_RUNTIME_DOWNLOAD_SHA256" in installer_run["run"] + assert "http.server" in installer_run["run"] + assert "$runtimeSha -notmatch" in installer_run["run"] and "$hasCandidate" in installer_run["run"] + + full_e2e = yaml.safe_load((ROOT / ".github/workflows/holo-full-e2e.yml").read_text(encoding="utf-8")) + e2e_candidate = next( + step + for step in full_e2e["jobs"]["full-e2e"]["steps"] + if step.get("name") == "Set up the pre-merge HAI runtime candidate" + ) + assert e2e_candidate["if"] == ( + "matrix.artifact_platform == 'windows-arm64' && vars.HAI_WINDOWS_ARM64_CANDIDATE_RUN_ID != ''" + ) + assert e2e_candidate["uses"] == "./.github/actions/setup-hai-runtime-candidate" def test_linux_live_workflows_install_the_pull_request_candidate() -> None: diff --git a/tests/test_runtime_install.py b/tests/test_runtime_install.py index 2f48757..cfae990 100644 --- a/tests/test_runtime_install.py +++ b/tests/test_runtime_install.py @@ -276,6 +276,13 @@ def test_published_manifest_entry_is_real(platform_key: str, suffix: str) -> Non assert artifact.url.endswith(suffix) +@pytest.mark.parametrize("platform_key", sorted(runtime_install.MANIFEST)) +def test_manifest_sha_is_lowercase_hex_or_placeholder(platform_key: str) -> None: + # bump_runtime.py rewrites digests by regex anchor; a malformed literal would silently never match. + sha = runtime_install.MANIFEST[platform_key].sha256 + assert len(sha) == 64 and set(sha) <= set("0123456789abcdef") + + _NETWORK_TEST_ENV = "HOLO_RUNTIME_NETWORK_TEST" @@ -289,6 +296,8 @@ def test_pinned_artifact_is_published_and_matches_sha(platform_key: str) -> None # actually live and its bytes hash to the pinned digest (the real end-to-end # guarantee a download relies on). Opt-in: it hits the network and is large. artifact = runtime_install.MANIFEST[platform_key] + if artifact.sha256 == runtime_install.PLACEHOLDER_SHA256: + pytest.skip(f"{platform_key} artifact is not published yet") digest = hashlib.sha256() with ( httpx.Client(follow_redirects=True, timeout=httpx.Timeout(30.0, read=600.0)) as client, @@ -300,26 +309,24 @@ def test_pinned_artifact_is_published_and_matches_sha(platform_key: str) -> None assert digest.hexdigest() == artifact.sha256.lower(), f"published bytes do not match pinned sha for {platform_key}" -@pytest.mark.parametrize("platform_key", ["darwin-x86_64"]) def test_unimplemented_platform_refuses_install( - platform_key: str, runtime_dir: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + runtime_dir: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - # Unpublished platforms must be explicit product gaps, not placeholder - # release URLs that look real until download/verification time. + # A platform absent from the manifest must fail with actionable guidance + # before any download is attempted. _clear_resolution_env(monkeypatch, tmp_path) - monkeypatch.setattr(runtime_install, "platform_key", lambda: platform_key) + monkeypatch.setattr(runtime_install, "platform_key", lambda: "darwin-x86_64") with pytest.raises(runtime_install.RuntimeArtifactUnavailable, match="not published"): _artifact() assert installed_binary(PINNED_RUNTIME_VERSION) is None -@pytest.mark.parametrize("platform_key", ["darwin-x86_64"]) def test_resolve_fails_before_prompting_on_unimplemented_platform( - platform_key: str, runtime_dir: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + runtime_dir: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: # The TTY confirm must never ask the user to approve a download that cannot happen. _clear_resolution_env(monkeypatch, tmp_path) - monkeypatch.setattr(runtime_install, "platform_key", lambda: platform_key) + monkeypatch.setattr(runtime_install, "platform_key", lambda: "darwin-x86_64") def _no_prompt() -> bool: raise AssertionError("confirm_download must not be reached for an unimplemented platform")