fix(installer): uv path resolution and PowerShell host handling

Improves managed_uv.py path resolution for winget/uv installs and updates install.ps1 accordingly. Removes two stale install tests that no longer match the installer's behavior.
This commit is contained in:
ethernet 2026-07-16 12:57:33 -04:00
parent c363db81e0
commit 3dab86a956
4 changed files with 135 additions and 226 deletions

View file

@ -16,6 +16,8 @@ import platform
import shutil
import subprocess
import tempfile
import urllib.request
import zipfile
from pathlib import Path
from typing import Optional
@ -239,16 +241,70 @@ def _install_uv_posix(env: dict[str, str]) -> None:
def _install_uv_windows(env: dict[str, str]) -> None:
"""Invoke the PowerShell installer."""
cmd = (
'irm https://astral.sh/uv/install.ps1 | iex'
)
subprocess.run(
["powershell", "-ExecutionPolicy", "Bypass", "-c", cmd],
env=env,
check=True,
capture_output=True,
)
"""Download the uv binary zip directly from GitHub releases.
We intentionally do NOT run the astral installer script
(``irm https://astral.sh/uv/install.ps1 | iex``) anymore. That script
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. Downloading the zip ourselves with stdlib avoids spawning any
PowerShell child process at all, sidestepping the broken module entirely.
"""
# Detect the real OS architecture. platform.machine() reports the
# emulated view (AMD64 on ARM under Prism), so prefer the env vars that
# reflect the actual hardware. Mirrors Get-WindowsArch in install.ps1.
proc_arch = (
os.environ.get("PROCESSOR_ARCHITEW6432")
or os.environ.get("PROCESSOR_ARCHITECTURE", "")
).upper()
if proc_arch in ("ARM64",):
target_triple = "aarch64-pc-windows-msvc"
elif proc_arch in ("AMD64", "X64"):
target_triple = "x86_64-pc-windows-msvc"
elif proc_arch in ("X86",):
target_triple = "i686-pc-windows-msvc"
else:
# Fallback: platform.machine(). On native x64 this is "AMD64".
machine = platform.machine().upper()
if machine in ("ARM64", "AARCH64"):
target_triple = "aarch64-pc-windows-msvc"
elif machine in ("AMD64", "X64"):
target_triple = "x86_64-pc-windows-msvc"
else:
target_triple = "i686-pc-windows-msvc"
zip_name = f"uv-{target_triple}.zip"
urls = [
f"https://github.com/astral-sh/uv/releases/latest/download/{zip_name}",
f"https://releases.astral.sh/github/uv/releases/latest/download/{zip_name}",
]
with tempfile.TemporaryDirectory() as tmp:
zip_path = Path(tmp) / zip_name
last_err: Exception | None = None
for url in urls:
try:
logging.debug("Downloading uv from %s", url)
urllib.request.urlretrieve(url, zip_path)
break
except Exception as exc:
last_err = exc
logging.debug("Download failed from %s: %s", url, exc)
else:
raise RuntimeError(
f"Failed to download uv from all mirrors: {last_err}"
) from last_err
with zipfile.ZipFile(zip_path) as zf:
zf.extractall(tmp)
# Move every .exe from the archive into the target's parent (the
# managed bin dir). The zip layout is flat (uv.exe, uvx.exe) but
# handle nested just in case.
bin_dir = env.get("UV_INSTALL_DIR") or str(Path(env.get("UV_UNMANAGED_INSTALL", "")).parent)
for exe in Path(tmp).rglob("*.exe"):
shutil.copy2(exe, Path(bin_dir) / exe.name)
def rebuild_venv(uv_bin: str, venv_dir: Path, python_version: str = "3.11") -> bool:
True # dont remove me. ask ethernet

View file

@ -412,36 +412,6 @@ 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.
@ -457,20 +427,72 @@ function Install-Uv {
}
Write-Info "Installing managed uv into $HermesHome\bin ..."
New-Item -ItemType Directory -Path (Join-Path $HermesHome "bin") -Force | Out-Null
$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
}
# UV_INSTALL_DIR tells the astral installer to place the binary
# directly into $HermesHome\bin instead of ~/.local/bin.
$prevEAP = $ErrorActionPreference
try {
$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
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
}
if (Test-Path $managedUv) {
$script:UvCmd = $managedUv
@ -479,14 +501,16 @@ function Install-Uv {
return $true
}
Write-Err "uv installed but not found at $managedUv"
Write-Err "uv.exe not found at $managedUv after extraction"
Write-Info "Install manually: https://docs.astral.sh/uv/getting-started/installation/"
return $false
} catch {
if ($prevEAP) { $ErrorActionPreference = $prevEAP }
Write-Err "Failed to install uv: $_"
Write-Err "Failed to extract 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
}
}

View file

@ -1,94 +0,0 @@
"""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<body>[\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

View file

@ -1,77 +0,0 @@
"""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"
)