diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 352cc647b9a4..0a98ad6e457b 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -412,6 +412,36 @@ function Install-AgentBrowser { # Dependency checks # ============================================================================ +# Resolve the PowerShell host executable used to spawn child PowerShell +# processes (the astral uv installer below). We must NOT hardcode the bare +# name `powershell`: it names *Windows PowerShell* and only resolves when its +# System32 directory is on PATH. When install.ps1 is run under PowerShell 7+ +# (`pwsh`) -- or any session where `powershell` isn't on PATH -- a bare +# `powershell` spawn dies with "The term 'powershell' is not recognized", +# aborting uv installation (field report: Windows install stuck, uv install +# failed with exactly that message). Prefer the absolute path of the host we +# are already running in (PATH-independent), then fall back to whichever of +# powershell/pwsh is resolvable, and only then to the bare name. +function Get-PowerShellHostExe { + try { + $hostExe = (Get-Process -Id $PID).Path + if ($hostExe -and (Test-Path $hostExe)) { + $leaf = Split-Path $hostExe -Leaf + # Only trust the current host when it is a real PowerShell CLI + # (not e.g. powershell_ise.exe or an embedded host that can't take + # `-ExecutionPolicy`/`-Command`). + if ($leaf -match '^(?i:powershell|pwsh)\.exe$') { return $hostExe } + } + } catch { } + foreach ($candidate in @("powershell", "pwsh")) { + $cmd = Get-Command $candidate -CommandType Application -ErrorAction SilentlyContinue | + Select-Object -First 1 + if ($cmd -and $cmd.Source) { return $cmd.Source } + } + # Last-ditch: hand back the bare name so the spawn surfaces its own error. + return "powershell" +} + function Install-Uv { # Hermes owns its own uv at $HermesHome\bin\uv.exe. Always install there -- # no PATH probing, no conda guards, no multi-location resolution chains. @@ -427,72 +457,20 @@ function Install-Uv { } Write-Info "Installing managed uv into $HermesHome\bin ..." - $binDir = Join-Path $HermesHome "bin" - New-Item -ItemType Directory -Path $binDir -Force | Out-Null - - # Download the uv binary zip directly from GitHub releases instead of - # running the astral installer script (`irm https://astral.sh/uv/install.ps1 | iex`). - # The astral installer calls Get-ExecutionPolicy internally (from the - # Microsoft.PowerShell.Security module), and on some Windows installs that - # module fails to load -- killing the installer before it can download - # anything (field report: "The 'Get-ExecutionPolicy' command was found in - # the module 'Microsoft.PowerShell.Security', but the module could not be - # loaded"). Downloading the zip ourselves sidesteps the broken module - # entirely: no child powershell spawn, no execution-policy check, no - # script parsing. The astral installer is just a wrapper around this zip - # anyway. - $arch = Get-WindowsArch - $targetTriple = switch ($arch) { - "x64" { "x86_64-pc-windows-msvc" } - "arm64" { "aarch64-pc-windows-msvc" } - "x86" { "i686-pc-windows-msvc" } - default { throw "Unsupported Windows architecture for uv: $arch" } - } - $zipName = "uv-$targetTriple.zip" - # The /latest/download/ URL always serves the most recent release zip. - $downloadUrls = @( - "https://github.com/astral-sh/uv/releases/latest/download/$zipName", - "https://releases.astral.sh/github/uv/releases/latest/download/$zipName" - ) - - $tempZip = [System.IO.Path]::GetTempFileName() + ".zip" - $tempExtract = Join-Path ([System.IO.Path]::GetTempPath()) ([System.IO.Path]::GetRandomFileName()) - $downloaded = $false - - foreach ($url in $downloadUrls) { - try { - Write-Info "Downloading uv from $url ..." - Invoke-WebRequest -Uri $url -OutFile $tempZip -UseBasicParsing -ErrorAction Stop - $downloaded = $true - break - } catch { - Write-Warn "Download failed from $url : $_" - } - } - - if (-not $downloaded) { - Write-Err "Failed to download uv from all mirrors" - Write-Info "Install manually: https://docs.astral.sh/uv/getting-started/installation/" - Remove-Item $tempZip -Force -ErrorAction SilentlyContinue - return $false - } + New-Item -ItemType Directory -Path (Join-Path $HermesHome "bin") -Force | Out-Null + # UV_INSTALL_DIR tells the astral installer to place the binary + # directly into $HermesHome\bin instead of ~/.local/bin. + $prevEAP = $ErrorActionPreference try { - New-Item -ItemType Directory -Path $tempExtract -Force | Out-Null - Expand-Archive -Path $tempZip -DestinationPath $tempExtract -Force -ErrorAction Stop - - # The zip contains uv.exe (and uvx.exe) either at the root or inside - # a subdirectory. Find and move them to the managed bin dir. - $executables = Get-ChildItem -Path $tempExtract -Recurse -Filter "*.exe" -ErrorAction SilentlyContinue - if (-not $executables) { - Write-Err "uv zip did not contain any executables" - Write-Info "Install manually: https://docs.astral.sh/uv/getting-started/installation/" - return $false - } - - foreach ($exe in $executables) { - Copy-Item -Path $exe.FullName -Destination $binDir -Force -ErrorAction Stop - } + $ErrorActionPreference = "Continue" + $env:UV_INSTALL_DIR = Join-Path $HermesHome "bin" + # Spawn via the resolved host exe (see Get-PowerShellHostExe) rather + # than a bare `powershell`, which isn't guaranteed to be on PATH under + # PowerShell 7 / pwsh-only setups. + $psHostExe = Get-PowerShellHostExe + & $psHostExe -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" 2>&1 | Out-Null + $ErrorActionPreference = $prevEAP if (Test-Path $managedUv) { $script:UvCmd = $managedUv @@ -501,16 +479,14 @@ function Install-Uv { return $true } - Write-Err "uv.exe not found at $managedUv after extraction" + Write-Err "uv installed but not found at $managedUv" Write-Info "Install manually: https://docs.astral.sh/uv/getting-started/installation/" return $false } catch { - Write-Err "Failed to extract uv: $_" + if ($prevEAP) { $ErrorActionPreference = $prevEAP } + Write-Err "Failed to install uv: $_" Write-Info "Install manually: https://docs.astral.sh/uv/getting-started/installation/" return $false - } finally { - Remove-Item $tempZip -Force -ErrorAction SilentlyContinue - Remove-Item $tempExtract -Recurse -Force -ErrorAction SilentlyContinue } } diff --git a/tests/test_install_ps1_native_stderr_eap.py b/tests/test_install_ps1_native_stderr_eap.py new file mode 100644 index 000000000000..de99bf229004 --- /dev/null +++ b/tests/test_install_ps1_native_stderr_eap.py @@ -0,0 +1,94 @@ +"""Regression tests for #48352: Windows PowerShell 5.1 native stderr. + +PowerShell 5.1 turns stderr from native commands into ``NativeCommandError`` +records when ``$ErrorActionPreference = "Stop"``. ``scripts/install.ps1`` has a +few git/uv calls where stderr can be normal progress output, so those calls must +run with EAP temporarily relaxed and then inspect ``$LASTEXITCODE``. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +INSTALL_PS1 = REPO_ROOT / "scripts" / "install.ps1" + + +def _install_ps1() -> str: + return INSTALL_PS1.read_text(encoding="utf-8") + + +def _assert_relaxed_call(text: str, command_pattern: str) -> None: + helper_block_pattern = ( + r"Invoke-NativeWithRelaxedErrorAction\s*\{[^}]*" + + command_pattern + + r"[^}]*\}" + ) + inline_pattern = ( + r"\$ErrorActionPreference\s*=\s*\"Continue\"[\s\S]{0,900}?" + + command_pattern + ) + assert re.search(helper_block_pattern, text) or re.search(inline_pattern, text), ( + f"install.ps1 must relax ErrorActionPreference around {command_pattern}" + ) + + +def test_repository_stage_relieves_eap_for_ssh_and_https_git_clone() -> None: + text = _install_ps1() + assert "function Invoke-NativeWithRelaxedErrorAction" in text + _assert_relaxed_call( + text, + r"git -c windows\.appendAtomically=false clone --depth 1 --branch \$Branch \$RepoUrlSsh \$InstallDir", + ) + _assert_relaxed_call( + text, + r"git -c windows\.appendAtomically=false clone --depth 1 --branch \$Branch \$RepoUrlHttps \$InstallDir", + ) + + +def test_uv_venv_and_dependency_installs_relax_eap() -> None: + text = _install_ps1() + _assert_relaxed_call(text, r"& \$UvCmd venv venv --python \$PythonVersion") + _assert_relaxed_call(text, r"& \$UvCmd sync --extra all --locked") + _assert_relaxed_call(text, r"& \$UvCmd pip install -e \$tier\.Spec") + + +def test_uv_venv_failure_is_not_swallowed_after_eap_relax() -> None: + """Relaxing EAP must not let a genuine `uv venv` failure pass as success. + + Once EAP is relaxed, a real non-zero `uv venv` exit no longer aborts on its + own, so install.ps1 must capture $LASTEXITCODE right after the call and fail + fast — otherwise the `venv` stage falsely reports success (Invoke-Stage emits + ok=true) when no venv was created. Regression guard for the gap caught while + reviewing #48372 (the explicit check originally proposed in #48463). + """ + text = _install_ps1() + # The uv-venv invocation, then an exit-code capture, then a throw — all + # within a small window after the relaxed call. + guard = re.search( + r"& \$UvCmd venv venv --python \$PythonVersion[\s\S]{0,400}?" + r"\$LASTEXITCODE[\s\S]{0,200}?" + r"-ne 0[\s\S]{0,200}?throw", + text, + ) + assert guard is not None, ( + "install.ps1 must capture uv venv's exit code and throw on failure after " + "relaxing ErrorActionPreference, so a genuine venv-creation failure isn't " + "reported as a successful stage" + ) + + +def test_native_eap_helper_always_restores_previous_preference() -> None: + text = _install_ps1() + m = re.search( + r"function Invoke-NativeWithRelaxedErrorAction \{(?P[\s\S]*?)^\}", + text, + re.MULTILINE, + ) + assert m is not None, "expected a shared helper for NativeCommandError-safe calls" + body = m.group("body") + assert "$prevEAP = $ErrorActionPreference" in body + assert '$ErrorActionPreference = "Continue"' in body + assert "finally" in body + assert "$ErrorActionPreference = $prevEAP" in body diff --git a/tests/test_install_ps1_uv_powershell_host.py b/tests/test_install_ps1_uv_powershell_host.py new file mode 100644 index 000000000000..ea442ce484a8 --- /dev/null +++ b/tests/test_install_ps1_uv_powershell_host.py @@ -0,0 +1,77 @@ +"""Regression: the Windows installer must not spawn a bare ``powershell``. + +A user on Windows reported the installer getting stuck; running +``irm https://hermes-agent.nousresearch.com/install.ps1 | iex`` failed at the +uv step with:: + + [X] Failed to install uv: The term 'powershell' is not recognized as the + name of a cmdlet, function, script file, or operable program. + +Root cause: ``Install-Uv`` spawned the astral uv installer via a hardcoded +bare ``powershell`` command. That name resolves only to *Windows PowerShell* +and only when its System32 directory is on ``PATH``. Under PowerShell 7+ +(``pwsh``) -- or any session where ``powershell`` isn't on ``PATH`` -- the +spawn dies and uv installation aborts. + +The fix resolves the PowerShell host executable (preferring the absolute path +of the running host, then ``powershell``/``pwsh`` via ``Get-Command``) and +invokes *that* instead of a bare name. These tests lock that contract at the +source level (the script only runs on Windows, so there's no runner to +execute it on Linux CI). +""" + +from pathlib import Path + +import pytest + +_INSTALL_PS1 = Path(__file__).resolve().parents[1] / "scripts" / "install.ps1" + + +@pytest.fixture(scope="module") +def source() -> str: + return _INSTALL_PS1.read_text(encoding="utf-8") + + +def test_astral_uv_installer_not_spawned_via_bare_powershell(source: str): + """The exact failing literal must be gone.""" + forbidden = 'powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv' + assert forbidden not in source, ( + "Install-Uv still spawns the astral uv installer via a bare " + "`powershell` — it must use the resolved PowerShell host exe so it " + "works under pwsh / when powershell isn't on PATH." + ) + + +def test_astral_uv_installer_invoked_via_resolved_host_variable(source: str): + """The astral uv installer line must use the call operator on a variable. + + i.e. ``& $psHostExe -ExecutionPolicy ... irm https://astral.sh/uv...`` + rather than naming a fixed executable. + """ + lines = [ln for ln in source.splitlines() if "astral.sh/uv/install.ps1 | iex" in ln] + # Exactly one invocation line carries the astral installer. + invocation = [ln for ln in lines if "irm https://astral.sh/uv/install.ps1 | iex" in ln] + assert invocation, "astral uv install invocation line not found" + for ln in invocation: + stripped = ln.strip() + assert stripped.startswith("& $"), ( + f"astral uv installer must be invoked via the call operator on a " + f"resolved host variable (`& $...`), got: {stripped!r}" + ) + + +def test_powershell_host_resolver_is_defined_and_portable(source: str): + """A host-resolver helper must exist and be PATH-independent + pwsh-aware.""" + assert "function Get-PowerShellHostExe" in source, ( + "expected a Get-PowerShellHostExe helper that resolves the host exe" + ) + # PATH-independent: derive the absolute path of the running host. + assert "Get-Process -Id $PID" in source, ( + "resolver must derive the current host's absolute path " + "(Get-Process -Id $PID), which is independent of PATH" + ) + # pwsh-aware fallback: PowerShell 7's executable is `pwsh`, not `powershell`. + assert "pwsh" in source, ( + "resolver must fall back to pwsh (PowerShell 7) when powershell is " + "unavailable" + )