fix(managed_uv): repair vulnerable SQLite runtime in .venv installs too
repair_vulnerable_runtime() hardcoded <checkout>/venv as the live venv, so uv-default/dev checkouts installed into .venv got 'not-applicable' on every hermes update — no repair path ever fired, leaving state.db-class DBs on journal_mode=DELETE forever (measured 26 ms + ~5.5 fsyncs per append vs ~0.01 ms under WAL, ~2,600x) while the WAL fallback warning falsely promised hermes update would repair the runtime. - _default_live_venv(): target venv/ when it has an interpreter (managed layout precedence), fall back to .venv/, keep not-applicable when neither exists. Explicit venv_dir arg unchanged; all staging/smoke/ cutover/rollback machinery untouched. - Rebuilt against the pruned test suite (main's test-prune waves 1+2 rewrote test_managed_uv.py, so this reapplies cleanly): 3 new TestDefaultLiveVenv tests + repair neutralized in the 6 unit tests whose subject is uv install/self-update mechanics — with .venv now probed for real, CI's own vulnerable .venv made the unmocked repair hook fire inside those tests and re-invoke _install_uv. 33/33 tests green on the pruned suite.
This commit is contained in:
@@ -41,6 +41,7 @@ logger = logging.getLogger(__name__)
|
||||
_PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
_RUNTIME_DIR_NAME = ".hermes-runtime"
|
||||
_VENV_NAME = "venv"
|
||||
_ALT_VENV_NAME = ".venv"
|
||||
_REPAIR_LOCK_NAME = "runtime-repair.lock"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -986,6 +987,31 @@ def _refresh_managed_uv_catalog(uv_bin: str) -> bool:
|
||||
return after != before
|
||||
|
||||
|
||||
def _default_live_venv(root: Path) -> Path:
|
||||
"""Return the venv that runtime repair should target for *root*.
|
||||
|
||||
Managed installs create ``<checkout>/venv``, but uv-default and dev
|
||||
checkouts use ``<checkout>/.venv``. Historically only ``venv`` was
|
||||
probed, so a ``.venv`` install linking a vulnerable SQLite returned
|
||||
``not-applicable`` on every ``hermes update`` and stayed on
|
||||
journal_mode=DELETE forever — even though the WAL fallback warning
|
||||
promises that ``hermes update`` repairs the runtime (issue class:
|
||||
2,600x slower ``state.db`` appends under DELETE).
|
||||
|
||||
``venv`` wins when it holds an interpreter (managed layout takes
|
||||
precedence); otherwise fall back to ``.venv`` when that one does.
|
||||
When neither has an interpreter, return the ``venv`` path so the
|
||||
caller's existing ``not-applicable`` handling fires unchanged.
|
||||
"""
|
||||
primary = root / _VENV_NAME
|
||||
if _venv_python(primary).is_file():
|
||||
return primary
|
||||
fallback = root / _ALT_VENV_NAME
|
||||
if _venv_python(fallback).is_file():
|
||||
return fallback
|
||||
return primary
|
||||
|
||||
|
||||
def repair_vulnerable_runtime(
|
||||
uv_bin: str,
|
||||
*,
|
||||
@@ -998,7 +1024,7 @@ def repair_vulnerable_runtime(
|
||||
post-cutover smoke failures restore the parked venv synchronously.
|
||||
"""
|
||||
root = Path(project_root) if project_root is not None else _PROJECT_ROOT
|
||||
live = Path(venv_dir) if venv_dir is not None else root / _VENV_NAME
|
||||
live = Path(venv_dir) if venv_dir is not None else _default_live_venv(root)
|
||||
live_python = _venv_python(live)
|
||||
if not (root / "pyproject.toml").is_file() or not live_python.is_file():
|
||||
return RuntimeRepairResult("not-applicable")
|
||||
|
||||
@@ -39,6 +39,18 @@ def _runtime_info(
|
||||
)
|
||||
|
||||
|
||||
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,
|
||||
*,
|
||||
@@ -101,6 +113,7 @@ 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):
|
||||
@@ -166,6 +179,7 @@ class TestEnsureUvUpdateBoundary:
|
||||
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")), \
|
||||
patch("hermes_cli.managed_uv.platform.system", return_value="Linux"):
|
||||
from hermes_cli.managed_uv import ensure_uv
|
||||
uv_bin = ensure_uv()
|
||||
@@ -175,6 +189,7 @@ class TestEnsureUvUpdateBoundary:
|
||||
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")), \
|
||||
patch("hermes_cli.managed_uv.platform.system", return_value="Linux"):
|
||||
from hermes_cli.managed_uv import ensure_uv
|
||||
uv_bin, fresh = ensure_uv() # old: uv_bin, fresh_bootstrap = ensure_uv()
|
||||
@@ -183,6 +198,7 @@ class TestEnsureUvUpdateBoundary:
|
||||
|
||||
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.platform.system", return_value="Linux"), \
|
||||
patch("hermes_cli.managed_uv._install_uv", side_effect=RuntimeError("network down")):
|
||||
from hermes_cli.managed_uv import ensure_uv
|
||||
@@ -221,6 +237,7 @@ class TestEnsureUvWindowsSafe:
|
||||
# On (mocked) 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")), \
|
||||
patch("hermes_cli.managed_uv.platform.system", return_value="Windows"):
|
||||
from hermes_cli.managed_uv import _UvResult, ensure_uv
|
||||
uv_bin = ensure_uv()
|
||||
@@ -280,6 +297,7 @@ class TestUpdateManagedUv:
|
||||
_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()
|
||||
@@ -847,3 +865,46 @@ class TestRepairRetriesAfterUvRefresh:
|
||||
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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user