1502 lines
63 KiB
Python
1502 lines
63 KiB
Python
"""Tests for hermes_cli.managed_uv — one path, no guessing."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import stat
|
|
import sys
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _make_executable(path: Path) -> None:
|
|
"""Create a minimal fake uv binary at *path*."""
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text("#!/bin/sh\necho uv 0.1.2\n")
|
|
path.chmod(path.stat().st_mode | stat.S_IEXEC)
|
|
|
|
|
|
def _runtime_info(
|
|
executable: Path,
|
|
sqlite_version: tuple[int, int, int],
|
|
):
|
|
from hermes_cli.sqlite_runtime import SQLiteRuntimeInfo
|
|
|
|
return SQLiteRuntimeInfo(
|
|
executable=executable,
|
|
base_prefix=executable.parent.parent,
|
|
python_version=(3, 11, 15),
|
|
sqlite_version=sqlite_version,
|
|
sqlite_version_string=".".join(str(part) for part in sqlite_version),
|
|
sqlite_source_id=f"source-{sqlite_version}",
|
|
)
|
|
|
|
|
|
def _RRR(status):
|
|
"""not-applicable RuntimeRepairResult for tests that neutralize the
|
|
repair hook. ensure_uv()/update_managed_uv() invoke runtime repair as a
|
|
side effect; unmocked, it probes the REAL checkout's venv/.venv — on CI
|
|
the repo .venv links vulnerable SQLite, so repair fires for real and
|
|
re-invokes _install_uv (uv-refresh retry), breaking call-count asserts.
|
|
"""
|
|
from hermes_cli.managed_uv import RuntimeRepairResult
|
|
|
|
return RuntimeRepairResult(status)
|
|
|
|
|
|
def _make_runtime_install(
|
|
tmp_path: Path,
|
|
*,
|
|
windows: bool = False,
|
|
) -> tuple[Path, Path, Path]:
|
|
root = tmp_path / "checkout"
|
|
root.mkdir()
|
|
(root / "pyproject.toml").write_text("[project]\n", encoding="utf-8")
|
|
live = root / "venv"
|
|
bin_dir = live / ("Scripts" if windows else "bin")
|
|
bin_dir.mkdir(parents=True)
|
|
python = bin_dir / ("python.exe" if windows else "python")
|
|
python.write_text("live interpreter", encoding="utf-8")
|
|
sentinel = live / "sentinel"
|
|
sentinel.write_text("live", encoding="utf-8")
|
|
return root, live, sentinel
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# managed_uv_path
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestManagedUvPath:
|
|
# POSIX arm of the name mapping; the Windows arm (uv.exe) is exercised
|
|
# for real by TestEnsureUvWindowsSafe on the Windows lane.
|
|
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX-only: bin/uv name")
|
|
def test_posix(self, tmp_path):
|
|
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path):
|
|
from hermes_cli.managed_uv import managed_uv_path
|
|
assert managed_uv_path() == tmp_path / "bin" / "uv"
|
|
|
|
|
|
class TestMacOSManagedPythonSigning:
|
|
def test_signs_with_stable_identifier_and_verifies(self, tmp_path, monkeypatch):
|
|
import hermes_cli.managed_uv as managed_uv
|
|
|
|
python = tmp_path / "generation" / "bin" / "python3.11"
|
|
python.parent.mkdir(parents=True)
|
|
python.touch()
|
|
calls = []
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
calls.append((cmd, kwargs))
|
|
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
|
|
|
monkeypatch.setattr(managed_uv.platform, "system", lambda: "Darwin")
|
|
monkeypatch.setattr(managed_uv.shutil, "which", lambda name: "/usr/bin/codesign")
|
|
monkeypatch.setattr(managed_uv.subprocess, "run", fake_run)
|
|
|
|
assert managed_uv._macos_sign_managed_python(python) is True
|
|
assert calls[0][0] == [
|
|
"/usr/bin/codesign",
|
|
"--force",
|
|
"--deep",
|
|
"--sign",
|
|
"-",
|
|
"--timestamp=none",
|
|
"--identifier",
|
|
"com.nousresearch.hermes.managed-python",
|
|
"--requirements",
|
|
'=designated => identifier "com.nousresearch.hermes.managed-python"',
|
|
str(python),
|
|
]
|
|
assert calls[1][0] == [
|
|
"/usr/bin/codesign",
|
|
"--verify",
|
|
"--deep",
|
|
"--strict",
|
|
str(python),
|
|
]
|
|
|
|
def test_is_non_blocking_when_signing_fails(self, tmp_path, monkeypatch):
|
|
import hermes_cli.managed_uv as managed_uv
|
|
|
|
python = tmp_path / "python3.11"
|
|
monkeypatch.setattr(managed_uv.platform, "system", lambda: "Darwin")
|
|
monkeypatch.setattr(managed_uv.shutil, "which", lambda name: "/usr/bin/codesign")
|
|
monkeypatch.setattr(
|
|
managed_uv.subprocess,
|
|
"run",
|
|
lambda *args, **kwargs: SimpleNamespace(
|
|
returncode=1, stdout="", stderr="not signable"
|
|
),
|
|
)
|
|
|
|
assert managed_uv._macos_sign_managed_python(python) is False
|
|
|
|
def test_skips_non_macos(self, tmp_path, monkeypatch):
|
|
import hermes_cli.managed_uv as managed_uv
|
|
|
|
monkeypatch.setattr(managed_uv.platform, "system", lambda: "Linux")
|
|
monkeypatch.setattr(
|
|
managed_uv.subprocess,
|
|
"run",
|
|
lambda *args, **kwargs: pytest.fail("codesign must not run on Linux"),
|
|
)
|
|
|
|
assert managed_uv._macos_sign_managed_python(tmp_path / "python") is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# resolve_uv
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestResolveUv:
|
|
|
|
def test_existing_executable(self, tmp_path):
|
|
_make_executable(tmp_path / "bin" / "uv")
|
|
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path):
|
|
from hermes_cli.managed_uv import resolve_uv
|
|
result = resolve_uv()
|
|
assert result == str(tmp_path / "bin" / "uv")
|
|
|
|
def test_non_executable_file_returns_none(self, tmp_path):
|
|
uv = tmp_path / "bin" / "uv"
|
|
uv.parent.mkdir(parents=True)
|
|
uv.write_text("not a binary")
|
|
# Ensure no execute bit
|
|
uv.chmod(0o644)
|
|
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path):
|
|
from hermes_cli.managed_uv import resolve_uv
|
|
assert resolve_uv() is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ensure_uv
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestEnsureUv:
|
|
|
|
def test_installs_if_missing(self, tmp_path):
|
|
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
|
|
patch("hermes_cli.managed_uv.repair_vulnerable_runtime", return_value=_RRR("not-applicable")), \
|
|
patch("hermes_cli.managed_uv._install_uv") as mock_install:
|
|
# Simulate the installer creating the binary
|
|
def fake_install(target):
|
|
_make_executable(target)
|
|
mock_install.side_effect = fake_install
|
|
|
|
from hermes_cli.managed_uv import ensure_uv
|
|
path = ensure_uv()
|
|
assert path == str(tmp_path / "bin" / "uv")
|
|
mock_install.assert_called_once()
|
|
|
|
def test_install_reports_runtime_repair_to_observer(self, tmp_path):
|
|
from hermes_cli.managed_uv import (
|
|
RuntimeRepairResult,
|
|
ensure_uv,
|
|
)
|
|
|
|
repair = RuntimeRepairResult(
|
|
"repaired",
|
|
sqlite_before="3.50.4",
|
|
sqlite_after="3.53.1",
|
|
)
|
|
|
|
def fake_install(target):
|
|
_make_executable(target)
|
|
|
|
observed = []
|
|
with patch(
|
|
"hermes_cli.managed_uv.get_hermes_home",
|
|
return_value=tmp_path,
|
|
), patch(
|
|
"hermes_cli.managed_uv._install_uv",
|
|
side_effect=fake_install,
|
|
), patch(
|
|
"hermes_cli.managed_uv.repair_vulnerable_runtime",
|
|
return_value=repair,
|
|
):
|
|
path = ensure_uv(repair_observer=observed.append)
|
|
|
|
assert path == str(tmp_path / "bin" / "uv")
|
|
assert observed == [repair]
|
|
|
|
|
|
@pytest.mark.skipif(sys.platform == "win32",
|
|
reason="POSIX-only: the _UvResult dual contract is not offered on Windows")
|
|
class TestEnsureUvUpdateBoundary:
|
|
"""``ensure_uv()`` must answer to both the single-value and the legacy
|
|
``(path, fresh_bootstrap)`` call conventions — **on POSIX**.
|
|
|
|
``hermes update`` runs the call site from the old, already-imported
|
|
``hermes_cli.main`` against the freshly pulled ``managed_uv``. A release
|
|
parked on a ``(path, fresh)`` tuple runs ``uv_bin, fresh = ensure_uv()``
|
|
against the single-value module; the path is an iterable ``str`` so the
|
|
2-target unpack walked its characters and raised
|
|
``ValueError: too many values to unpack (expected 2)`` (root cause behind
|
|
PR #39763), or ``TypeError`` on the ``None`` failure path. On POSIX the
|
|
result must therefore be usable as a bare path *and* unpackable as a
|
|
2-tuple, in both the success and failure cases.
|
|
|
|
The dual contract is intentionally **not** offered on Windows — see
|
|
``TestEnsureUvWindowsSafe`` for why — so these tests are POSIX-only: the
|
|
host's real ``platform.system()`` selects the wrapper branch, nothing is
|
|
faked.
|
|
"""
|
|
|
|
def test_success_usable_as_single_value(self, tmp_path):
|
|
_make_executable(tmp_path / "bin" / "uv")
|
|
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
|
|
patch("hermes_cli.managed_uv.repair_vulnerable_runtime", return_value=_RRR("not-applicable")):
|
|
from hermes_cli.managed_uv import ensure_uv
|
|
uv_bin = ensure_uv()
|
|
assert uv_bin == str(tmp_path / "bin" / "uv")
|
|
assert bool(uv_bin) is True
|
|
|
|
def test_success_unpacks_as_legacy_two_tuple(self, tmp_path):
|
|
_make_executable(tmp_path / "bin" / "uv")
|
|
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
|
|
patch("hermes_cli.managed_uv.repair_vulnerable_runtime", return_value=_RRR("not-applicable")):
|
|
from hermes_cli.managed_uv import ensure_uv
|
|
uv_bin, fresh = ensure_uv() # old: uv_bin, fresh_bootstrap = ensure_uv()
|
|
assert uv_bin == str(tmp_path / "bin" / "uv")
|
|
assert fresh is False
|
|
|
|
def test_failure_unpacks_without_raising(self, tmp_path):
|
|
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
|
|
patch("hermes_cli.managed_uv.repair_vulnerable_runtime", return_value=_RRR("not-applicable")), \
|
|
patch("hermes_cli.managed_uv._install_uv", side_effect=RuntimeError("network down")):
|
|
from hermes_cli.managed_uv import ensure_uv
|
|
uv_bin, fresh = ensure_uv()
|
|
assert uv_bin is None
|
|
assert fresh is False
|
|
|
|
|
|
class TestEnsureUvWindowsSafe:
|
|
"""On Windows ``ensure_uv()`` must return a plain ``str``/``None``.
|
|
|
|
``subprocess`` on Windows serializes argv through
|
|
``subprocess.list2cmdline``, which iterates every entry *as a string*
|
|
(``for c in arg``). The dependency installer feeds uv straight into the
|
|
command list (``[uv_bin, "pip", "install", ...]``). A ``str`` subclass
|
|
whose ``__iter__`` yields ``(path, fresh_bootstrap)`` instead of characters
|
|
therefore injects the bool into the command line and crashes the install
|
|
with ``TypeError: sequence item 1: expected str instance, bool found``
|
|
(a real field report on a 10-commits-behind Windows install). A single
|
|
return value cannot serve both the legacy 2-tuple unpack and Windows
|
|
char-iteration — both use the iterator protocol — so Windows opts out of
|
|
the wrapper entirely.
|
|
"""
|
|
|
|
def test_uvresult_would_break_windows_list2cmdline(self):
|
|
# Canary: this is *why* the wrapper is gated off Windows. If a future
|
|
# change makes _UvResult char-iterable (and thus list2cmdline-safe),
|
|
# the gate may be revisited.
|
|
import subprocess
|
|
from hermes_cli.managed_uv import _UvResult
|
|
with pytest.raises(TypeError):
|
|
subprocess.list2cmdline([_UvResult("C:\\hermes\\uv.exe"), "pip"])
|
|
|
|
@pytest.mark.windows_only
|
|
def test_windows_returns_plain_str_safe_for_subprocess(self, tmp_path):
|
|
"""``windows_only``: the subject is the real Windows opt-out branch and
|
|
``subprocess.list2cmdline`` — the faked ``platform.system`` only ever
|
|
proved the branch existed, not that the field crash was fixed on the
|
|
host that reported it."""
|
|
import subprocess
|
|
# On Windows the managed binary is uv.exe.
|
|
_make_executable(tmp_path / "bin" / "uv.exe")
|
|
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
|
|
patch("hermes_cli.managed_uv.repair_vulnerable_runtime", return_value=_RRR("not-applicable")):
|
|
from hermes_cli.managed_uv import _UvResult, ensure_uv
|
|
uv_bin = ensure_uv()
|
|
assert type(uv_bin) is str and not isinstance(uv_bin, _UvResult)
|
|
# The exact operation that crashed in the field must now succeed.
|
|
cmdline = subprocess.list2cmdline([uv_bin, "pip", "install", "-e", "."])
|
|
assert "pip" in cmdline and "install" in cmdline
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# update_managed_uv
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestUpdateManagedUv:
|
|
|
|
|
|
|
|
def test_fresh_stamp_skips_network_self_update_but_not_repair(self, tmp_path, monkeypatch):
|
|
"""A recent success stamp must skip `uv self update` entirely while the
|
|
vulnerable-runtime repair probe still runs (CVE repair is never gated)."""
|
|
from hermes_cli.managed_uv import RuntimeRepairResult, update_managed_uv
|
|
|
|
uv = tmp_path / "bin" / "uv"
|
|
_make_executable(uv)
|
|
# Fresh stamp under the isolated HERMES_HOME.
|
|
import hermes_constants
|
|
stamp = hermes_constants.get_hermes_home() / "cache" / ".uv_self_update_stamp"
|
|
stamp.parent.mkdir(parents=True, exist_ok=True)
|
|
stamp.touch()
|
|
|
|
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
|
|
patch("hermes_cli.managed_uv.subprocess.run") as mock_run, \
|
|
patch(
|
|
"hermes_cli.managed_uv.repair_vulnerable_runtime",
|
|
return_value=RuntimeRepairResult("skipped"),
|
|
) as mock_repair:
|
|
result = update_managed_uv()
|
|
|
|
assert result == str(uv)
|
|
assert mock_run.call_count == 0, "fresh stamp must skip the network self-update"
|
|
mock_repair.assert_called_once_with(str(uv))
|
|
|
|
|
|
def test_stale_stamp_runs_self_update_and_refreshes_stamp(self, tmp_path):
|
|
import os as _os
|
|
import time as _time
|
|
|
|
from hermes_cli.managed_uv import UV_SELF_UPDATE_INTERVAL_SECONDS, update_managed_uv
|
|
|
|
uv = tmp_path / "bin" / "uv"
|
|
_make_executable(uv)
|
|
import hermes_constants
|
|
stamp = hermes_constants.get_hermes_home() / "cache" / ".uv_self_update_stamp"
|
|
stamp.parent.mkdir(parents=True, exist_ok=True)
|
|
stamp.touch()
|
|
old = _time.time() - UV_SELF_UPDATE_INTERVAL_SECONDS - 60
|
|
_os.utime(stamp, (old, old))
|
|
|
|
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
|
|
patch("hermes_cli.managed_uv.repair_vulnerable_runtime", return_value=_RRR("not-applicable")), \
|
|
patch("hermes_cli.managed_uv.subprocess.run") as mock_run:
|
|
mock_run.return_value = MagicMock(returncode=0, stdout="uv 0.2.0")
|
|
update_managed_uv()
|
|
|
|
assert mock_run.call_args_list[0][0][0] == [str(uv), "self", "update"]
|
|
assert stamp.stat().st_mtime > old + 30, "successful self-update must refresh the stamp"
|
|
|
|
|
|
|
|
|
|
class TestManagedPythonStore:
|
|
def test_store_is_checkout_scoped_across_profiles(self, tmp_path, monkeypatch):
|
|
from hermes_cli.managed_uv import managed_python_install_dir
|
|
|
|
checkout = tmp_path / "checkout"
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "profiles" / "alpha"))
|
|
alpha = managed_python_install_dir(checkout)
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "profiles" / "beta"))
|
|
beta = managed_python_install_dir(checkout)
|
|
|
|
expected = checkout / ".hermes-runtime" / "python"
|
|
assert alpha == expected
|
|
assert beta == expected
|
|
|
|
def test_environment_is_private_and_sanitized(self, tmp_path):
|
|
from hermes_cli.managed_uv import managed_python_env
|
|
|
|
checkout = tmp_path / "checkout"
|
|
base_env = {
|
|
"KEEP_ME": "yes",
|
|
"CONDA_DEFAULT_ENV": "poison",
|
|
"CONDA_PREFIX": "/poison/conda",
|
|
"UV_PROJECT_ENVIRONMENT": "/poison/project",
|
|
"UV_NO_MANAGED_PYTHON": "1",
|
|
"UV_PYTHON": "/poison/python",
|
|
"UV_PYTHON_DOWNLOADS": "never",
|
|
"UV_SYSTEM_PYTHON": "1",
|
|
"VIRTUAL_ENV": "/poison/venv",
|
|
"PYTHONHOME": "/poison/home",
|
|
"PYTHONPATH": "/poison/path",
|
|
}
|
|
|
|
env = managed_python_env(checkout, base_env=base_env)
|
|
|
|
assert env["KEEP_ME"] == "yes"
|
|
assert env["UV_MANAGED_PYTHON"] == "1"
|
|
assert env["UV_NO_CONFIG"] == "1"
|
|
assert env["UV_PYTHON_INSTALL_BIN"] == "0"
|
|
assert env["UV_PYTHON_INSTALL_REGISTRY"] == "0"
|
|
assert env["UV_PYTHON_INSTALL_DIR"] == str(
|
|
checkout / ".hermes-runtime" / "python"
|
|
)
|
|
for key in (
|
|
"CONDA_DEFAULT_ENV",
|
|
"CONDA_PREFIX",
|
|
"UV_PROJECT_ENVIRONMENT",
|
|
"UV_NO_MANAGED_PYTHON",
|
|
"UV_PYTHON",
|
|
"UV_PYTHON_DOWNLOADS",
|
|
"UV_SYSTEM_PYTHON",
|
|
"VIRTUAL_ENV",
|
|
"PYTHONHOME",
|
|
"PYTHONPATH",
|
|
):
|
|
assert key not in env
|
|
assert base_env["PYTHONHOME"] == "/poison/home"
|
|
|
|
|
|
@pytest.mark.skipif(sys.platform == "win32",
|
|
reason="POSIX-only: fixtures build the bin/ (not Scripts/) venv layout")
|
|
class TestRuntimeRepair:
|
|
def test_safe_runtime_is_a_noop(self, tmp_path):
|
|
from hermes_cli.managed_uv import repair_vulnerable_runtime
|
|
|
|
root, live, sentinel = _make_runtime_install(tmp_path)
|
|
current = _runtime_info(live / "bin" / "python", (3, 53, 1))
|
|
with patch(
|
|
"hermes_cli.managed_uv.probe_sqlite_runtime",
|
|
return_value=current,
|
|
), \
|
|
patch(
|
|
"hermes_cli.managed_uv._install_safe_python_generation"
|
|
) as mock_install:
|
|
result = repair_vulnerable_runtime("uv", project_root=root)
|
|
|
|
assert result.status == "safe"
|
|
assert result.sqlite_before == "3.53.1"
|
|
assert result.sqlite_after == "3.53.1"
|
|
assert sentinel.read_text(encoding="utf-8") == "live"
|
|
assert not (root / ".hermes-runtime").exists()
|
|
mock_install.assert_not_called()
|
|
|
|
def test_stage_candidate_sync_keeps_uv_project_config(self, tmp_path):
|
|
from hermes_cli.managed_uv import _stage_candidate_venv
|
|
|
|
root = tmp_path / "checkout"
|
|
root.mkdir()
|
|
(root / "uv.lock").write_text("# lock\n", encoding="utf-8")
|
|
generation = root / ".hermes-runtime" / "python" / "gen"
|
|
python = generation / "bin" / "python"
|
|
python.parent.mkdir(parents=True)
|
|
python.write_text("py", encoding="utf-8")
|
|
|
|
calls = []
|
|
|
|
def fake_run(argv, **kwargs):
|
|
calls.append((list(argv), kwargs.get("env")))
|
|
return MagicMock(returncode=0)
|
|
|
|
with patch("hermes_cli.managed_uv.subprocess.run", side_effect=fake_run), \
|
|
patch(
|
|
"hermes_cli.managed_uv._smoke_candidate_venv",
|
|
return_value=(True, "", None),
|
|
):
|
|
candidate = _stage_candidate_venv(
|
|
"uv",
|
|
project_root=root,
|
|
generation=generation,
|
|
python=python,
|
|
)
|
|
|
|
assert candidate is not None
|
|
assert len(calls) == 2
|
|
venv_argv, venv_env = calls[0]
|
|
sync_argv, sync_env = calls[1]
|
|
assert venv_argv[:2] == ["uv", "venv"]
|
|
assert "--no-config" in venv_argv
|
|
assert venv_env.get("UV_NO_CONFIG") == "1"
|
|
assert sync_argv[:2] == ["uv", "sync"]
|
|
assert "--locked" in sync_argv
|
|
assert "--no-config" not in sync_argv
|
|
assert "UV_NO_CONFIG" not in sync_env
|
|
|
|
def test_failed_candidate_preserves_live_venv(self, tmp_path):
|
|
from hermes_cli.managed_uv import (
|
|
_acquire_repair_lock,
|
|
_release_repair_lock,
|
|
repair_vulnerable_runtime,
|
|
)
|
|
|
|
root, live, sentinel = _make_runtime_install(tmp_path)
|
|
current = _runtime_info(live / "bin" / "python", (3, 50, 4))
|
|
generation = root / ".hermes-runtime" / "python" / "generation-test"
|
|
candidate_python = generation / "bin" / "python"
|
|
candidate_python.parent.mkdir(parents=True)
|
|
candidate_python.write_text("candidate interpreter", encoding="utf-8")
|
|
fixed = _runtime_info(candidate_python, (3, 53, 1))
|
|
|
|
with patch(
|
|
"hermes_cli.managed_uv.probe_sqlite_runtime",
|
|
side_effect=[current, current],
|
|
), \
|
|
patch(
|
|
"hermes_cli.managed_uv._install_safe_python_generation",
|
|
return_value=(generation, candidate_python, fixed),
|
|
), \
|
|
patch(
|
|
"hermes_cli.managed_uv._stage_candidate_venv",
|
|
return_value=None,
|
|
):
|
|
result = repair_vulnerable_runtime("uv", project_root=root)
|
|
|
|
assert result.status == "failed"
|
|
assert "replacement environment" in result.detail
|
|
assert sentinel.read_text(encoding="utf-8") == "live"
|
|
assert (live / "bin" / "python").read_text(encoding="utf-8") == (
|
|
"live interpreter"
|
|
)
|
|
assert not generation.exists()
|
|
reacquired = _acquire_repair_lock(root / ".hermes-runtime")
|
|
assert reacquired is not None
|
|
_release_repair_lock(reacquired)
|
|
|
|
def test_safe_runtime_sweeps_old_stale_backups(self, tmp_path):
|
|
"""A fixed runtime reclaims aged venv.stale.runtime-* leftovers
|
|
(issue #73109) but leaves fresh ones (possible in-flight repair)."""
|
|
import os
|
|
import time as _time
|
|
|
|
from hermes_cli.managed_uv import repair_vulnerable_runtime
|
|
|
|
root, live, sentinel = _make_runtime_install(tmp_path)
|
|
old_backup = root / f"{live.name}.stale.runtime-1-2-aaaa"
|
|
(old_backup / "bin").mkdir(parents=True)
|
|
(old_backup / "bin" / "python").write_text("old", encoding="utf-8")
|
|
stale_mtime = _time.time() - 7200
|
|
os.utime(old_backup, (stale_mtime, stale_mtime))
|
|
|
|
fresh_backup = root / f"{live.name}.stale.runtime-9-9-bbbb"
|
|
(fresh_backup / "bin").mkdir(parents=True)
|
|
|
|
current = _runtime_info(live / "bin" / "python", (3, 53, 1))
|
|
with patch(
|
|
"hermes_cli.managed_uv.probe_sqlite_runtime",
|
|
return_value=current,
|
|
):
|
|
result = repair_vulnerable_runtime("uv", project_root=root)
|
|
|
|
assert result.status == "safe"
|
|
assert not old_backup.exists(), "aged stale backup must be reclaimed"
|
|
assert fresh_backup.exists(), "fresh backup may be an in-flight repair"
|
|
assert sentinel.read_text(encoding="utf-8") == "live"
|
|
|
|
def test_successful_repair_removes_parked_backup(self, tmp_path):
|
|
"""After a successful cutover the parked venv is removed instead of
|
|
leaking ~1 GB at the project root forever (issue #73109)."""
|
|
from hermes_cli.managed_uv import repair_vulnerable_runtime
|
|
|
|
root, live, sentinel = _make_runtime_install(tmp_path)
|
|
current = _runtime_info(live / "bin" / "python", (3, 50, 4))
|
|
generation = root / ".hermes-runtime" / "python" / "generation-test"
|
|
candidate_python = generation / "bin" / "python"
|
|
candidate_python.parent.mkdir(parents=True)
|
|
candidate_python.write_text("candidate interpreter", encoding="utf-8")
|
|
fixed = _runtime_info(candidate_python, (3, 53, 1))
|
|
candidate_venv = root / ".hermes-runtime" / "venv-candidate"
|
|
(candidate_venv / "bin").mkdir(parents=True)
|
|
(candidate_venv / "bin" / "python").write_text(
|
|
"candidate venv interpreter", encoding="utf-8"
|
|
)
|
|
|
|
with patch(
|
|
"hermes_cli.managed_uv.probe_sqlite_runtime",
|
|
side_effect=[current, current],
|
|
), \
|
|
patch(
|
|
"hermes_cli.managed_uv._install_safe_python_generation",
|
|
return_value=(generation, candidate_python, fixed),
|
|
), \
|
|
patch(
|
|
"hermes_cli.managed_uv._stage_candidate_venv",
|
|
return_value=candidate_venv,
|
|
), \
|
|
patch(
|
|
"hermes_cli.managed_uv._smoke_candidate_venv",
|
|
return_value=(True, "", fixed),
|
|
):
|
|
result = repair_vulnerable_runtime("uv", project_root=root)
|
|
|
|
assert result.status == "repaired"
|
|
assert result.backup_venv is not None
|
|
assert not result.backup_venv.exists(), (
|
|
"parked venv must be removed after a successful repair"
|
|
)
|
|
leftovers = list(root.glob(f"{live.name}.stale.runtime-*"))
|
|
assert leftovers == [], f"no stale markers may remain: {leftovers}"
|
|
|
|
|
|
class TestRuntimeCutover:
|
|
def test_os_lock_blocks_concurrent_repair_and_releases(self, tmp_path):
|
|
from hermes_cli.managed_uv import _acquire_repair_lock, _release_repair_lock
|
|
|
|
runtime_root = tmp_path / ".hermes-runtime"
|
|
first = _acquire_repair_lock(runtime_root)
|
|
assert first is not None
|
|
assert _acquire_repair_lock(runtime_root) is None
|
|
|
|
_release_repair_lock(first)
|
|
second = _acquire_repair_lock(runtime_root)
|
|
assert second is not None
|
|
_release_repair_lock(second)
|
|
|
|
|
|
|
|
def test_post_swap_smoke_failure_rolls_back_live_venv(self, tmp_path):
|
|
from hermes_cli.managed_uv import _cut_over_candidate
|
|
|
|
root, live, sentinel = _make_runtime_install(tmp_path)
|
|
runtime_root = root / ".hermes-runtime"
|
|
candidate = runtime_root / "venv-candidate-test"
|
|
candidate.mkdir(parents=True)
|
|
(candidate / "sentinel").write_text("candidate", encoding="utf-8")
|
|
rejected_info = _runtime_info(candidate / "bin" / "python", (3, 50, 4))
|
|
|
|
with patch(
|
|
"hermes_cli.managed_uv._smoke_candidate_venv",
|
|
return_value=(False, "core import smoke failed", rejected_info),
|
|
):
|
|
ok, backup, info, detail = _cut_over_candidate(
|
|
candidate,
|
|
project_root=root,
|
|
)
|
|
|
|
assert ok is False
|
|
assert backup is None
|
|
assert info == rejected_info
|
|
assert "post-cutover smoke failed" in detail
|
|
assert sentinel.read_text(encoding="utf-8") == "live"
|
|
assert (live / "bin" / "python").read_text(encoding="utf-8") == (
|
|
"live interpreter"
|
|
)
|
|
assert not candidate.exists()
|
|
assert not list(runtime_root.glob("venv-rejected-*"))
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _install_uv internals
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestInstallUvInternals:
|
|
def test_posix_sets_uv_unmanaged_install(self, tmp_path):
|
|
target = tmp_path / "bin" / "uv"
|
|
with patch("hermes_cli.managed_uv._install_uv_posix") as mock_posix:
|
|
from hermes_cli.managed_uv import _install_uv
|
|
_install_uv(target)
|
|
mock_posix.assert_called_once()
|
|
call_env = mock_posix.call_args[0][0]
|
|
assert call_env["UV_UNMANAGED_INSTALL"] == str(tmp_path / "bin")
|
|
|
|
|
|
class TestRuntimeRequestMinorLine:
|
|
"""The repair must request the CPython minor line, not the exact patch.
|
|
|
|
Real-world constraint (verified live, July 2026): every published
|
|
python-build-standalone artifact for 3.11.14 links vulnerable SQLite
|
|
3.50.4 — even with --reinstall. The fixed SQLite (3.53.1) only exists
|
|
from 3.11.15. An exact-patch pin makes the repair permanently
|
|
impossible on such installs.
|
|
"""
|
|
|
|
def test_requests_minor_line(self):
|
|
from hermes_cli.managed_uv import _runtime_request
|
|
|
|
info = _runtime_info(Path("/venv/bin/python"), (3, 50, 4))
|
|
assert _runtime_request(info) == "3.11"
|
|
|
|
@staticmethod
|
|
def _run_generation(tmp_path, monkeypatch, current_version, candidate_version):
|
|
"""Drive _install_safe_python_generation with fakes; return result."""
|
|
import hermes_cli.managed_uv as managed_uv
|
|
from hermes_cli.sqlite_runtime import SQLiteRuntimeInfo
|
|
|
|
state = {}
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
if "install" in cmd:
|
|
state["generation"] = Path(kwargs["env"]["UV_PYTHON_INSTALL_DIR"])
|
|
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
|
# uv python find → a path inside the generation dir
|
|
python = state["generation"] / "cpython" / "bin" / "python3"
|
|
python.parent.mkdir(parents=True, exist_ok=True)
|
|
python.touch()
|
|
return SimpleNamespace(returncode=0, stdout=str(python), stderr="")
|
|
|
|
def fake_probe(python, **kwargs):
|
|
return SQLiteRuntimeInfo(
|
|
executable=Path(python),
|
|
base_prefix=Path(python).parent.parent,
|
|
python_version=candidate_version,
|
|
sqlite_version=(3, 53, 1),
|
|
sqlite_version_string="3.53.1",
|
|
sqlite_source_id="fixed",
|
|
)
|
|
|
|
current = SQLiteRuntimeInfo(
|
|
executable=Path("/venv/bin/python"),
|
|
base_prefix=Path("/venv"),
|
|
python_version=current_version,
|
|
sqlite_version=(3, 50, 4),
|
|
sqlite_version_string="3.50.4",
|
|
sqlite_source_id="old",
|
|
)
|
|
monkeypatch.setattr(managed_uv.subprocess, "run", fake_run)
|
|
monkeypatch.setattr(managed_uv, "probe_sqlite_runtime", fake_probe)
|
|
return managed_uv._install_safe_python_generation(
|
|
"uv", project_root=tmp_path, current=current
|
|
)
|
|
|
|
def test_accepts_newer_patch_same_minor(self, tmp_path, monkeypatch):
|
|
result = self._run_generation(
|
|
tmp_path, monkeypatch, (3, 11, 14), (3, 11, 15)
|
|
)
|
|
assert result is not None
|
|
_, _, candidate = result
|
|
assert candidate.python_version == (3, 11, 15)
|
|
|
|
|
|
class TestPatchRetryOnVulnerableCandidate:
|
|
"""Regression tests for issue #71250: when the bare minor-line request
|
|
(e.g. "3.11") resolves to a candidate that's still vulnerable -- because
|
|
uv's default resolution for that host picked an older cached/indexed
|
|
patch even though a newer non-vulnerable one is available -- the
|
|
provisioner must query the available patches and retry with explicit
|
|
newer versions, rather than giving up after the first attempt.
|
|
"""
|
|
|
|
@staticmethod
|
|
def _versioned_probe_run(vulnerable_versions, sqlite_fixed=(3, 53, 1)):
|
|
"""Build a fake subprocess.run where the install/find/probe cycle
|
|
resolves to a DIFFERENT candidate Python version depending on which
|
|
exact version string was requested, so retries with explicit
|
|
patches can be distinguished from the initial bare-minor attempt."""
|
|
import hermes_cli.managed_uv as managed_uv
|
|
from hermes_cli.sqlite_runtime import SQLiteRuntimeInfo
|
|
|
|
state = {"requested": None}
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
if "install" in cmd:
|
|
# cmd = [uv, "python", "install", <request>, ...]
|
|
state["requested"] = cmd[3]
|
|
state["generation"] = Path(kwargs["env"]["UV_PYTHON_INSTALL_DIR"])
|
|
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
|
if "list" in cmd:
|
|
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
|
# uv python find → a path inside the generation dir, tagged with
|
|
# which request produced it so the probe below can look it up.
|
|
python = state["generation"] / "cpython" / "bin" / "python3"
|
|
python.parent.mkdir(parents=True, exist_ok=True)
|
|
python.write_text(state["requested"] or "")
|
|
return SimpleNamespace(returncode=0, stdout=str(python), stderr="")
|
|
|
|
def fake_probe(python, **kwargs):
|
|
requested = Path(python).read_text()
|
|
# Bare minor request ("3.11") always resolves to the FIRST
|
|
# (worst-case / already-known-vulnerable) version in the list.
|
|
if requested in vulnerable_versions or requested == "3.11":
|
|
version = (3, 11, 14) if requested == "3.11" else tuple(
|
|
int(p) for p in requested.split(".")
|
|
)
|
|
return SQLiteRuntimeInfo(
|
|
executable=Path(python), base_prefix=Path(python).parent.parent,
|
|
python_version=version, sqlite_version=(3, 50, 4),
|
|
sqlite_version_string="3.50.4", sqlite_source_id="vulnerable",
|
|
)
|
|
version = tuple(int(p) for p in requested.split("."))
|
|
return SQLiteRuntimeInfo(
|
|
executable=Path(python), base_prefix=Path(python).parent.parent,
|
|
python_version=version, sqlite_version=sqlite_fixed,
|
|
sqlite_version_string=".".join(str(p) for p in sqlite_fixed),
|
|
sqlite_source_id="fixed",
|
|
)
|
|
|
|
return fake_run, fake_probe
|
|
|
|
def _run(self, tmp_path, monkeypatch, *, vulnerable_versions, patch_list):
|
|
import hermes_cli.managed_uv as managed_uv
|
|
from hermes_cli.sqlite_runtime import SQLiteRuntimeInfo
|
|
|
|
fake_run, fake_probe = self._versioned_probe_run(vulnerable_versions)
|
|
current = SQLiteRuntimeInfo(
|
|
executable=Path("/venv/bin/python"), base_prefix=Path("/venv"),
|
|
python_version=(3, 11, 14), sqlite_version=(3, 50, 4),
|
|
sqlite_version_string="3.50.4", sqlite_source_id="old",
|
|
)
|
|
monkeypatch.setattr(managed_uv.subprocess, "run", fake_run)
|
|
monkeypatch.setattr(managed_uv, "probe_sqlite_runtime", fake_probe)
|
|
monkeypatch.setattr(
|
|
managed_uv, "_list_available_patches", lambda *a, **kw: patch_list
|
|
)
|
|
return managed_uv._install_safe_python_generation(
|
|
"uv", project_root=tmp_path, current=current
|
|
)
|
|
|
|
def test_retries_and_succeeds_with_explicit_newer_patch(self, tmp_path, monkeypatch):
|
|
"""The exact #71250 scenario: bare '3.11' resolves to vulnerable
|
|
3.11.14, but 3.11.15 (fixed) is available and gets tried explicitly."""
|
|
result = self._run(
|
|
tmp_path, monkeypatch,
|
|
vulnerable_versions={"3.11"},
|
|
patch_list=[(3, 11, 15), (3, 11, 14), (3, 11, 13), (3, 11, 12)],
|
|
)
|
|
assert result is not None, "Must recover via explicit-patch retry"
|
|
_, _, candidate = result
|
|
assert candidate.python_version == (3, 11, 15)
|
|
assert not candidate.wal_reset_vulnerable
|
|
|
|
|
|
|
|
|
|
|
|
def test_retry_is_bounded_by_max_retries_constant(self, tmp_path, monkeypatch):
|
|
"""A very long patch list must not result in unbounded retries -- capped at
|
|
_MAX_PATCH_RETRIES attempts. After exhausting same-minor retries the
|
|
fallback tries the next minor line, which may succeed."""
|
|
import hermes_cli.managed_uv as managed_uv
|
|
|
|
from hermes_cli.sqlite_runtime import SQLiteRuntimeInfo
|
|
|
|
current = SQLiteRuntimeInfo(
|
|
executable=Path("/venv/bin/python"), base_prefix=Path("/venv"),
|
|
python_version=(3, 11, 14), sqlite_version=(3, 50, 4),
|
|
sqlite_version_string="3.50.4", sqlite_source_id="old",
|
|
)
|
|
# 20 vulnerable patches -- far more than _MAX_PATCH_RETRIES.
|
|
huge_patch_list = [(3, 11, v) for v in range(30, 10, -1)]
|
|
all_vulnerable = {f"3.11.{v}" for v in range(30, 10, -1)} | {"3.11"}
|
|
fake_run2, fake_probe2 = self._versioned_probe_run(all_vulnerable)
|
|
|
|
install_calls = []
|
|
|
|
def counting_fake_run(cmd, **kwargs):
|
|
if "install" in cmd:
|
|
install_calls.append(cmd[3])
|
|
return fake_run2(cmd, **kwargs)
|
|
|
|
monkeypatch.setattr(managed_uv.subprocess, "run", counting_fake_run)
|
|
monkeypatch.setattr(managed_uv, "probe_sqlite_runtime", fake_probe2)
|
|
monkeypatch.setattr(
|
|
managed_uv, "_list_available_patches", lambda *a, **kw: huge_patch_list
|
|
)
|
|
result = managed_uv._install_safe_python_generation(
|
|
"uv", project_root=tmp_path, current=current
|
|
)
|
|
# The same-minor retries are bounded, but the minor-line fallback
|
|
# (3.11 → 3.12) succeeds because the mock returns a fixed build.
|
|
assert result is not None, (
|
|
"Minor-line fallback should find a fixed 3.12 build"
|
|
)
|
|
# 1 initial bare-minor attempt + at most _MAX_PATCH_RETRIES retries.
|
|
assert managed_uv._MAX_PATCH_RETRIES <= 5, (
|
|
"sanity: constant should stay small since each attempt is a "
|
|
"real download+install+probe cycle"
|
|
)
|
|
same_minor_explicit = [
|
|
call for call in install_calls if call.startswith("3.11.")
|
|
]
|
|
assert len(same_minor_explicit) <= managed_uv._MAX_PATCH_RETRIES, (
|
|
f"same-minor explicit retries must be capped: {same_minor_explicit}"
|
|
)
|
|
assert install_calls[0] == "3.11"
|
|
# The run ends the moment the bare next-minor fallback succeeds.
|
|
assert install_calls[-1] == "3.12"
|
|
assert install_calls.count("3.12") == 1
|
|
|
|
|
|
class TestMinorLineFallForward:
|
|
"""Regression tests for issue #76106: when EVERY build on the current
|
|
minor line (e.g. all of 3.11 on Windows) links a vulnerable SQLite,
|
|
the provisioner must fall forward to the next supported minor line
|
|
(3.12, then 3.13) -- first via a bare minor request, then via explicit
|
|
patches on that line -- instead of leaving the user stuck on every
|
|
`hermes update` with no path to a fixed runtime.
|
|
"""
|
|
|
|
@staticmethod
|
|
def _mapped_run(resolutions, fixed_versions, install_calls):
|
|
"""Fake subprocess.run/probe pair driven by explicit tables:
|
|
|
|
- *resolutions*: request string -> python_version tuple the probe
|
|
reports for that request (bare minors resolve like uv would).
|
|
- *fixed_versions*: set of version tuples that link FIXED SQLite;
|
|
everything else probes as vulnerable 3.50.4.
|
|
- *install_calls*: list collecting each `uv python install` request,
|
|
in order, so tests can assert the actual request sequence.
|
|
"""
|
|
from hermes_cli.sqlite_runtime import SQLiteRuntimeInfo
|
|
|
|
state: dict = {"requested": None}
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
if "install" in cmd:
|
|
# cmd = [uv, "python", "install", <request>, ...]
|
|
state["requested"] = cmd[3]
|
|
state["generation"] = Path(kwargs["env"]["UV_PYTHON_INSTALL_DIR"])
|
|
install_calls.append(cmd[3])
|
|
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
|
if "list" in cmd:
|
|
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
|
# uv python find → a path inside the generation dir, tagged with
|
|
# the request that produced it so the probe can look it up.
|
|
python = state["generation"] / "cpython" / "bin" / "python3"
|
|
python.parent.mkdir(parents=True, exist_ok=True)
|
|
python.write_text(state["requested"] or "")
|
|
return SimpleNamespace(returncode=0, stdout=str(python), stderr="")
|
|
|
|
def fake_probe(python, **kwargs):
|
|
requested = Path(python).read_text()
|
|
version = resolutions[requested]
|
|
if version in fixed_versions:
|
|
return SQLiteRuntimeInfo(
|
|
executable=Path(python),
|
|
base_prefix=Path(python).parent.parent,
|
|
python_version=version, sqlite_version=(3, 53, 1),
|
|
sqlite_version_string="3.53.1", sqlite_source_id="fixed",
|
|
)
|
|
return SQLiteRuntimeInfo(
|
|
executable=Path(python),
|
|
base_prefix=Path(python).parent.parent,
|
|
python_version=version, sqlite_version=(3, 50, 4),
|
|
sqlite_version_string="3.50.4", sqlite_source_id="vulnerable",
|
|
)
|
|
|
|
return fake_run, fake_probe
|
|
|
|
@staticmethod
|
|
def _current_3_11_14():
|
|
from hermes_cli.sqlite_runtime import SQLiteRuntimeInfo
|
|
|
|
return SQLiteRuntimeInfo(
|
|
executable=Path("/venv/bin/python"), base_prefix=Path("/venv"),
|
|
python_version=(3, 11, 14), sqlite_version=(3, 50, 4),
|
|
sqlite_version_string="3.50.4", sqlite_source_id="old",
|
|
)
|
|
|
|
def test_explicit_patch_fallback_when_bare_next_minor_is_vulnerable(
|
|
self, tmp_path, monkeypatch
|
|
):
|
|
"""The review-gap scenario from #76252: the bare '3.12' request
|
|
resolves to a VULNERABLE 3.12 build, but an explicit 3.12 patch
|
|
links fixed SQLite -- the `_list_available_patches(..., '3.12', ...)`
|
|
fallback branch must run, skip the already-tried bare resolution,
|
|
and succeed via the explicit patch."""
|
|
import hermes_cli.managed_uv as managed_uv
|
|
|
|
install_calls = []
|
|
fake_run, fake_probe = self._mapped_run(
|
|
resolutions={
|
|
"3.11": (3, 11, 14), # bare current minor: vulnerable
|
|
"3.12": (3, 12, 11), # bare next minor: ALSO vulnerable
|
|
"3.12.10": (3, 12, 10), # explicit patch: fixed
|
|
},
|
|
fixed_versions={(3, 12, 10)},
|
|
install_calls=install_calls,
|
|
)
|
|
patch_lists = {
|
|
# No newer 3.11 patch exists (the Windows #76106 reality).
|
|
"3.11": [(3, 11, 14), (3, 11, 13)],
|
|
# Newest 3.12 is the same build the bare request resolved to.
|
|
"3.12": [(3, 12, 11), (3, 12, 10)],
|
|
}
|
|
monkeypatch.setattr(managed_uv.subprocess, "run", fake_run)
|
|
monkeypatch.setattr(managed_uv, "probe_sqlite_runtime", fake_probe)
|
|
monkeypatch.setattr(
|
|
managed_uv, "_list_available_patches",
|
|
lambda uv_bin, minor, **kw: patch_lists[minor],
|
|
)
|
|
|
|
result = managed_uv._install_safe_python_generation(
|
|
"uv", project_root=tmp_path, current=self._current_3_11_14()
|
|
)
|
|
assert result is not None, (
|
|
"Explicit-patch fallback on the next minor line must recover"
|
|
)
|
|
_, _, candidate = result
|
|
assert candidate.python_version == (3, 12, 10)
|
|
assert not candidate.wal_reset_vulnerable
|
|
# The actual uv-install request sequence: bare current minor, then
|
|
# bare next minor, then STRAIGHT to the fixed explicit patch --
|
|
# 3.12.11 must NOT be re-requested explicitly, because the bare
|
|
# '3.12' attempt already resolved to (and rejected) that build.
|
|
assert install_calls == ["3.11", "3.12", "3.12.10"]
|
|
|
|
def test_returns_none_with_bounded_attempts_when_all_minors_exhausted(
|
|
self, tmp_path, monkeypatch
|
|
):
|
|
"""When every build on every supported minor line (3.11-3.13) is
|
|
vulnerable, the provisioner must give up with None -- and the total
|
|
install workload must stay bounded by _MAX_PATCH_RETRIES per line."""
|
|
import hermes_cli.managed_uv as managed_uv
|
|
|
|
install_calls = []
|
|
resolutions = {"3.11": (3, 11, 14), "3.12": (3, 12, 30), "3.13": (3, 13, 30)}
|
|
patch_lists = {}
|
|
for minor in (11, 12, 13):
|
|
versions = [(3, minor, v) for v in range(30, 10, -1)] # 20 patches
|
|
patch_lists[f"3.{minor}"] = versions
|
|
for version in versions:
|
|
resolutions[".".join(str(p) for p in version)] = version
|
|
|
|
fake_run, fake_probe = self._mapped_run(
|
|
resolutions=resolutions, fixed_versions=set(),
|
|
install_calls=install_calls,
|
|
)
|
|
monkeypatch.setattr(managed_uv.subprocess, "run", fake_run)
|
|
monkeypatch.setattr(managed_uv, "probe_sqlite_runtime", fake_probe)
|
|
monkeypatch.setattr(
|
|
managed_uv, "_list_available_patches",
|
|
lambda uv_bin, minor, **kw: patch_lists[minor],
|
|
)
|
|
|
|
result = managed_uv._install_safe_python_generation(
|
|
"uv", project_root=tmp_path, current=self._current_3_11_14()
|
|
)
|
|
assert result is None, "Nothing fixed anywhere: must give up cleanly"
|
|
|
|
cap = managed_uv._MAX_PATCH_RETRIES
|
|
# Per line: one bare request + at most _MAX_PATCH_RETRIES explicit
|
|
# patches; three lines total (3.11, 3.12, 3.13) and nothing beyond
|
|
# 3.13 (requires-python is <3.14).
|
|
assert install_calls.count("3.11") == 1
|
|
assert install_calls.count("3.12") == 1
|
|
assert install_calls.count("3.13") == 1
|
|
assert not any(call.startswith("3.14") for call in install_calls)
|
|
for minor in (11, 12, 13):
|
|
explicit = [
|
|
call for call in install_calls
|
|
if call.startswith(f"3.{minor}.")
|
|
]
|
|
assert len(explicit) <= cap, (
|
|
f"3.{minor} explicit retries must be capped at {cap}: {explicit}"
|
|
)
|
|
assert len(install_calls) <= 3 * (1 + cap)
|
|
|
|
|
|
class TestListAvailablePatches:
|
|
"""Direct unit tests for _list_available_patches()'s JSON parsing,
|
|
against realistic `uv python list --all-versions --output-format json`
|
|
output (captured from a real uv 0.11.7 invocation)."""
|
|
|
|
SAMPLE_OUTPUT = (
|
|
'[{"key":"cpython-3.11.15-linux-x86_64-gnu","version":"3.11.15",'
|
|
'"version_parts":{"major":3,"minor":11,"patch":15},"path":null,'
|
|
'"symlink":null,"url":"https://example/cpython-3.11.15.tar.gz",'
|
|
'"os":"linux","variant":"default","implementation":"cpython",'
|
|
'"arch":"x86_64","libc":"gnu"},'
|
|
'{"key":"cpython-3.11.14-linux-x86_64-gnu","version":"3.11.14",'
|
|
'"version_parts":{"major":3,"minor":11,"patch":14},"path":null,'
|
|
'"symlink":null,"url":"https://example/cpython-3.11.14.tar.gz",'
|
|
'"os":"linux","variant":"default","implementation":"cpython",'
|
|
'"arch":"x86_64","libc":"gnu"},'
|
|
'{"key":"pypy-3.11.15-linux-x86_64-gnu","version":"3.11.15",'
|
|
'"version_parts":{"major":3,"minor":11,"patch":15},"path":null,'
|
|
'"symlink":null,"url":"https://example/pypy-3.11.15.tar.gz",'
|
|
'"os":"linux","variant":"default","implementation":"pypy",'
|
|
'"arch":"x86_64","libc":"gnu"}]'
|
|
)
|
|
|
|
def test_parses_and_sorts_newest_first(self, tmp_path, monkeypatch):
|
|
import hermes_cli.managed_uv as managed_uv
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
return SimpleNamespace(returncode=0, stdout=self.SAMPLE_OUTPUT, stderr="")
|
|
|
|
monkeypatch.setattr(managed_uv.subprocess, "run", fake_run)
|
|
result = managed_uv._list_available_patches(
|
|
"uv", "3.11", cwd=tmp_path, env={}
|
|
)
|
|
assert result == [(3, 11, 15), (3, 11, 14)]
|
|
|
|
|
|
def test_subprocess_exception_returns_empty_list(self, tmp_path, monkeypatch):
|
|
import hermes_cli.managed_uv as managed_uv
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
raise OSError("uv binary not found")
|
|
|
|
monkeypatch.setattr(managed_uv.subprocess, "run", fake_run)
|
|
assert managed_uv._list_available_patches("uv", "3.11", cwd=tmp_path, env={}) == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _refresh_managed_uv_catalog + provisioning retry (issue #72093)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestRefreshManagedUvCatalog:
|
|
"""The managed uv is UV_UNMANAGED_INSTALL'd, so `uv self update` is
|
|
disabled and its python-build-standalone catalog freezes at bootstrap
|
|
age. python-build-standalone re-releases the same patch versions with
|
|
fixed SQLite, so a stale catalog makes provisioning fail forever with
|
|
no newer patch number to retry (issue #72093)."""
|
|
|
|
|
|
def test_version_change_reports_true(self, tmp_path):
|
|
import hermes_cli.managed_uv as managed_uv
|
|
|
|
versions = iter(["uv 0.1.0", "uv 0.2.0"])
|
|
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
|
|
patch("hermes_cli.managed_uv._install_uv"), \
|
|
patch(
|
|
"hermes_cli.managed_uv._uv_version_string",
|
|
side_effect=lambda _uv: next(versions),
|
|
):
|
|
# Host-native path: the refresh only acts on the managed binary,
|
|
# so the fixture must live at the real host's managed_uv_path()
|
|
# (uv on POSIX, uv.exe on Windows) — no platform fake needed.
|
|
uv_path = managed_uv.managed_uv_path()
|
|
_make_executable(uv_path)
|
|
assert managed_uv._refresh_managed_uv_catalog(str(uv_path)) is True
|
|
|
|
|
|
def test_installer_failure_reports_false(self, tmp_path):
|
|
import hermes_cli.managed_uv as managed_uv
|
|
|
|
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
|
|
patch(
|
|
"hermes_cli.managed_uv._install_uv",
|
|
side_effect=RuntimeError("network down"),
|
|
):
|
|
uv_path = managed_uv.managed_uv_path()
|
|
_make_executable(uv_path)
|
|
assert managed_uv._refresh_managed_uv_catalog(str(uv_path)) is False
|
|
|
|
|
|
@pytest.mark.skipif(sys.platform == "win32",
|
|
reason="POSIX-only: fixtures build the bin/ (not Scripts/) venv layout")
|
|
class TestRepairRetriesAfterUvRefresh:
|
|
def _run_repair(self, tmp_path, *, refresh_result, second_attempt):
|
|
"""Drive repair with the first provisioning attempt failing."""
|
|
from hermes_cli.managed_uv import repair_vulnerable_runtime
|
|
|
|
root, live, sentinel = _make_runtime_install(tmp_path)
|
|
current = _runtime_info(live / "bin" / "python", (3, 50, 4))
|
|
|
|
attempts = []
|
|
|
|
def fake_install(uv_bin, *, project_root, current):
|
|
attempts.append(uv_bin)
|
|
if len(attempts) == 1:
|
|
return None
|
|
return second_attempt(project_root)
|
|
|
|
with patch(
|
|
"hermes_cli.managed_uv.probe_sqlite_runtime",
|
|
return_value=current,
|
|
), \
|
|
patch(
|
|
"hermes_cli.managed_uv._install_safe_python_generation",
|
|
side_effect=fake_install,
|
|
), \
|
|
patch(
|
|
"hermes_cli.managed_uv._refresh_managed_uv_catalog",
|
|
return_value=refresh_result,
|
|
) as mock_refresh, \
|
|
patch(
|
|
"hermes_cli.managed_uv._stage_candidate_venv",
|
|
return_value=None,
|
|
):
|
|
result = repair_vulnerable_runtime("uv", project_root=root)
|
|
return result, attempts, mock_refresh, sentinel
|
|
|
|
def test_no_retry_when_refresh_did_not_change_uv(self, tmp_path):
|
|
result, attempts, mock_refresh, sentinel = self._run_repair(
|
|
tmp_path,
|
|
refresh_result=False,
|
|
second_attempt=lambda root: None,
|
|
)
|
|
assert result.status == "failed"
|
|
assert len(attempts) == 1
|
|
mock_refresh.assert_called_once_with("uv")
|
|
assert sentinel.read_text(encoding="utf-8") == "live"
|
|
|
|
def test_retries_once_after_successful_refresh(self, tmp_path):
|
|
result, attempts, mock_refresh, sentinel = self._run_repair(
|
|
tmp_path,
|
|
refresh_result=True,
|
|
second_attempt=lambda root: None,
|
|
)
|
|
# Second attempt ran (and also failed) — exactly one retry, no loop.
|
|
assert result.status == "failed"
|
|
assert len(attempts) == 2
|
|
mock_refresh.assert_called_once_with("uv")
|
|
assert sentinel.read_text(encoding="utf-8") == "live"
|
|
|
|
def test_retry_success_proceeds_to_staging(self, tmp_path):
|
|
def second_attempt(root):
|
|
generation = root / ".hermes-runtime" / "python" / "generation-retry"
|
|
candidate_python = generation / "bin" / "python"
|
|
candidate_python.parent.mkdir(parents=True)
|
|
candidate_python.write_text("candidate", encoding="utf-8")
|
|
return generation, candidate_python, _runtime_info(
|
|
candidate_python, (3, 53, 1)
|
|
)
|
|
|
|
result, attempts, mock_refresh, sentinel = self._run_repair(
|
|
tmp_path,
|
|
refresh_result=True,
|
|
second_attempt=second_attempt,
|
|
)
|
|
# Provisioning succeeded on retry; staging (mocked to None) is what
|
|
# failed — proving the retry result flows into the normal pipeline.
|
|
assert result.status == "failed"
|
|
assert "replacement environment" in result.detail
|
|
assert len(attempts) == 2
|
|
assert sentinel.read_text(encoding="utf-8") == "live"
|
|
|
|
class TestDefaultLiveVenv:
|
|
"""_default_live_venv() must cover BOTH install layouts (venv/ and .venv/).
|
|
|
|
Historically repair hardcoded venv/, so uv-default/.venv checkouts got
|
|
'not-applicable' on every hermes update and stayed on journal_mode=DELETE
|
|
(2,600x slower state.db appends) while the WAL warning promised repair.
|
|
"""
|
|
|
|
def _checkout(self, tmp_path, *dirs):
|
|
root = tmp_path / "checkout"
|
|
root.mkdir()
|
|
(root / "pyproject.toml").write_text("[project]\n", encoding="utf-8")
|
|
for d in dirs:
|
|
bin_dir = root / d / "bin"
|
|
bin_dir.mkdir(parents=True)
|
|
(bin_dir / "python").write_text("py", encoding="utf-8")
|
|
return root
|
|
|
|
def test_dot_venv_only_is_targeted(self, tmp_path):
|
|
from hermes_cli.managed_uv import _default_live_venv
|
|
|
|
root = self._checkout(tmp_path, ".venv")
|
|
assert _default_live_venv(root) == root / ".venv"
|
|
|
|
def test_managed_venv_takes_precedence(self, tmp_path):
|
|
from hermes_cli.managed_uv import _default_live_venv
|
|
|
|
root = self._checkout(tmp_path, "venv", ".venv")
|
|
assert _default_live_venv(root) == root / "venv"
|
|
|
|
def test_neither_layout_keeps_not_applicable(self, tmp_path):
|
|
from hermes_cli.managed_uv import (
|
|
_default_live_venv,
|
|
repair_vulnerable_runtime,
|
|
)
|
|
|
|
root = self._checkout(tmp_path)
|
|
# Neither venv nor .venv has an interpreter -> repair is not applicable.
|
|
assert _default_live_venv(root) == root / "venv"
|
|
result = repair_vulnerable_runtime("uv", project_root=root)
|
|
assert result.status == "not-applicable"
|
|
|
|
|
|
class TestVenvPythonUpdateBoundary:
|
|
"""``_venv_python`` must survive a hermes_constants predating its symbol.
|
|
|
|
``hermes update`` imports hermes_constants from the OLD checkout, ``git
|
|
pull`` replaces that file, and the freshly-pulled managed_uv then runs its
|
|
lazy ``from hermes_constants import venv_python_path`` against the module
|
|
object already cached in ``sys.modules``. That cached module has no such
|
|
symbol, so the import raises — while naming the NEW file on disk, which
|
|
plainly contains it, which is what made the error so confusing:
|
|
|
|
cannot import name 'venv_python_path' from 'hermes_constants'
|
|
(~/.hermes/hermes-agent/hermes_constants.py)
|
|
|
|
It aborted the managed-Python runtime repair on the first update from any
|
|
release older than the symbol. Same class as the ``ensure_uv()`` arity skew
|
|
documented on ``_UvResult``.
|
|
"""
|
|
|
|
def test_recovers_when_the_cached_module_predates_the_symbol(self, monkeypatch):
|
|
import hermes_constants
|
|
|
|
from hermes_cli.managed_uv import _venv_python
|
|
|
|
# The stale in-memory module: the symbol the new code wants is absent,
|
|
# exactly as on an install that booted the pre-upgrade checkout. The
|
|
# file on disk is the current one, so a reload recovers the real helper.
|
|
monkeypatch.delattr(hermes_constants, "venv_python_path", raising=False)
|
|
|
|
# Host-native: the subject is the reload-recovery seam, not the
|
|
# bin/Scripts mapping — assert whatever layout the real host resolves.
|
|
expected = Path("/opt/hermes/venv/Scripts/python.exe") \
|
|
if sys.platform == "win32" else Path("/opt/hermes/venv/bin/python")
|
|
assert _venv_python(Path("/opt/hermes/venv")) == expected
|
|
|
|
def test_recovery_uses_the_shared_helper_not_a_second_copy(self, monkeypatch):
|
|
"""The reload must resolve through hermes_constants, not open-code it.
|
|
|
|
Hand-rolling `Scripts`/`bin` here is what #76105 deduped away and what
|
|
`test_no_open_coded_venv_layout_remains_in_hermes_cli` bans.
|
|
"""
|
|
import hermes_constants
|
|
|
|
from hermes_cli.managed_uv import _venv_python
|
|
|
|
monkeypatch.delattr(hermes_constants, "venv_python_path", raising=False)
|
|
|
|
sentinel = Path("/sentinel/from/shared/helper")
|
|
real_reload = __import__("importlib").reload
|
|
|
|
def _reload_with_marker(module):
|
|
fresh = real_reload(module)
|
|
monkeypatch.setattr(
|
|
fresh, "venv_python_path", lambda *a, **k: sentinel, raising=False
|
|
)
|
|
return fresh
|
|
|
|
monkeypatch.setattr("importlib.reload", _reload_with_marker)
|
|
assert _venv_python(Path("/opt/hermes/venv")) == sentinel
|
|
|
|
def test_uses_the_real_helper_when_it_is_importable(self, monkeypatch):
|
|
"""The normal path never reloads — recovery stays a fallback."""
|
|
from hermes_cli.managed_uv import _venv_python
|
|
|
|
def _no_reload(module): # pragma: no cover - must not run
|
|
raise AssertionError("reload must not run when the import succeeds")
|
|
|
|
monkeypatch.setattr("importlib.reload", _no_reload)
|
|
|
|
expected = Path("/opt/hermes/venv/Scripts/python.exe") \
|
|
if sys.platform == "win32" else Path("/opt/hermes/venv/bin/python")
|
|
assert _venv_python(Path("/opt/hermes/venv")) == expected
|
|
|
|
|
|
|
|
class TestWindowsRuntimeSelfLock:
|
|
"""The repair pre-flight must see the ONE holder the generic scan hides:
|
|
the updater itself (#93032).
|
|
|
|
A CLI ``hermes update`` runs from the venv's own python, and
|
|
``_detect_venv_python_processes`` excludes the calling process and its
|
|
ancestors on purpose (correct for the dependency-sync path). For the
|
|
whole-venv park rename that exemption is fatal on Windows: a directory
|
|
containing an executable mapped by a running process cannot be renamed,
|
|
so the cutover retries burn out against a lock that cannot be released
|
|
while the updater lives. The repair must detect the self-lock and defer
|
|
with honest guidance instead of provisioning a candidate for a doomed
|
|
rename.
|
|
"""
|
|
|
|
def _checkout(self, tmp_path):
|
|
root, live, sentinel = _make_runtime_install(tmp_path)
|
|
# Windows-layout interpreter so sys.executable can point inside the
|
|
# live venv on any host (the detector only string-compares paths).
|
|
scripts_python = live / "Scripts" / "python.exe"
|
|
scripts_python.parent.mkdir(parents=True, exist_ok=True)
|
|
scripts_python.write_text("live interpreter", encoding="utf-8")
|
|
return root, live, sentinel, scripts_python
|
|
|
|
def test_self_lock_defers_repair_before_provisioning(
|
|
self, tmp_path, monkeypatch, capsys
|
|
):
|
|
"""Regression for #93032: pre-fix, the repair walks straight into the
|
|
doomed rename (provisioning + cutover) whenever the updater itself
|
|
maps the live venv; the park then fails with WinError 5 and the user
|
|
gets the misleading 'next update will retry' message forever."""
|
|
from hermes_cli import managed_uv
|
|
from hermes_cli.managed_uv import repair_vulnerable_runtime
|
|
|
|
root, live, sentinel, scripts_python = self._checkout(tmp_path)
|
|
current = _runtime_info(scripts_python, (3, 50, 4))
|
|
monkeypatch.setattr(managed_uv.platform, "system", lambda: "Windows")
|
|
monkeypatch.setattr(sys, "executable", str(scripts_python))
|
|
|
|
with patch(
|
|
"hermes_cli.managed_uv._windows_runtime_holders",
|
|
return_value=(False, ""),
|
|
), \
|
|
patch(
|
|
"hermes_cli.managed_uv.probe_sqlite_runtime",
|
|
return_value=current,
|
|
), \
|
|
patch(
|
|
"hermes_cli.managed_uv._install_safe_python_generation"
|
|
) as mock_install:
|
|
result = repair_vulnerable_runtime("uv", project_root=root)
|
|
|
|
assert result.status == "skipped"
|
|
assert "live venv" in result.detail
|
|
assert mock_install.call_count == 0, (
|
|
"a self-locked updater must not provision a candidate it can "
|
|
"never cut over"
|
|
)
|
|
assert sentinel.read_text(encoding="utf-8") == "live"
|
|
assert not (root / ".hermes-runtime").exists()
|
|
|
|
out = capsys.readouterr().out
|
|
assert "SQLite runtime repair deferred" in out
|
|
assert "will retry" not in out, (
|
|
"the structural self-lock must not promise that retrying helps"
|
|
)
|
|
assert "outside" in out, "the deferral must point at an escape hatch"
|
|
|
|
def test_non_self_locked_repair_proceeds(self, tmp_path, monkeypatch):
|
|
"""The guard must fail OPEN when the updater runs from outside the
|
|
venv — an always-firing deferral would recreate the never-converging
|
|
loop this fix removes (#86735 class)."""
|
|
from hermes_cli import managed_uv
|
|
from hermes_cli.managed_uv import repair_vulnerable_runtime
|
|
|
|
root, live, sentinel, scripts_python = self._checkout(tmp_path)
|
|
current = _runtime_info(scripts_python, (3, 50, 4))
|
|
monkeypatch.setattr(managed_uv.platform, "system", lambda: "Windows")
|
|
monkeypatch.setattr(
|
|
sys, "executable", str(tmp_path / "outside" / "python.exe")
|
|
)
|
|
|
|
with patch(
|
|
"hermes_cli.managed_uv._windows_runtime_holders",
|
|
return_value=(False, ""),
|
|
), \
|
|
patch(
|
|
"hermes_cli.managed_uv.probe_sqlite_runtime",
|
|
return_value=current,
|
|
), \
|
|
patch(
|
|
"hermes_cli.managed_uv._install_safe_python_generation",
|
|
return_value=None,
|
|
) as mock_install:
|
|
result = repair_vulnerable_runtime("uv", project_root=root)
|
|
|
|
assert result.status == "failed"
|
|
assert "provision" in result.detail
|
|
mock_install.assert_called_once()
|
|
assert sentinel.read_text(encoding="utf-8") == "live"
|
|
|
|
def test_self_lock_is_a_noop_off_windows(self, tmp_path, monkeypatch):
|
|
"""POSIX renames work while the updater maps the venv, so the guard
|
|
must stay Windows-only."""
|
|
from hermes_cli import managed_uv
|
|
|
|
root, live, sentinel, scripts_python = self._checkout(tmp_path)
|
|
monkeypatch.setattr(managed_uv.platform, "system", lambda: "Linux")
|
|
monkeypatch.setattr(sys, "executable", str(scripts_python))
|
|
|
|
locked, detail = managed_uv._windows_runtime_self_lock(live)
|
|
assert (locked, detail) == (False, "")
|
|
|
|
def test_venv_launcher_ancestor_is_a_self_lock(self, tmp_path, monkeypatch):
|
|
r"""The venv\Scripts\hermes.exe shim stays mapped while it waits for
|
|
this child — an ancestor running from the venv blocks the rename too."""
|
|
from hermes_cli import managed_uv
|
|
|
|
root, live, sentinel, scripts_python = self._checkout(tmp_path)
|
|
monkeypatch.setattr(managed_uv.platform, "system", lambda: "Windows")
|
|
monkeypatch.setattr(
|
|
sys, "executable", str(tmp_path / "outside" / "python.exe")
|
|
)
|
|
|
|
class _FakeProc:
|
|
def __init__(self, pid, exe):
|
|
self.pid = pid
|
|
self._exe = exe
|
|
|
|
def exe(self):
|
|
return self._exe
|
|
|
|
fake_psutil = SimpleNamespace(
|
|
Process=lambda: SimpleNamespace(
|
|
parents=lambda: [_FakeProc(999, str(scripts_python))],
|
|
),
|
|
)
|
|
with patch.dict(sys.modules, {"psutil": fake_psutil}):
|
|
locked, detail = managed_uv._windows_runtime_self_lock(live)
|
|
|
|
assert locked
|
|
assert "999" in detail
|