Compare commits

...

3 Commits

Author SHA1 Message Date
Brooklyn Nicholson
41a12b0070 fix(installer): try session PATH before registry refresh in Resolve-UvCmd
Address Copilot review on #37622:
- Check the current process PATH for a trusted uv before refreshing $env:Path
  from the registry, so a session-only trusted uv (prepended for this shell
  but not persisted) is honored instead of being clobbered by the refresh.
- Distinguish the failure modes in the thrown error: a uv that exists but was
  rejected as conda/Anaconda-managed now reports that explicitly, rather than
  implying no uv was found.
2026-06-02 15:37:41 -05:00
Brooklyn Nicholson
859fd752f4 fix(installer): case-insensitive conda guard + local temp (review)
Address Copilot review on #37622:
- uv_path_is_trusted now lowercases the candidate path (bash-3.2-safe via tr)
  before matching, so capitalized install dirs like Miniconda3/Anaconda3/
  Miniforge3 can't bypass the guard.
- Declare the PATH-uv temporary `local` in install_uv so it doesn't leak into
  global script scope.
2026-06-02 15:30:27 -05:00
Brooklyn Nicholson
1e7ccaa2b6 fix(installer): prefer a trusted uv over a bare PATH uv in bootstrap
The bootstrap installers resolved uv by trusting whatever was first on PATH
(`command -v uv` / `Get-Command uv`) before checking the managed standalone
locations. On Windows that bare lookup frequently picks up a conda/Anaconda
uv; pointed at the Hermes venv via VIRTUAL_ENV its environment assumptions
collide and the dependency install breaks.

Reorder install.sh `install_uv` and install.ps1 `Install-Uv`/`Resolve-UvCmd`
to probe `~/.local/bin` / `~/.cargo/bin` first and accept a PATH uv only when
it isn't conda-managed (new `uv_path_is_trusted` / `Test-UvUntrusted` guard).
This brings the bootstrap path to parity with `hermes update`, which was fixed
the same way in PR #37605 (hermes_cli/managed_uv.py).

Add tests/test_install_trusted_uv_resolution.py asserting the managed-before-
PATH ordering and the conda guard in both installers.
2026-06-02 15:11:09 -05:00
3 changed files with 189 additions and 37 deletions

View File

@@ -288,18 +288,25 @@ function Install-AgentBrowser {
# Dependency checks
# ============================================================================
# A uv that lives inside a conda/Anaconda environment carries its own
# environment assumptions; pointed at the Hermes venv via VIRTUAL_ENV it breaks
# dependency installs (reported on Windows with a conda uv first on PATH).
# Treat such a uv as untrusted so resolution falls back to a standalone uv.
# Mirrors hermes_cli/managed_uv.py's trust ordering for `hermes update`.
function Test-UvUntrusted {
param([string]$UvSource)
if (-not $UvSource) { return $false }
return ($UvSource -match '(?i)(anaconda|miniconda|miniforge|mambaforge|conda)')
}
function Install-Uv {
Write-Info "Checking for uv package manager..."
# Check if uv is already available
if (Get-Command uv -ErrorAction SilentlyContinue) {
$version = uv --version
$script:UvCmd = "uv"
Write-Success "uv found ($version)"
return $true
}
# Check common install locations
# Prefer a standalone uv in the known managed locations over whatever is
# first on PATH. A bare `Get-Command uv` frequently resolves to a
# conda/Anaconda-shipped uv; pointed at the Hermes venv via VIRTUAL_ENV its
# environment assumptions collide and the install breaks. Mirrors the trust
# ordering hermes_cli/managed_uv.py uses for `hermes update` (PR #37605).
$uvPaths = @(
"$env:USERPROFILE\.local\bin\uv.exe",
"$env:USERPROFILE\.cargo\bin\uv.exe"
@@ -312,6 +319,18 @@ function Install-Uv {
return $true
}
}
# Fall back to a PATH uv only when it isn't a conda/Anaconda-managed one.
$pathUv = Get-Command uv -ErrorAction SilentlyContinue
if ($pathUv -and -not (Test-UvUntrusted $pathUv.Source)) {
$version = & $pathUv.Source --version
$script:UvCmd = "uv"
Write-Success "uv found ($version)"
return $true
}
if ($pathUv) {
Write-Info "Ignoring conda-managed uv on PATH ($($pathUv.Source)); installing a standalone uv instead"
}
# Install uv
Write-Info "Installing uv (fast Python package manager)..."
@@ -404,24 +423,11 @@ function Resolve-UvCmd {
# Stale; fall through to re-discover.
}
# Try PATH first (covers `winget install astral.uv`, manual installs,
# and the post-Install-Uv state where uv.exe lives in
# %USERPROFILE%\.local\bin which the installer added to PATH).
if (Get-Command uv -ErrorAction SilentlyContinue) {
$script:UvCmd = "uv"
return
}
# Refresh PATH from registry in case the current process started before
# Install-Uv updated User PATH.
$env:Path = [Environment]::GetEnvironmentVariable("Path", "User") + ";" + [Environment]::GetEnvironmentVariable("Path", "Machine")
if (Get-Command uv -ErrorAction SilentlyContinue) {
$script:UvCmd = "uv"
return
}
# Check the well-known install locations the astral.sh installer drops
# uv into. Mirrors the probe order Install-Uv uses.
# Prefer the well-known managed locations the astral.sh installer drops uv
# into over a bare PATH uv, which may be a conda/Anaconda uv that breaks the
# Hermes venv install. Mirrors hermes_cli/managed_uv.py's trust ordering for
# `hermes update` (PR #37605). Test-Path doesn't depend on PATH, so this is
# also robust to the stale-PATH-in-a-fresh-stage-process case.
foreach ($uvPath in @("$env:USERPROFILE\.local\bin\uv.exe", "$env:USERPROFILE\.cargo\bin\uv.exe")) {
if (Test-Path $uvPath) {
$script:UvCmd = $uvPath
@@ -429,6 +435,31 @@ function Resolve-UvCmd {
}
}
# Try the current process PATH *before* mutating it, so a session-only
# trusted uv (prepended for this shell but not persisted to the registry)
# is still honored. Accept it only when it isn't conda/Anaconda-managed.
$pathUv = Get-Command uv -ErrorAction SilentlyContinue
if ($pathUv -and -not (Test-UvUntrusted $pathUv.Source)) {
$script:UvCmd = "uv"
return
}
# Nothing trusted on the current PATH -- refresh from the registry in case
# this process started before Install-Uv updated User PATH (each stage runs
# in its own process), then re-check for a trusted uv.
$env:Path = [Environment]::GetEnvironmentVariable("Path", "User") + ";" + [Environment]::GetEnvironmentVariable("Path", "Machine")
$registryUv = Get-Command uv -ErrorAction SilentlyContinue
if ($registryUv -and -not (Test-UvUntrusted $registryUv.Source)) {
$script:UvCmd = "uv"
return
}
# Be explicit about *why* resolution failed: a uv that exists but was
# rejected as conda/Anaconda-managed is a different fix than no uv at all.
$rejected = if ($registryUv) { $registryUv.Source } elseif ($pathUv) { $pathUv.Source } else { $null }
if ($rejected) {
throw "Only a conda/Anaconda-managed uv was found ($rejected); refusing to use it for the Hermes venv. Run install.ps1 -Stage uv to install a standalone uv."
}
throw "uv is not installed or not on PATH. Run install.ps1 -Stage uv first."
}

View File

@@ -468,6 +468,22 @@ detect_os() {
# Dependency checks
# ============================================================================
# A uv that lives inside a conda/Anaconda environment carries its own
# environment assumptions; pointed at the Hermes venv via VIRTUAL_ENV it breaks
# dependency installs. Treat such a uv as untrusted so resolution falls back to
# a standalone uv. Mirrors hermes_cli/managed_uv.py's trust ordering.
uv_path_is_trusted() {
# Normalize to lowercase first (bash-3.2-safe via tr, no ${var,,}) so
# capitalized install dirs like "Miniconda3" / "Anaconda3" / "Miniforge3"
# don't slip past the guard and reintroduce the PATH hijack.
local _uv_lc
_uv_lc="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')"
case "$_uv_lc" in
*conda*|*miniforge*|*mambaforge*) return 1 ;;
*) return 0 ;;
esac
}
install_uv() {
if [ "$DISTRO" = "termux" ]; then
log_info "Termux detected — using Python's stdlib venv + pip instead of uv"
@@ -477,15 +493,11 @@ install_uv() {
log_info "Checking for uv package manager..."
# Check common locations for uv
if command -v uv &> /dev/null; then
UV_CMD="uv"
UV_VERSION=$($UV_CMD --version 2>/dev/null)
log_success "uv found ($UV_VERSION)"
return 0
fi
# Check ~/.local/bin (default uv install location) even if not on PATH yet
# Prefer a standalone uv in the known managed locations over whatever is
# first on PATH. A bare `command -v uv` frequently resolves to a
# conda/Anaconda-shipped uv; pointed at the Hermes venv via VIRTUAL_ENV its
# environment assumptions collide and the install breaks. Mirrors the trust
# ordering hermes_cli/managed_uv.py uses for `hermes update` (PR #37605).
if [ -x "$HOME/.local/bin/uv" ]; then
UV_CMD="$HOME/.local/bin/uv"
UV_VERSION=$($UV_CMD --version 2>/dev/null)
@@ -501,6 +513,19 @@ install_uv() {
return 0
fi
# Fall back to a PATH uv only when it isn't conda/Anaconda-managed.
if command -v uv &> /dev/null; then
local _path_uv
_path_uv="$(command -v uv)"
if uv_path_is_trusted "$_path_uv"; then
UV_CMD="uv"
UV_VERSION=$($UV_CMD --version 2>/dev/null)
log_success "uv found ($UV_VERSION)"
return 0
fi
log_info "Ignoring conda-managed uv on PATH ($_path_uv); installing a standalone uv instead"
fi
# Install uv
log_info "Installing uv (fast Python package manager)..."
# Capture installer output so a failure shows the user WHY (network,

View File

@@ -0,0 +1,96 @@
"""Regression test: installers prefer a managed/standalone uv over a bare PATH uv.
A bare ``command -v uv`` / ``Get-Command uv`` frequently resolves to a
conda/Anaconda-shipped uv. Pointed at the Hermes venv via ``VIRTUAL_ENV`` that
uv's own environment assumptions collide and the dependency install breaks
(reported on Windows with a conda uv first on PATH). ``hermes update`` was fixed
the same way in PR #37605 (``hermes_cli/managed_uv.py``); this guards the
bootstrap installers so resolution checks the managed locations
(``~/.local/bin`` / ``~/.cargo/bin``) before falling back to PATH, and skips a
conda-managed PATH uv entirely.
These are ordering-contract assertions (managed-before-PATH), not data
snapshots, so routine edits won't churn them.
"""
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
INSTALL_SH = REPO_ROOT / "scripts" / "install.sh"
INSTALL_PS1 = REPO_ROOT / "scripts" / "install.ps1"
def _body(text: str, signature: str) -> str:
"""Return the body of a shell/PowerShell function, bounded by its opening
signature and the next top-level ``}`` (a brace alone on its own line)."""
_, _, rest = text.partition(signature)
assert rest, f"Could not find {signature!r}"
body, _, _ = rest.partition("\n}\n")
assert body, f"Could not find closing brace for {signature!r}"
return body
# --------------------------------------------------------------------------- #
# install.sh
# --------------------------------------------------------------------------- #
def test_install_sh_has_conda_trust_helper() -> None:
text = INSTALL_SH.read_text(encoding="utf-8")
helper = _body(text, "uv_path_is_trusted() {")
assert "conda" in helper, "uv_path_is_trusted must reject conda-managed uv"
def test_install_sh_prefers_managed_uv_over_path() -> None:
text = INSTALL_SH.read_text(encoding="utf-8")
body = _body(text, "install_uv() {")
local_idx = body.find('if [ -x "$HOME/.local/bin/uv" ]')
path_idx = body.find("if command -v uv &> /dev/null")
assert local_idx != -1, "install_uv must probe ~/.local/bin/uv"
assert path_idx != -1, "install_uv must still fall back to a PATH uv"
assert local_idx < path_idx, (
"managed ~/.local/bin/uv must be preferred over a bare PATH uv"
)
assert "uv_path_is_trusted" in body, (
"install_uv must gate the PATH fallback through uv_path_is_trusted"
)
# --------------------------------------------------------------------------- #
# install.ps1
# --------------------------------------------------------------------------- #
def test_install_ps1_has_conda_trust_helper() -> None:
text = INSTALL_PS1.read_text(encoding="utf-8")
helper = _body(text, "function Test-UvUntrusted {")
assert "conda" in helper.lower(), "Test-UvUntrusted must reject conda-managed uv"
def test_install_ps1_install_uv_prefers_managed_over_path() -> None:
text = INSTALL_PS1.read_text(encoding="utf-8")
body = _body(text, "function Install-Uv {")
managed_idx = body.find(r"$env:USERPROFILE\.local\bin\uv.exe")
path_idx = body.find("$pathUv = Get-Command uv")
assert managed_idx != -1, "Install-Uv must probe the managed uv locations"
assert path_idx != -1, "Install-Uv must still fall back to a PATH uv"
assert managed_idx < path_idx, (
"managed uv locations must be preferred over a bare PATH uv"
)
assert "Test-UvUntrusted" in body, (
"Install-Uv must gate the PATH fallback through Test-UvUntrusted"
)
def test_install_ps1_resolve_uvcmd_prefers_managed_over_path() -> None:
text = INSTALL_PS1.read_text(encoding="utf-8")
body = _body(text, "function Resolve-UvCmd {")
managed_idx = body.find(r"$env:USERPROFILE\.local\bin\uv.exe")
refresh_idx = body.find('GetEnvironmentVariable("Path", "User")')
assert managed_idx != -1, "Resolve-UvCmd must probe the managed uv locations"
assert refresh_idx != -1, "Resolve-UvCmd must still refresh PATH as a fallback"
assert managed_idx < refresh_idx, (
"managed uv locations must be checked before the PATH-refresh fallback"
)
assert "Test-UvUntrusted" in body, (
"Resolve-UvCmd must gate the PATH fallback through Test-UvUntrusted"
)