mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-23 16:36:23 +00:00
revert(installer): drop install.ps1 rewrite from E2E branch
Pulls commit3dab86a95out of this branch per review — the install.ps1 rewrite (swapping the astral install-script for a direct GitHub-zip download) is a real Windows-installer behavior change that belongs in its own installer PR, not riding along in a Desktop E2E PR. e2e-windows.yml is `if: false` on both jobs and can't run on Linux CI, so the rewrite lands here with no coverage. The deleted install tests (test_install_ps1_native_stderr_eap.py, test_install_ps1_uv_powershell_host.py) are restored — they'll be dropped alongside the installer change in its own PR. Original commit3dab86a95will be cherry-picked onto a dedicated installer PR.
This commit is contained in:
parent
33c154e41f
commit
0b40ba10cd
3 changed files with 216 additions and 69 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
94
tests/test_install_ps1_native_stderr_eap.py
Normal file
94
tests/test_install_ps1_native_stderr_eap.py
Normal file
|
|
@ -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<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
|
||||
77
tests/test_install_ps1_uv_powershell_host.py
Normal file
77
tests/test_install_ps1_uv_powershell_host.py
Normal file
|
|
@ -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"
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue