mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-11 12:48:54 +08:00
Compare commits
1 Commits
feat/apify
...
hermes/her
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eebed21070 |
@@ -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
181
hermes_cli/managed_uv.py
Normal 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())
|
||||
136
tests/hermes_cli/test_managed_uv.py
Normal file
136
tests/hermes_cli/test_managed_uv.py
Normal 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
|
||||
Reference in New Issue
Block a user