From 81364f6fe28e07f4505121555c6632f5f10e7831 Mon Sep 17 00:00:00 2001 From: David McKay Date: Tue, 15 Sep 2026 09:07:52 -0700 Subject: [PATCH 1/5] fix(desktop): detect Windows prerequisites without elevation --- .github/workflows/desktop.yml | 15 + .../scripts/test-windows-standard-user.ps1 | 173 ++++++++ desktop/src-tauri/src/windows.rs | 373 +++++++++++++++--- 3 files changed, 501 insertions(+), 60 deletions(-) create mode 100644 desktop/scripts/test-windows-standard-user.ps1 diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml index 2167b17f1..11c085ba4 100644 --- a/.github/workflows/desktop.yml +++ b/.github/workflows/desktop.yml @@ -133,6 +133,21 @@ jobs: - name: Rust regression tests run: cargo test --locked --lib --bins working-directory: desktop/src-tauri + # Hosted Windows runners are administrators. Exercise setup again with an actual Users-only + # account: DISM's feature reads worked as CI's administrator but refused ordinary app users. + - name: Windows standard-user setup regression + if: matrix.platform.name == 'windows' + shell: pwsh + working-directory: desktop/src-tauri + run: | + $messages = @(cargo test --locked --lib --no-run --message-format=json) + if ($LASTEXITCODE -ne 0) { throw 'Could not build the native setup test.' } + $executables = @($messages | ForEach-Object { $_ | ConvertFrom-Json } | Where-Object { + $_.reason -eq 'compiler-artifact' -and $_.target.name -eq 'openbot_desktop_lib' -and + $_.profile.test -and $_.executable + } | ForEach-Object { $_.executable } | Select-Object -Unique) + if ($executables.Count -ne 1) { throw 'Expected exactly one current desktop library test executable.' } + & ../scripts/test-windows-standard-user.ps1 -TestExecutable $executables[0] # Keep what was built. Without this the only way to try an installer is to build one on # the machine you are trying it on, which is not what anybody installs. - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/desktop/scripts/test-windows-standard-user.ps1 b/desktop/scripts/test-windows-standard-user.ps1 new file mode 100644 index 000000000..e8ec2f04d --- /dev/null +++ b/desktop/scripts/test-windows-standard-user.ps1 @@ -0,0 +1,173 @@ +#Requires -Version 5.1 +#Requires -RunAsAdministrator +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string]$TestExecutable, + [ValidateRange(10, 600)] + [int]$TimeoutSeconds = 120 +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +# A limited token belonging to an administrator is not this regression boundary. +# Create a fresh account whose only local group is Users, and run the actual Rust test there. +$source = (Get-Item -LiteralPath $TestExecutable).FullName +$testName = 'windows::tests::native_standard_user_setup_probe' +$listed = & $source --list --ignored --exact $testName +if ($LASTEXITCODE -ne 0 -or $listed -notcontains "${testName}: test") { + throw "The supplied executable does not contain the ignored native standard-user setup test." +} + +$suffix = [Guid]::NewGuid().ToString('N') +$userName = "obci_$($suffix.Substring(0, 12))" +$directory = Join-Path $env:ProgramData "OpenBot-standard-user-$suffix" +$userCreated = $false +$directoryCreated = $false +$process = $null +$userSid = $null +$password = $null +$securePassword = $null +$passwordBytes = New-Object byte[] 48 +$random = [Security.Cryptography.RandomNumberGenerator]::Create() + +try { + # The password only reaches the account/process APIs in memory. It is never written into the + # wrapper, environment, command-line arguments, logs, or a credential file. + $random.GetBytes($passwordBytes) + $password = 'aZ9!' + [Convert]::ToBase64String($passwordBytes) + $securePassword = ConvertTo-SecureString $password -AsPlainText -Force + $user = New-LocalUser -Name $userName -Password $securePassword ` + -Description 'OpenBot standard-user regression test' ` + -AccountExpires (Get-Date).AddHours(1) + $userCreated = $true + $userSid = $user.SID + $usersSid = [Security.Principal.SecurityIdentifier]'S-1-5-32-545' + Add-LocalGroupMember -SID $usersSid -Member $user + $memberships = @(Get-LocalGroup | Where-Object { + @(Get-LocalGroupMember -SID $_.SID | Where-Object { $_.SID -eq $userSid }).Count -gt 0 + }) + if ($memberships.Count -ne 1 -or $memberships[0].SID -ne $usersSid) { + throw 'The temporary account must belong only to the local Users group.' + } + + New-Item -ItemType Directory -Path $directory | Out-Null + $directoryCreated = $true + $acl = New-Object Security.AccessControl.DirectorySecurity + $acl.SetAccessRuleProtection($true, $false) + foreach ($sid in @('S-1-5-18', 'S-1-5-32-544', $userSid.Value)) { + $rights = if ($sid -eq $userSid.Value) { 'Modify' } else { 'FullControl' } + $rule = New-Object Security.AccessControl.FileSystemAccessRule( + [Security.Principal.SecurityIdentifier]$sid, $rights, + 'ContainerInherit, ObjectInherit', 'None', 'Allow' + ) + $acl.AddAccessRule($rule) + } + Set-Acl -LiteralPath $directory -AclObject $acl + Copy-Item -LiteralPath $source -Destination (Join-Path $directory 'native-test.exe') + + # Windows PowerShell is available to the new user without depending on the CI user's PATH. + # This wrapper contains no credentials. Its output and atomic result file are the only IPC. + $wrapper = @' +param([Parameter(Mandatory)][string]$ExpectedSid) +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$exitCode = 1 +try { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + if ($identity.User.Value -ne $ExpectedSid) { throw 'The test did not start as the temporary user.' } + $principal = New-Object Security.Principal.WindowsPrincipal($identity) + if ($principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw 'The test process has administrator privileges.' + } + $profile = [Environment]::GetFolderPath('UserProfile') + $registeredProfile = (Get-ItemProperty -LiteralPath "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\$ExpectedSid").ProfileImagePath + if (-not $profile -or $profile -ne [Environment]::ExpandEnvironmentVariables($registeredProfile)) { + throw 'The temporary account profile was not loaded.' + } + if ($env:USERPROFILE -ne $profile -or + $env:APPDATA -ne [Environment]::GetFolderPath('ApplicationData') -or + $env:LOCALAPPDATA -ne [Environment]::GetFolderPath('LocalApplicationData')) { + throw 'The test inherited folders from a different user profile.' + } + @{ identity = $identity.Name; sid = $ExpectedSid; profile = $env:USERPROFILE; elevated = $false } | + ConvertTo-Json -Compress | Set-Content -LiteralPath (Join-Path $PSScriptRoot 'identity.log') + $test = Start-Process -FilePath (Join-Path $PSScriptRoot 'native-test.exe') ` + -ArgumentList @('--ignored', '--exact', '--nocapture', 'windows::tests::native_standard_user_setup_probe') ` + -WorkingDirectory $PSScriptRoot -NoNewWindow -Wait -PassThru ` + -RedirectStandardOutput (Join-Path $PSScriptRoot 'stdout.log') ` + -RedirectStandardError (Join-Path $PSScriptRoot 'stderr.log') + $exitCode = $test.ExitCode +} catch { + $_ | Out-String | Set-Content -LiteralPath (Join-Path $PSScriptRoot 'wrapper-error.log') +} finally { + @{ exitCode = $exitCode } | ConvertTo-Json -Compress | + Set-Content -LiteralPath (Join-Path $PSScriptRoot 'result.tmp') + Move-Item -LiteralPath (Join-Path $PSScriptRoot 'result.tmp') -Destination (Join-Path $PSScriptRoot 'result.json') +} +exit $exitCode +'@ + $wrapperPath = Join-Path $directory 'run.ps1' + Set-Content -LiteralPath $wrapperPath -Value $wrapper -Encoding UTF8 + $powershell = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' + # Run from the CI runner's administrator account, not LocalSystem. LoadUserProfile gives the + # child its own HKCU hive, which setup reads to find the user's WSL configuration. + # https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.management/start-process + $credential = New-Object Management.Automation.PSCredential("$env:COMPUTERNAME\$userName", $securePassword) + $process = Start-Process -FilePath $powershell -Credential $credential -LoadUserProfile ` + -WorkingDirectory $directory -WindowStyle Hidden -PassThru ` + -ArgumentList "-NoProfile -NonInteractive -ExecutionPolicy Bypass -File `"$wrapperPath`" -ExpectedSid $($userSid.Value)" + $password = $null + $securePassword.Dispose() + $securePassword = $null + [Array]::Clear($passwordBytes, 0, $passwordBytes.Length) + + if (-not $process.WaitForExit($TimeoutSeconds * 1000)) { + throw "Standard-user setup test timed out after $TimeoutSeconds seconds." + } + $resultPath = Join-Path $directory 'result.json' + if (-not (Test-Path -LiteralPath $resultPath)) { + throw "Standard-user wrapper exited without a result (exit code: $($process.ExitCode))." + } + $result = Get-Content -LiteralPath $resultPath -Raw | ConvertFrom-Json + if ($result.exitCode -ne 0) { + throw "Standard-user setup test failed with exit code $($result.exitCode)." + } + $stdout = Get-Content -LiteralPath (Join-Path $directory 'stdout.log') -Raw + if ($stdout -notmatch 'test result: ok\. 1 passed; 0 failed; 0 ignored;') { + throw 'The standard-user executable did not report exactly one passing native test.' + } + Write-Host 'Verified Windows setup detection under a real standard-user account.' +} finally { + $password = $null + if ($null -ne $securePassword) { $securePassword.Dispose() } + [Array]::Clear($passwordBytes, 0, $passwordBytes.Length) + $random.Dispose() + $cleanupErrors = @() + if ($null -ne $process -and -not $process.HasExited) { + try { + & "$env:SystemRoot\System32\taskkill.exe" /PID $process.Id /T /F | Out-Null + if ($LASTEXITCODE -ne 0 -and -not $process.HasExited) { throw 'Could not stop the native test process tree.' } + if (-not $process.WaitForExit(10000)) { throw 'The native test process did not stop.' } + } catch { $cleanupErrors += $_.Exception.Message } + } + if ($directoryCreated) { + foreach ($name in @('identity.log', 'stdout.log', 'stderr.log', 'wrapper-error.log')) { + $path = Join-Path $directory $name + if (Test-Path -LiteralPath $path) { Get-Content -LiteralPath $path } + } + } + if ($userCreated) { + try { + Get-CimInstance Win32_UserProfile -Filter "SID='$($userSid.Value)'" | Remove-CimInstance + } catch { $cleanupErrors += $_.Exception.Message } + try { Remove-LocalUser -SID $userSid } catch { $cleanupErrors += $_.Exception.Message } + } + if ($directoryCreated) { + try { Remove-Item -LiteralPath $directory -Recurse -Force } catch { $cleanupErrors += $_.Exception.Message } + } + if ($cleanupErrors.Count -gt 0) { + throw "Standard-user test cleanup failed: $($cleanupErrors -join '; ')" + } +} diff --git a/desktop/src-tauri/src/windows.rs b/desktop/src-tauri/src/windows.rs index 9aacc4f77..d3886f385 100644 --- a/desktop/src-tauri/src/windows.rs +++ b/desktop/src-tauri/src/windows.rs @@ -355,6 +355,37 @@ fn probe_bool(operation: &str, output: &str) -> Result { } } +/// Local WMI reads work in the signed-in user's token; the DISM cmdlet +/// Get-WindowsOptionalFeature requires elevation even when it only reads state. +fn optional_feature_probe_command(feature: &str) -> String { + format!( + "$ErrorActionPreference = 'Stop'; \ + $features = @(Get-CimInstance -ClassName Win32_OptionalFeature -Filter \"Name = '{feature}'\"); \ + if ($features.Count -ne 1 -or $null -eq $features[0].InstallState) {{ \ + throw '{feature} query did not return one feature state' \ + }}; \ + $features[0].InstallState" + ) +} + +fn probe_feature_enabled(operation: &str, output: &str) -> Result { + // https://learn.microsoft.com/en-us/windows/win32/cimwin32prov/win32-optionalfeature + // Unknown (4) is not evidence that a feature is disabled. + match output.trim() { + "1" => Ok(true), + "2" | "3" => Ok(false), + _ => Err(detection_failed( + operation, + format!("Expected InstallState 1 (enabled), 2 (disabled), or 3 (absent); probe returned: {output}"), + )), + } +} + +fn modern_wsl_probe_command() -> &'static str { + "$ErrorActionPreference = 'Stop'; \ + $null -ne (Get-CimInstance -ClassName Win32_Service -Filter \"Name = 'WslService'\")" +} + /// The native adapter above only supplies process execution and the legacy kernel-file check. /// Keeping the decision path shared lets failure tests run without touching Windows components. #[cfg_attr(not(target_os = "windows"), allow(dead_code))] @@ -363,7 +394,7 @@ fn blocker_with( kernel_file_exists: impl FnOnce() -> Result, ) -> Result, Problem> { // A running hypervisor is positive virtualization evidence even when firmware reports False. - // Stop converts CIM/DISM non-terminating errors into failed probes instead of partial answers. + // Stop converts CIM non-terminating errors into failed probes instead of partial answers. let virtualization = "Windows virtualization support (powershell)"; let reported = probe_text( virtualization, @@ -414,29 +445,66 @@ fn blocker_with( ]))?)?; let wsl_feature = "the WSL feature state (powershell)"; - let enabled = probe_bool(wsl_feature, &probe_text(wsl_feature, run("powershell", &[ - "-NoProfile", "-NonInteractive", "-Command", - "$ErrorActionPreference = 'Stop'; \ - $state = (Get-WindowsOptionalFeature -Online -FeatureName Microsoft-Windows-Subsystem-Linux).State; \ - if ($null -eq $state) { throw 'WSL feature query returned no state' }; \ - $state -eq 'Enabled'", - ]))?)?; + let enabled = probe_feature_enabled( + wsl_feature, + &probe_text( + wsl_feature, + run( + "powershell", + &[ + "-NoProfile", + "-NonInteractive", + "-Command", + &optional_feature_probe_command("Microsoft-Windows-Subsystem-Linux"), + ], + ), + )?, + )?; if !enabled { - return Ok(Some(if elevated { - Blocker::WslAbsent - } else { - Blocker::NotAdministrator - })); + // Modern WSL2 uses WslService and does not require the legacy WSL1 optional component. + // https://learn.microsoft.com/en-us/windows/wsl/faq#was-lxssmanager-replaced-by-wslservice + // Service presence only establishes installation; VMP and kernel health are checked below. + let modern_wsl = "the WSL service (powershell)"; + let installed = probe_bool( + modern_wsl, + &probe_text( + modern_wsl, + run( + "powershell", + &[ + "-NoProfile", + "-NonInteractive", + "-Command", + modern_wsl_probe_command(), + ], + ), + )?, + )?; + if !installed { + return Ok(Some(if elevated { + Blocker::WslAbsent + } else { + Blocker::NotAdministrator + })); + } } let vmp_feature = "the Virtual Machine Platform feature state (powershell)"; - let enabled = probe_bool(vmp_feature, &probe_text(vmp_feature, run("powershell", &[ - "-NoProfile", "-NonInteractive", "-Command", - "$ErrorActionPreference = 'Stop'; \ - $state = (Get-WindowsOptionalFeature -Online -FeatureName VirtualMachinePlatform).State; \ - if ($null -eq $state) { throw 'Virtual Machine Platform feature query returned no state' }; \ - $state -eq 'Enabled'", - ]))?)?; + let enabled = probe_feature_enabled( + vmp_feature, + &probe_text( + vmp_feature, + run( + "powershell", + &[ + "-NoProfile", + "-NonInteractive", + "-Command", + &optional_feature_probe_command("VirtualMachinePlatform"), + ], + ), + )?, + )?; if !enabled { return Ok(Some(if elevated { Blocker::VirtualMachinePlatformDisabled @@ -493,8 +561,8 @@ mod tests { const PROBE_OUTPUTS: [&str; 6] = [ "hypervisor=True\nfirmware=False\n", "True\n", - "True\n", - "True\n", + "1\n", + "1\n", "2\n", "WSL version: 2.7.13.0\nKernel version: 6.18.33.2-2\n", ]; @@ -507,11 +575,14 @@ mod tests { match probe { 0 => assert!(args[3].contains("Get-CimInstance Win32_ComputerSystem")), 1 => assert!(args[3].contains("WindowsBuiltInRole]::Administrator")), - 2 => assert!(args[3].contains("-FeatureName Microsoft-Windows-Subsystem-Linux")), - 3 => assert_eq!(args[3], "$ErrorActionPreference = 'Stop'; \ - $state = (Get-WindowsOptionalFeature -Online -FeatureName VirtualMachinePlatform).State; \ - if ($null -eq $state) { throw 'Virtual Machine Platform feature query returned no state' }; \ - $state -eq 'Enabled'"), + 2 => assert_eq!( + args[3], + optional_feature_probe_command("Microsoft-Windows-Subsystem-Linux") + ), + 3 => assert_eq!( + args[3], + optional_feature_probe_command("VirtualMachinePlatform") + ), 4 => assert_eq!(args[3], default_wsl_version_probe_command()), _ => unreachable!(), } @@ -537,6 +608,191 @@ mod tests { } } + #[test] + fn standard_user_with_working_wsl_does_not_need_elevated_feature_queries() { + let result = blocker_with( + |program, args| { + let command = args.join(" "); + let stdout = if command.contains("Get-WindowsOptionalFeature") { + return Ok(probe_output( + 1, + "", + "Get-WindowsOptionalFeature : The requested operation requires elevation.", + )); + } else if command.contains("Win32_ComputerSystem") { + PROBE_OUTPUTS[0] + } else if command.contains("WindowsBuiltInRole]::Administrator") { + "False" + } else if command.contains("Win32_OptionalFeature") { + "1" + } else if program == "powershell" && args[3] == default_wsl_version_probe_command() + { + "2" + } else if program == "wsl.exe" && args == ["--version"] { + PROBE_OUTPUTS[5] + } else { + panic!("unexpected standard-user probe: {program} {args:?}"); + }; + Ok(probe_output(0, stdout, "")) + }, + || Ok(false), + ); + assert_eq!(result, Ok(None)); + } + + fn probe_with_modern_wsl_service( + outputs: [&str; 6], + service: std::io::Result, + ) -> Result, Problem> { + let mut service = Some(service); + let mut probe = 0; + let result = blocker_with( + |program, args| { + if program == "powershell" && args[3] == modern_wsl_probe_command() { + return service.take().expect("queried the WSL service twice"); + } + assert_probe_call(probe, program, args); + let output = probe_output(0, outputs[probe], ""); + probe += 1; + Ok(output) + }, + || Ok(false), + ); + assert!(service.is_none(), "did not check modern WSL installation"); + result + } + + #[test] + fn modern_wsl_does_not_require_the_legacy_component_but_still_needs_vmp_and_a_kernel() { + for legacy in ["2", "3"] { + for (vmp, version, expected) in [ + ("1", PROBE_OUTPUTS[5], None), + ("2", PROBE_OUTPUTS[5], Some(Blocker::NotAdministrator)), + ("3", PROBE_OUTPUTS[5], Some(Blocker::NotAdministrator)), + ("1", "WSL version: 2", Some(Blocker::WslNoKernel)), + ] { + let mut outputs = PROBE_OUTPUTS; + outputs[1] = "False"; + outputs[2] = legacy; + outputs[3] = vmp; + outputs[5] = version; + assert_eq!( + probe_with_modern_wsl_service(outputs, Ok(probe_output(0, "True", ""))), + Ok(expected), + ); + } + } + } + + #[test] + fn absent_modern_and_legacy_wsl_returns_install_guidance_for_standard_users() { + for legacy in ["2", "3"] { + let mut outputs = PROBE_OUTPUTS; + outputs[1] = "False"; + outputs[2] = legacy; + assert_eq!( + probe_with_modern_wsl_service(outputs, Ok(probe_output(0, "False", ""))), + Ok(Some(Blocker::NotAdministrator)), + ); + } + } + + #[test] + fn unknown_or_malformed_feature_states_are_detection_errors() { + for probe in [2, 3] { + for state in ["4", "0", "-1", "Enabled", "1\n2", ""] { + let error = fail_probe_at(probe, Ok(probe_output(0, state, ""))).unwrap_err(); + assert!(error.said.contains("feature state")); + assert!(error.detail.unwrap().contains(state)); + } + } + } + + #[test] + fn modern_wsl_service_query_failures_are_not_missing_prerequisites() { + let mut outputs = PROBE_OUTPUTS; + outputs[1] = "False"; + outputs[2] = "2"; + for (failure, diagnostic) in [ + ( + Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "service launch denied", + )), + "service launch denied", + ), + ( + Ok(probe_output(1, "False", "service query denied")), + "service query denied", + ), + ( + Ok(probe_output(0, "", "service returned no answer")), + "service returned no answer", + ), + (Ok(probe_output(0, "unknown", "")), "unknown"), + ] { + let error = probe_with_modern_wsl_service(outputs, failure).unwrap_err(); + assert!(error.said.contains("the WSL service")); + assert!(error.detail.unwrap().contains(diagnostic)); + } + } + + /// Run explicitly under a normal Windows account. Read both features before blocker() so a + /// missing hypervisor cannot make this privilege-boundary check pass without querying them. + #[cfg(target_os = "windows")] + #[test] + #[ignore = "requires a real Windows standard-user session"] + fn native_standard_user_setup_probe() { + let run = |command: &str| { + crate::quiet::command("powershell") + .args(["-NoProfile", "-NonInteractive", "-Command", command]) + .output() + }; + let elevated = probe_bool( + "native probe identity", + &probe_text("native probe identity", run( + "$ErrorActionPreference = 'Stop'; \ + ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)", + )).unwrap(), + ).unwrap(); + assert!(!elevated, "run this probe without administrator elevation"); + println!( + "native setup identity: {}", + serde_json::json!({"elevated": elevated}) + ); + for feature in [ + "Microsoft-Windows-Subsystem-Linux", + "VirtualMachinePlatform", + ] { + let state = probe_text(feature, run(&optional_feature_probe_command(feature))).unwrap(); + let enabled = probe_feature_enabled(feature, &state).unwrap(); + println!( + "native setup feature: {}", + serde_json::json!({ + "feature": feature, "installState": state, "enabled": enabled, + }) + ); + } + let modern_wsl = probe_bool( + "native WSL service probe", + &probe_text("native WSL service probe", run(modern_wsl_probe_command())).unwrap(), + ) + .unwrap(); + println!( + "native setup service: {}", + serde_json::json!({"modernWslInstalled": modern_wsl}) + ); + let result = blocker(); + println!( + "native setup result: {}", + serde_json::to_string(&result).unwrap() + ); + assert!( + result.is_ok(), + "normal-user setup detection failed: {result:?}" + ); + } + #[derive(Deserialize)] struct ComponentVersionFormats { formats: Vec, @@ -711,11 +967,11 @@ mod tests { } else if args .iter() .any(|arg| arg.contains("VirtualMachinePlatform")) - { - "False" - } else if program == "powershell" && args[3] == default_wsl_version_probe_command() + || (program == "powershell" && args[3] == default_wsl_version_probe_command()) { "2" + } else if args.iter().any(|arg| arg.contains("Win32_OptionalFeature")) { + "1" } else if program == "powershell" { "True" } else { @@ -825,10 +1081,7 @@ mod tests { assert_probe_call(probe, program, args); let mut output = probe_output(0, PROBE_OUTPUTS[probe], ""); if probe == 3 { - output.stdout = "True\r\n" - .encode_utf16() - .flat_map(u16::to_le_bytes) - .collect(); + output.stdout = "1\r\n".encode_utf16().flat_map(u16::to_le_bytes).collect(); } probe += 1; Ok(output) @@ -935,8 +1188,8 @@ mod tests { [ PROBE_OUTPUTS[0], "True", - "False", - "True", + "2", + "1", PROBE_OUTPUTS[4], PROBE_OUTPUTS[5], ], @@ -946,30 +1199,23 @@ mod tests { [ PROBE_OUTPUTS[0], "False", - "False", - "True", + "2", + "1", PROBE_OUTPUTS[4], PROBE_OUTPUTS[5], ], Some(Blocker::NotAdministrator), ), ( - [ - PROBE_OUTPUTS[0], - "True", - "True", - "True", - "1", - PROBE_OUTPUTS[5], - ], + [PROBE_OUTPUTS[0], "True", "1", "1", "1", PROBE_OUTPUTS[5]], Some(Blocker::WslOne), ), ( [ PROBE_OUTPUTS[0], "True", - "True", - "True", + "1", + "1", PROBE_OUTPUTS[4], "WSL version: 2", ], @@ -980,6 +1226,9 @@ mod tests { let mut probe = 0; let result = blocker_with( |program, args| { + if program == "powershell" && args[3] == modern_wsl_probe_command() { + return Ok(probe_output(0, "False", "")); + } assert_probe_call(probe, program, args); let output = probe_output(0, outputs[probe], ""); probe += 1; @@ -1092,37 +1341,37 @@ mod tests { ( "vmp-disabled-admin", "True", - "True", - "False", + "1", + "2", Some(Blocker::VirtualMachinePlatformDisabled), 4, ), ( "vmp-disabled-standard", "False", - "True", - "False", + "1", + "2", Some(Blocker::NotAdministrator), 4, ), ( "wsl-absent-admin", "True", - "False", - "True", + "2", + "1", Some(Blocker::WslAbsent), - 3, + 4, ), ( "wsl-absent-standard", "False", - "False", - "True", + "2", + "1", Some(Blocker::NotAdministrator), - 3, + 4, ), - ("healthy-admin", "True", "True", "True", None, 6), - ("healthy-standard", "False", "True", "True", None, 6), + ("healthy-admin", "True", "1", "1", None, 6), + ("healthy-standard", "False", "1", "1", None, 6), ] { let mut outputs = PROBE_OUTPUTS; outputs[1] = elevated; @@ -1133,6 +1382,10 @@ mod tests { let result = blocker_with( |program, args| { let probe = calls.len(); + if program == "powershell" && args[3] == modern_wsl_probe_command() { + calls.push(serde_json::json!({ "program": program, "args": args })); + return child_probe_output("0", "False", ""); + } assert_probe_call(probe, program, args); calls.push(serde_json::json!({ "program": program, "args": args })); stages.borrow_mut().push(probe); From 31fb7337f3f3541e6e7c0715c11a24cd794c15ef Mon Sep 17 00:00:00 2001 From: David McKay Date: Tue, 15 Sep 2026 09:15:54 -0700 Subject: [PATCH 2/5] fix(ci): use Windows PowerShell for standard-user launch --- .github/workflows/desktop.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml index 11c085ba4..910ca05a6 100644 --- a/.github/workflows/desktop.yml +++ b/.github/workflows/desktop.yml @@ -147,7 +147,11 @@ jobs: $_.profile.test -and $_.executable } | ForEach-Object { $_.executable } | Select-Object -Unique) if ($executables.Count -ne 1) { throw 'Expected exactly one current desktop library test executable.' } - & ../scripts/test-windows-standard-user.ps1 -TestExecutable $executables[0] + # Windows PowerShell's credentialed launch matches the desktop and native validation. + # PowerShell Core inherited the CI runner's profile directories into the temporary user. + $windowsPowerShell = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' + & $windowsPowerShell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File ../scripts/test-windows-standard-user.ps1 -TestExecutable $executables[0] + if ($LASTEXITCODE -ne 0) { throw 'The native standard-user setup test failed.' } # Keep what was built. Without this the only way to try an installer is to build one on # the machine you are trying it on, which is not what anybody installs. - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 From 84cef7dac0b7d7b6ebd72aaa3e5c98e40aef7010 Mon Sep 17 00:00:00 2001 From: David McKay Date: Tue, 15 Sep 2026 09:24:31 -0700 Subject: [PATCH 3/5] fix(desktop): explain Windows installer policy rejection --- desktop/src-tauri/src/install.rs | 42 +++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/desktop/src-tauri/src/install.rs b/desktop/src-tauri/src/install.rs index 17a18594d..7aa710779 100644 --- a/desktop/src-tauri/src/install.rs +++ b/desktop/src-tauri/src/install.rs @@ -363,7 +363,15 @@ fn installer_stopped(verb: &str, msi: &Path, failure: MsiexecFailure, log: &Path ), ), MsiexecFailure::Exit(code) => Problem::with( - "Installing the software OpenBot needs did not finish. Try again.", + if code == 1625 { + // ERROR_INSTALL_PACKAGE_REJECTED requires an administrator to resolve policy. + // https://learn.microsoft.com/en-us/windows/win32/msi/error-codes + "Windows policy blocks installing Podman, the container engine OpenBot needs. \ + Ask an administrator to allow the installation or install Podman for your \ + account, then try again." + } else { + "Installing the software OpenBot needs did not finish. Try again." + }, format!( "msiexec {verb} {} stopped with exit code {code}; its log is at {}", msi.display(), @@ -663,6 +671,38 @@ mod tests { let _ = std::fs::remove_dir_all(msi.parent().unwrap()); } + #[test] + fn windows_msi_policy_rejection_needs_administrator_action_before_retry() { + let (msi, log) = synthetic_msi_paths("msi-policy-rejected"); + let mut calls = Vec::new(); + let result = install_podman_msi(&msi, &log, |verb, _msi, _log| { + calls.push(verb[0].to_string()); + Err(MsiexecFailure::Exit(1625)) + }); + + let problem = result.expect_err("policy rejection must be reported"); + assert_eq!( + calls, + ["/i"], + "policy rejection must not trigger another attempt" + ); + assert_eq!( + problem.said, + "Windows policy blocks installing Podman, the container engine \ + OpenBot needs. Ask an administrator to allow the installation or install Podman for \ + your account, then try again." + ); + assert_eq!( + problem.detail, + Some(format!( + "msiexec /i {} stopped with exit code 1625; its log is at {}", + msi.display(), + log.display(), + )), + ); + let _ = std::fs::remove_dir_all(msi.parent().unwrap()); + } + #[test] #[cfg(unix)] fn windows_msi_recovery_uses_actual_child_process_boundary() { From a03a634d2a54c3f52f448ad2a2016c310278fabc Mon Sep 17 00:00:00 2001 From: David McKay Date: Tue, 15 Sep 2026 09:39:11 -0700 Subject: [PATCH 4/5] fix(desktop): recognize stopped default Podman machine --- desktop/src-tauri/src/acquire.rs | 90 +++++++++++++++++++++++++++++--- 1 file changed, 83 insertions(+), 7 deletions(-) diff --git a/desktop/src-tauri/src/acquire.rs b/desktop/src-tauri/src/acquire.rs index 47e2f1337..9bcbb94b4 100644 --- a/desktop/src-tauri/src/acquire.rs +++ b/desktop/src-tauri/src/acquire.rs @@ -123,13 +123,24 @@ fn command_failure(binary: &str, args: &[&str], output: &std::process::Output) - } fn machine_exists_with(run: impl FnOnce() -> Result) -> Result { - let names = run()?; - Ok(names.lines().any(|name| name.trim() == MACHINE)) + #[derive(Deserialize)] + struct ListedMachine { + #[serde(rename = "Name")] + name: String, + } + + let listing = run()?; + let machines: Vec = serde_json::from_str(&listing).map_err(|error| { + format!("could not read podman machine list JSON: {error}; stdout: {listing}") + })?; + Ok(machines.iter().any(|machine| machine.name == MACHINE)) } /// Does this app's machine already exist? pub fn machine_exists() -> Result { - machine_exists_with(|| podman(&["machine", "list", "--quiet"])) + // --quiet still uses Podman's human format, which appends '*' to the default machine's name. + // JSON preserves the raw Name, independently of whether the machine is running or default. + machine_exists_with(|| podman(&["machine", "list", "--format", "json"])) } /// Create the machine. @@ -537,9 +548,41 @@ mod tests { } #[test] - fn machine_existence_uses_the_quiet_machine_list_names() { - assert!(machine_exists_with(|| Ok("default\nopenbot\n".into())).unwrap()); - assert!(!machine_exists_with(|| Ok("default\nopenbot-old\n".into())).unwrap()); + fn machine_existence_uses_exact_json_names() { + for (listing, expected) in [ + (r#"[{"Name":"default"},{"Name":"openbot"}]"#, true), + (r#"[{"Name":"default"},{"Name":"openbot-old"}]"#, false), + ("[]", false), + ] { + assert_eq!( + machine_exists_with(|| Ok(listing.into())).unwrap(), + expected + ); + } + } + + #[test] + fn existing_stopped_default_machine_is_not_initialized_again() { + // Podman 6.1.1 reports this stopped default machine as `openbot*` in --quiet output. + // JSON keeps its raw name and reports default/running state as separate fields. + let listing = r#"[{"Name":"openbot","Default":true,"Running":false,"VMType":"wsl"}]"#; + let mut init_called = false; + let result = create_machine_with( + 2, + 4096, + 20, + || machine_exists_with(|| Ok(listing.into())), + |_args| { + init_called = true; + Err("machine openbot already exists".into()) + }, + ); + assert!( + !init_called, + "existing stopped default machine was initialized again" + ); + assert!(result.ok, "{result:?}"); + assert_eq!(result.said, "openbot already exists."); } #[test] @@ -549,7 +592,11 @@ mod tests { 2, 4096, 20, - || Err("podman machine list exited with status 125; stdout: denied".into()), + || { + machine_exists_with(|| { + Err("podman machine list exited with status 125; stdout: denied".into()) + }) + }, |_args| { init_called = true; Ok(String::new()) @@ -568,6 +615,35 @@ mod tests { ); } + #[test] + fn malformed_machine_list_stops_create_and_keeps_the_response() { + for listing in [ + "openbot*", + "", + "{}", + "null", + r#"[{"Name":null}]"#, + r#"[{"Running":false}]"#, + ] { + let result = create_machine_with( + 2, + 4096, + 20, + || machine_exists_with(|| Ok(listing.into())), + |_args| panic!("machine init must not run after malformed list output"), + ); + assert!(!result.ok, "{result:?}"); + let detail = result + .detail + .expect("malformed listing needs diagnostic detail"); + assert!( + detail.contains("could not read podman machine list JSON"), + "{detail}" + ); + assert!(detail.ends_with(&format!("stdout: {listing}")), "{detail}"); + } + } + #[test] fn absent_machine_creates_with_requested_resources() { let mut captured = Vec::new(); From e4c5062b2ba073d71b8fea05946338fdcde4d244 Mon Sep 17 00:00:00 2001 From: David McKay Date: Tue, 15 Sep 2026 10:11:51 -0700 Subject: [PATCH 5/5] fix(desktop): provision Bun for fresh Windows accounts --- desktop/src-tauri/src/install.rs | 278 ++++++++++++++++++++++++++++++- desktop/src-tauri/src/main.rs | 21 ++- 2 files changed, 287 insertions(+), 12 deletions(-) diff --git a/desktop/src-tauri/src/install.rs b/desktop/src-tauri/src/install.rs index 7aa710779..7e1f3336c 100644 --- a/desktop/src-tauri/src/install.rs +++ b/desktop/src-tauri/src/install.rs @@ -43,6 +43,8 @@ use crate::problem::Problem; /// there, a digest is what was run. Moving these means re-recording the digests below. pub const PODMAN: &str = "6.1.1"; pub const COMPOSE: &str = "5.5.1"; +/// Keep aligned with the repository's packageManager and container runtime. +pub const BUN: &str = "1.3.14"; /// A file to fetch and the digest it has to have. #[derive(Debug, PartialEq, Eq)] @@ -202,6 +204,154 @@ fn digest_of(bytes: &[u8]) -> String { .collect() } +/// A fresh Windows account has no developer tools. Acquire the host runtime as that user, +/// without changing PATH or requiring an administrator. Existing installations remain usable. +pub fn ensure_bun(cache: &Path, existing: Option) -> Result { + ensure_bun_with(existing, || install_bun(cache)) +} + +fn ensure_bun_with( + existing: Option, + install: impl FnOnce() -> Result, +) -> Result { + match existing { + Some(path) => Ok(path), + None => install(), + } +} + +#[cfg(any(windows, test))] +fn bun_download(arch: &str) -> Result { + // Official bun-v1.3.14/SHASUMS256.txt. The baseline x64 build also supports older CPUs. + let (file, sha256) = match arch { + "x86_64" => ( + "bun-windows-x64-baseline.zip", + "538f9c846355d9e847b2671bc00c47da4229a0befb24df3282b739770f3b475f", + ), + "aarch64" => ( + "bun-windows-aarch64.zip", + "89841f5a57f2348b67ec0839b718f4bf4ea7d07c371c9ba4b77b6c790f918953", + ), + _ => { + return Err(Problem::with( + "OpenBot cannot install its app runtime on this kind of computer.", + format!("no Bun {BUN} Windows build for {arch}"), + )) + } + }; + Ok(Download { + url: format!("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/oven-sh/bun/releases/download/bun-v{BUN}/{file}"), + sha256, + file, + }) +} + +#[cfg(windows)] +fn install_bun(cache: &Path) -> Result { + let download = bun_download(std::env::consts::ARCH)?; + let entry = format!("{}/bun.exe", download.file.trim_end_matches(".zip")); + let into = crate::acquire::download_dir(cache).join(format!("bun-{BUN}")); + install_bun_with( + &into, + &download, + |archive, target| extract_bun(archive, target, &entry), + verify_bun, + ) +} + +#[cfg(not(windows))] +fn install_bun(_cache: &Path) -> Result { + Err(Problem::plain( + "bun was not found, so the API server cannot be started", + )) +} + +#[cfg(any(windows, test))] +fn install_bun_with( + into: &Path, + download: &Download, + extract: impl FnOnce(&Path, &Path) -> Result<(), Problem>, + verify: impl Fn(&Path) -> Result<(), Problem>, +) -> Result { + let binary = into.join("bun.exe"); + if binary.is_file() { + verify(&binary)?; + return Ok(binary); + } + let archive = fetch_verified(download, into)?; + let staged = into.join("bun.download.exe"); + extract(&archive, &staged)?; + verify(&staged)?; + std::fs::rename(&staged, &binary).map_err(|error| unwritable(&binary, &error.to_string()))?; + Ok(binary) +} + +#[cfg(windows)] +fn extract_bun(archive: &Path, target: &Path, entry: &str) -> Result<(), Problem> { + // Extract only the expected executable. Paths travel as environment values, never as + // PowerShell source, so spaces and quotes in a profile directory cannot change the command. + let script = r#"$ErrorActionPreference = 'Stop' +Add-Type -AssemblyName System.IO.Compression.FileSystem +$zip = [IO.Compression.ZipFile]::OpenRead($env:OPENBOT_BUN_ARCHIVE) +try { + $entry = $zip.GetEntry($env:OPENBOT_BUN_ENTRY) + if ($null -eq $entry) { throw 'The Bun archive does not contain the expected executable.' } + [IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $env:OPENBOT_BUN_TARGET, $true) +} finally { + $zip.Dispose() +}"#; + let output = crate::quiet::command("powershell") + .args(["-NoProfile", "-NonInteractive", "-Command", script]) + .env("OPENBOT_BUN_ARCHIVE", archive) + .env("OPENBOT_BUN_TARGET", target) + .env("OPENBOT_BUN_ENTRY", entry) + .output() + .map_err(|error| { + Problem::with( + "OpenBot could not unpack its app runtime. Try again.", + error.to_string(), + ) + })?; + if !output.status.success() { + return Err(Problem::with( + "OpenBot could not unpack its app runtime. Try again.", + format!( + "PowerShell {}: {}{}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ), + )); + } + Ok(()) +} + +#[cfg(windows)] +fn verify_bun(binary: &Path) -> Result<(), Problem> { + let output = crate::quiet::command(binary) + .arg("--version") + .output() + .map_err(|error| { + Problem::with( + "OpenBot could not start its app runtime.", + format!("{}: {error}", binary.display()), + ) + })?; + if !output.status.success() || String::from_utf8_lossy(&output.stdout).trim() != BUN { + return Err(Problem::with( + "OpenBot could not verify its app runtime.", + format!( + "{} --version: {}; expected {BUN}; stdout: {}; stderr: {}", + binary.display(), + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ), + )); + } + Ok(()) +} + /// Put the engine on this machine, and a Compose it can run. /// /// Both halves, in that order, because the second is invisible until the first has succeeded and @@ -503,9 +653,14 @@ mod tests { fn every_pinned_digest_is_a_lowercase_sha256() { // The table is written by hand from each release's own checksums, and a digest with a typo // in it fails on somebody else's machine at install time rather than here. - for download in [compose_download(), podman_download()] - .into_iter() - .flatten() + for download in [ + compose_download(), + podman_download(), + bun_download("x86_64"), + bun_download("aarch64"), + ] + .into_iter() + .flatten() { assert_eq!(download.sha256.len(), 64, "{download:?}"); assert!( @@ -566,6 +721,123 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + fn cached_bun_archive(name: &str) -> (PathBuf, Download) { + let dir = temp_root(name); + std::fs::create_dir_all(&dir).unwrap(); + let download = Download { + url: "http://127.0.0.1:1/never-reached".into(), + sha256: "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + file: "bun.zip", + }; + std::fs::write(dir.join(download.file), b"abc").unwrap(); + (dir, download) + } + + #[test] + fn a_fresh_account_acquires_bun_before_returning_its_executable() { + let (dir, download) = cached_bun_archive("bun-fresh-account"); + let binary = ensure_bun_with(None, || { + install_bun_with( + &dir, + &download, + |archive, target| { + assert_eq!(std::fs::read(archive).unwrap(), b"abc"); + assert!(!dir.join("bun.exe").exists()); + std::fs::write(target, b"executable").unwrap(); + Ok(()) + }, + |target| { + assert_eq!(std::fs::read(target).unwrap(), b"executable"); + assert!(!dir.join("bun.exe").exists(), "verify before publishing"); + Ok(()) + }, + ) + }) + .unwrap(); + assert_eq!(binary, dir.join("bun.exe")); + assert_eq!(std::fs::read(binary).unwrap(), b"executable"); + assert!(!dir.join("bun.download.exe").exists()); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn an_existing_bun_does_not_trigger_installation() { + let existing = PathBuf::from("existing user runtime/bun.exe"); + assert_eq!( + ensure_bun_with(Some(existing.clone()), || panic!("already installed")).unwrap(), + existing + ); + } + + #[test] + fn an_acquired_bun_is_checked_and_reused_without_extracting_again() { + let (dir, download) = cached_bun_archive("bun-reuse"); + let binary = dir.join("bun.exe"); + std::fs::write(&binary, b"installed").unwrap(); + std::fs::remove_file(dir.join(download.file)).unwrap(); + let result = install_bun_with( + &dir, + &download, + |_, _| panic!("must not replace the installed runtime"), + |target| { + assert_eq!(target, binary); + Ok(()) + }, + ); + assert_eq!(result.unwrap(), binary); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn bun_extraction_failure_keeps_the_diagnostic_and_publishes_nothing() { + let (dir, download) = cached_bun_archive("bun-extraction-failure"); + let failure = Problem::with("could not unpack", "PowerShell exit 1: access denied"); + let result = install_bun_with( + &dir, + &download, + |_, _| Err(failure.clone()), + |_| panic!("failed extraction must not be executed"), + ); + assert_eq!(result, Err(failure)); + assert!(!dir.join("bun.exe").exists()); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn bun_that_cannot_run_is_not_published_and_its_failure_is_preserved() { + let (dir, download) = cached_bun_archive("bun-probe-failure"); + let failure = Problem::with("could not start", "bun --version exited 1"); + let result = ensure_bun_with(None, || { + install_bun_with( + &dir, + &download, + |_, target| { + std::fs::write(target, b"broken executable").unwrap(); + Ok(()) + }, + |_| Err(failure.clone()), + ) + }); + assert_eq!(result, Err(failure)); + assert!(!dir.join("bun.exe").exists()); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn an_unverified_bun_archive_is_never_extracted_or_run() { + let (dir, download) = cached_bun_archive("bun-wrong-digest"); + std::fs::write(dir.join(download.file), b"corrupt archive").unwrap(); + let result = install_bun_with( + &dir, + &download, + |_, _| panic!("an unverified archive must not be extracted"), + |_| panic!("an unverified runtime must not be run"), + ); + assert!(result.unwrap_err().detail.is_some()); + assert!(!dir.join("bun.exe").exists()); + let _ = std::fs::remove_dir_all(dir); + } + /// Podman publishes an `arm64` package only, so an Intel Mac has to be told rather than handed /// a package that cannot run. The message names what to do instead. #[test] diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index e6a2bdddb..ccfa7949e 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -882,7 +882,7 @@ async fn start_stack_inner( ), })?; - let (logs, bun, mut secrets) = { + let (logs, existing_bun, mut secrets) = { let _startup = attempt.lock_current()?; // Belt and braces: a fetch that reported success and left something out is still not a @@ -1087,7 +1087,7 @@ async fn start_stack_inner( } let logs = root.join(".logs"); - let bun = which_bun().ok_or("bun was not found, so the API server cannot be started")?; + let bun = which_bun(); (logs, bun, secrets) }; @@ -1095,14 +1095,17 @@ async fn start_stack_inner( // The source alone will not run: without this the server stops at a package it cannot resolve // and the app at a missing `vite`, neither of which mentions dependencies. report(&app, "dependencies", true, "installing"); - { + let bun = { let target = root.clone(); - let bun = bun.clone(); - tauri::async_runtime::spawn_blocking(move || stack::install_dependencies(&target, &bun)) - .await - .map_err(|error| format!("the install did not run: {error}"))? - .inspect_err(|error| report(&app, "dependencies", false, error.clone()))?; - } + tauri::async_runtime::spawn_blocking(move || { + let bun = install::ensure_bun(&target, existing_bun)?; + stack::install_dependencies(&target, &bun).map_err(Problem::plain)?; + Ok::<_, Problem>(bun) + }) + .await + .map_err(|error| format!("the install did not run: {error}"))? + .inspect_err(|error| report(&app, "dependencies", false, problem_detail(error.clone())))? + }; report(&app, "dependencies", true, "installed"); // Never persisted or passed to Compose. Only the server process receives this credential;