diff --git a/hermes_cli/managed_uv.py b/hermes_cli/managed_uv.py index 78c8f469003a..90ecbaac5671 100644 --- a/hermes_cli/managed_uv.py +++ b/hermes_cli/managed_uv.py @@ -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 \ No newline at end of file diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 0a98ad6e457b..352cc647b9a4 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -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 } } diff --git a/tests/test_install_ps1_native_stderr_eap.py b/tests/test_install_ps1_native_stderr_eap.py deleted file mode 100644 index de99bf229004..000000000000 --- a/tests/test_install_ps1_native_stderr_eap.py +++ /dev/null @@ -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[\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 deleted file mode 100644 index ea442ce484a8..000000000000 --- a/tests/test_install_ps1_uv_powershell_host.py +++ /dev/null @@ -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" - )