Compare commits

...

1 Commits

Author SHA1 Message Date
teknium1
eebed21070 fix(update): resolve a trusted uv instead of blindly trusting PATH
hermes update drove dependency installs with shutil.which("uv"), which on
Windows returns whatever uv sits earliest on PATH — frequently an Anaconda/
conda-shipped uv. Pointed at the Hermes venv via VIRTUAL_ENV, that uv's own
environment assumptions collide and the install breaks.

Add hermes_cli/managed_uv with a trust-ordered resolver:
  $HERMES_HOME/bin/uv -> venv uv -> ~/.local/bin -> ~/.cargo/bin -> PATH
mirroring the managed-binary convention already used for tirith and bws.
ensure_uv() bootstraps a standalone uv into $HERMES_HOME/bin (via the
official installer with UV_UNMANAGED_INSTALL, no PATH/registry edits) when
nothing trusted exists, so a poisoned PATH can never hijack update again.

Wired into the two venv-install update paths (_cmd_update_impl, _update_via_zip)
and _ensure_uv_for_termux. _cmd_update_pip is left on PATH uv intentionally —
that path upgrades a uv-tool/pipx-managed install the PATH uv actually owns.
2026-06-02 12:36:40 -07:00
3 changed files with 327 additions and 4 deletions

View File

@@ -7480,7 +7480,9 @@ def _update_via_zip(args):
print("→ Updating Python dependencies...")
pip_cmd = [sys.executable, "-m", "pip"]
uv_bin = shutil.which("uv") or _ensure_uv_for_termux(pip_cmd)
from hermes_cli.managed_uv import ensure_uv
uv_bin = ensure_uv() or _ensure_uv_for_termux(pip_cmd)
if uv_bin:
uv_env = {**os.environ, "VIRTUAL_ENV": str(PROJECT_ROOT / "venv")}
if _is_termux_env(uv_env):
@@ -8581,7 +8583,9 @@ def _install_psutil_android_compat(
def _ensure_uv_for_termux(pip_cmd: list[str]) -> str | None:
"""Best-effort uv bootstrap on Termux for faster update installs."""
uv_bin = shutil.which("uv")
from hermes_cli.managed_uv import resolve_uv
uv_bin = resolve_uv()
if uv_bin or not _is_termux_env():
return uv_bin
try:
@@ -8589,7 +8593,7 @@ def _ensure_uv_for_termux(pip_cmd: list[str]) -> str | None:
subprocess.run(pip_cmd + ["install", "uv"], cwd=PROJECT_ROOT, check=False)
except Exception:
pass
return shutil.which("uv")
return resolve_uv()
def _update_node_dependencies() -> None:
@@ -9649,7 +9653,9 @@ def _cmd_update_impl(args, gateway_mode: bool):
# individually so update does not silently strip working capabilities.
print("→ Updating Python dependencies...")
pip_cmd = [sys.executable, "-m", "pip"]
uv_bin = shutil.which("uv") or _ensure_uv_for_termux(pip_cmd)
from hermes_cli.managed_uv import ensure_uv
uv_bin = ensure_uv() or _ensure_uv_for_termux(pip_cmd)
install_group = "all"
if uv_bin:

181
hermes_cli/managed_uv.py Normal file
View File

@@ -0,0 +1,181 @@
"""Hermes-managed ``uv`` resolution and bootstrap.
``hermes update`` and the other dependency-install paths need a *known-good*
``uv`` to drive ``uv pip install`` against the Hermes venv. The naive
``shutil.which("uv")`` is dangerous: on Windows it frequently returns an
Anaconda/conda-shipped ``uv`` whose own environment assumptions collide with
the Hermes venv we point it at via ``VIRTUAL_ENV``, and the install breaks. The
same class of failure shows up on POSIX when a stale ``pip install uv==0.7.20``
sits earlier on PATH (the installer already works around that — see
``ensure_fts5`` in ``scripts/install.sh``).
The durable fix is to stop trusting an arbitrary PATH ``uv`` and instead
*vendor* one under ``$HERMES_HOME/bin`` — the same convention already used for
tirith (``tools/tirith_security.py``) and bws (``agent/secret_sources/
bitwarden.py``). Resolution prefers trusted locations and only falls back to
PATH when nothing trusted exists:
1. ``$HERMES_HOME/bin/uv[.exe]`` (our managed copy — preferred)
2. ``PROJECT_ROOT/venv/{Scripts,bin}/uv[.exe]`` (the Hermes venv's own uv)
3. ``~/.local/bin/uv[.exe]`` (uv's official installer target)
4. ``~/.cargo/bin/uv[.exe]`` (cargo-installed uv)
5. ``shutil.which("uv")`` (PATH fallback — last resort)
``ensure_uv()`` adds a final step: if nothing trusted resolves, it installs a
fresh standalone uv into ``$HERMES_HOME/bin`` via the official installer using
``UV_UNMANAGED_INSTALL`` (POSIX) / ``UV_INSTALL_DIR`` (Windows), so a poisoned
PATH can never again hijack ``hermes update``.
"""
from __future__ import annotations
import logging
import os
import platform
import shutil
import subprocess
import tempfile
from pathlib import Path
from typing import Optional
logger = logging.getLogger(__name__)
# Project root: hermes_cli/ -> repo root. Mirrors hermes_cli/main.py's
# PROJECT_ROOT so a git/source install can find the venv's own uv.
PROJECT_ROOT = Path(__file__).resolve().parent.parent
def _is_windows() -> bool:
return os.name == "nt"
def _uv_exe_name() -> str:
return "uv.exe" if _is_windows() else "uv"
def hermes_bin_dir() -> Path:
"""``$HERMES_HOME/bin`` — where Hermes stores its managed binaries.
Profile-aware via ``get_hermes_home()``. Created on demand by
:func:`ensure_uv`; this accessor never has the side effect of mkdir.
"""
from hermes_constants import get_hermes_home
return Path(get_hermes_home()) / "bin"
def _is_usable(path: Path) -> bool:
try:
return path.is_file() and os.access(path, os.X_OK)
except OSError:
return False
def _candidate_paths() -> list[Path]:
"""Trusted ``uv`` locations, in preference order (managed → PATH)."""
exe = _uv_exe_name()
bindir = "Scripts" if _is_windows() else "bin"
candidates: list[Path] = [
hermes_bin_dir() / exe,
PROJECT_ROOT / "venv" / bindir / exe,
]
home = Path(os.path.expanduser("~"))
candidates += [
home / ".local" / "bin" / exe,
home / ".cargo" / "bin" / exe,
]
return candidates
def resolve_uv() -> Optional[str]:
"""Return a path to a known-good ``uv``, or ``None`` if none is found.
Probes the trusted locations first (managed copy, venv, official installer
dirs) and only then falls back to ``shutil.which("uv")``. Pure lookup — no
install, no network, no side effects. Safe to call from hot paths.
"""
for cand in _candidate_paths():
if _is_usable(cand):
return str(cand)
return shutil.which("uv")
def _install_standalone_uv(dest: Path) -> Optional[str]:
"""Install a fresh standalone uv into ``dest`` via the official installer.
Uses ``UV_UNMANAGED_INSTALL`` (POSIX) / ``UV_INSTALL_DIR`` (Windows) so the
binary lands exactly in ``dest`` and nowhere else — no PATH edits, no shell
profile changes, no interference with an existing system uv. Returns the
path to the installed binary, or ``None`` on failure.
"""
dest.mkdir(parents=True, exist_ok=True)
target = dest / _uv_exe_name()
try:
if _is_windows():
# PowerShell installer honours $env:UV_INSTALL_DIR for the target
# dir and $env:UV_UNMANAGED_INSTALL to skip PATH/registry edits.
env = {
**os.environ,
"UV_INSTALL_DIR": str(dest),
"UV_UNMANAGED_INSTALL": str(dest),
}
ps_cmd = (
"$ErrorActionPreference='Stop'; "
"irm https://astral.sh/uv/install.ps1 | iex"
)
subprocess.run(
["powershell", "-ExecutionPolicy", "ByPass", "-NoProfile", "-Command", ps_cmd],
env=env,
check=True,
capture_output=True,
timeout=180,
)
else:
# Shell installer: pipe through `sh` with UV_*_INSTALL pointing at
# dest. Matches scripts/install.sh ensure_fts5's fresh-uv bootstrap.
installer = subprocess.run(
["curl", "-LsSf", "https://astral.sh/uv/install.sh"],
check=True,
capture_output=True,
timeout=120,
)
env = {
**os.environ,
"UV_INSTALL_DIR": str(dest),
"UV_UNMANAGED_INSTALL": str(dest),
}
subprocess.run(
["sh"],
input=installer.stdout,
env=env,
check=True,
capture_output=True,
timeout=180,
)
except Exception as exc: # noqa: BLE001 — never block update on this
logger.warning("standalone uv bootstrap into %s failed: %s", dest, exc)
return None
if _is_usable(target):
return str(target)
logger.warning("standalone uv installer ran but %s is not usable", target)
return None
def ensure_uv(*, install_if_missing: bool = True) -> Optional[str]:
"""Return a known-good ``uv``, installing a managed one if necessary.
Resolution order is :func:`resolve_uv`. When that returns ``None`` and
``install_if_missing`` is True, bootstrap a standalone uv into
``$HERMES_HOME/bin`` and return it. Returns ``None`` only when no uv could
be found and the bootstrap failed (or was disabled).
"""
found = resolve_uv()
if found:
return found
if not install_if_missing:
return None
return _install_standalone_uv(hermes_bin_dir())

View File

@@ -0,0 +1,136 @@
"""Tests for hermes_cli.managed_uv — trusted uv resolution + bootstrap.
The contract under test: ``resolve_uv()`` must prefer a Hermes-managed uv
(``$HERMES_HOME/bin``), then the venv's uv, then the official-installer dirs
(``~/.local/bin``, ``~/.cargo/bin``), and only fall back to ``shutil.which``
when nothing trusted exists. This is what stops a conda/Anaconda uv earlier on
PATH from hijacking ``hermes update`` on Windows.
"""
import os
import stat
from pathlib import Path
import pytest
import hermes_cli.managed_uv as mu
def _make_uv(path: Path, tag: str = "x") -> str:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(f"#!/bin/sh\necho {tag}\n")
path.chmod(path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
return str(path)
@pytest.fixture
def env(tmp_path, monkeypatch):
"""Isolated HOME + HERMES_HOME + project root, with a fresh empty PATH dir."""
home = tmp_path / "home"
hermes_home = tmp_path / "hermes_home"
project = tmp_path / "project"
pathdir = tmp_path / "pathbin"
for p in (home, hermes_home, project, pathdir):
p.mkdir(parents=True, exist_ok=True)
monkeypatch.setenv("HOME", str(home))
monkeypatch.setenv("USERPROFILE", str(home))
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setenv("PATH", str(pathdir))
# Point the module's PROJECT_ROOT at our temp project so the venv probe is
# deterministic regardless of where the test runs.
monkeypatch.setattr(mu, "PROJECT_ROOT", project)
# POSIX layout for the test (resolution order is identical cross-platform;
# only the exe name / bin subdir differ, covered by _uv_exe_name()).
monkeypatch.setattr(mu, "_is_windows", lambda: False)
return {
"home": home,
"hermes_home": hermes_home,
"project": project,
"pathdir": pathdir,
}
def test_resolve_none_when_no_uv_anywhere(env):
assert mu.resolve_uv() is None
def test_resolve_falls_back_to_path(env):
path_uv = _make_uv(env["pathdir"] / "uv", "PATH")
assert mu.resolve_uv() == path_uv
def test_managed_beats_path(env):
_make_uv(env["pathdir"] / "uv", "PATH")
managed = _make_uv(env["hermes_home"] / "bin" / "uv", "MANAGED")
assert mu.resolve_uv() == managed
def test_venv_beats_path(env):
_make_uv(env["pathdir"] / "uv", "PATH")
venv_uv = _make_uv(env["project"] / "venv" / "bin" / "uv", "VENV")
assert mu.resolve_uv() == venv_uv
def test_managed_beats_venv(env):
_make_uv(env["project"] / "venv" / "bin" / "uv", "VENV")
managed = _make_uv(env["hermes_home"] / "bin" / "uv", "MANAGED")
assert mu.resolve_uv() == managed
def test_local_bin_beats_path(env):
_make_uv(env["pathdir"] / "uv", "PATH")
local_uv = _make_uv(env["home"] / ".local" / "bin" / "uv", "LOCAL")
assert mu.resolve_uv() == local_uv
def test_venv_beats_local_bin(env):
_make_uv(env["home"] / ".local" / "bin" / "uv", "LOCAL")
venv_uv = _make_uv(env["project"] / "venv" / "bin" / "uv", "VENV")
assert mu.resolve_uv() == venv_uv
def test_hermes_bin_dir_is_under_hermes_home(env):
assert mu.hermes_bin_dir() == env["hermes_home"] / "bin"
def test_ensure_uv_no_install_returns_resolved(env):
path_uv = _make_uv(env["pathdir"] / "uv", "PATH")
assert mu.ensure_uv(install_if_missing=False) == path_uv
def test_ensure_uv_no_install_returns_none_when_missing(env):
assert mu.ensure_uv(install_if_missing=False) is None
def test_ensure_uv_installs_when_missing(env, monkeypatch):
called = {}
def fake_install(dest: Path):
called["dest"] = dest
return str(dest / "uv")
monkeypatch.setattr(mu, "_install_standalone_uv", fake_install)
result = mu.ensure_uv(install_if_missing=True)
assert result == str(env["hermes_home"] / "bin" / "uv")
assert called["dest"] == env["hermes_home"] / "bin"
def test_ensure_uv_skips_install_when_resolved(env, monkeypatch):
_make_uv(env["hermes_home"] / "bin" / "uv", "MANAGED")
monkeypatch.setattr(
mu, "_install_standalone_uv", lambda dest: pytest.fail("should not install")
)
result = mu.ensure_uv(install_if_missing=True)
assert result is not None and result.endswith("/bin/uv")
def test_non_executable_file_is_skipped(env):
# A uv file that exists but isn't executable must not be selected.
managed = env["hermes_home"] / "bin" / "uv"
managed.parent.mkdir(parents=True, exist_ok=True)
managed.write_text("not executable")
managed.chmod(0o644)
path_uv = _make_uv(env["pathdir"] / "uv", "PATH")
assert mu.resolve_uv() == path_uv