diff --git a/hermes_constants.py b/hermes_constants.py index 36e6656334..5cd0294918 100644 --- a/hermes_constants.py +++ b/hermes_constants.py @@ -1458,15 +1458,35 @@ def venv_bin_dir(venv_dir, *, windows: bool | None = None) -> Path: def project_venv_dir(project_root) -> Path | None: - """The project's ``venv`` or ``.venv`` dir when one exists (``uv venv`` defaults to ``.venv``). + """The project's ``venv`` or ``.venv`` dir when one exists (``uv venv`` defaults to ``.venv``); + for an install whose interpreter lives outside the checkout, the running interpreter's venv. ``uv venv`` defaults to ``.venv`` while our installers create ``venv``, so both layouts are in the wild. Call sites that only knew about ``venv`` silently no-oped on a ``.venv`` install — that is how the Windows shim-lock preflight skipped itself entirely (#79542). ``venv`` wins when both exist, matching what the installers write. + + Installers that keep the interpreter out of the checkout (``$HERMES_HOME/venvs/``, the layout the + shipped Windows launchers assume) have neither, and the ``project_venv_dir(root) or root / "venv"`` + idiom those call sites share then handed ``uv`` a ``VIRTUAL_ENV`` that does not exist: that one invented + path skipped the import probe, reclassified every ``hermes tools`` dependency as missing and failed the + reinstall with interpreter errors (#116148). The interpreter running this module is the only truthful + answer to "which venv is live", so fall back to it — but only for the checkout it was loaded from. A + foreign root (test temp dir, another clone) still resolves to ``None``: handing it someone else's venv + would point the callers' writes at the wrong environment. """ root = Path(project_root) - return next((root / n for n in ("venv", ".venv") if (root / n).is_dir()), None) + in_tree = next((root / n for n in ("venv", ".venv") if (root / n).is_dir()), None) + if in_tree is not None: + return in_tree + # Out-of-tree install: the path is real by construction (never invented), and non-venv installs + # keep today's ``None`` so the ``or root / "venv"`` fallback cannot install into a base interpreter. + running = Path(sys.prefix) + if (Path(__file__).resolve().parent == root.resolve() + and sys.prefix != sys.base_prefix + and venv_python_path(running).is_file()): + return running + return None def venv_python_path(venv_dir, *, windows: bool | None = None) -> Path: diff --git a/tests/test_hermes_constants.py b/tests/test_hermes_constants.py index 9de605fb01..9e75e371e2 100644 --- a/tests/test_hermes_constants.py +++ b/tests/test_hermes_constants.py @@ -1,6 +1,7 @@ """Tests for hermes_constants module.""" import os +import sys from pathlib import Path from types import SimpleNamespace @@ -1240,3 +1241,40 @@ class TestHealAttemptFlagSemantics: # The flag is set, so the once-per-process budget is spent. assert heal_hermes_managed_node() is False assert calls["n"] == 1 + +class TestProjectVenvDirOutOfTree: + """#116148: a checkout with no in-tree venv whose interpreter lives in ``$HERMES_HOME/venvs/`` + (the layout the shipped Windows launchers pin) must resolve to the running interpreter's venv, + never ``None`` — every updater call site turns ``None`` into a fabricated ``/venv`` that + uv cannot inspect, so tool dependencies are never refreshed.""" + + @staticmethod + def _running_from(monkeypatch, checkout, venv): + monkeypatch.setattr(hermes_constants, "__file__", str(checkout / "hermes_constants.py")) + monkeypatch.setattr(sys, "prefix", str(venv)) + monkeypatch.setattr(sys, "base_prefix", str(checkout / "no-such-base")) + + def test_out_of_tree_install_resolves_the_running_interpreter_venv(self, monkeypatch, tmp_path): + checkout = tmp_path / "hermes-agent" + checkout.mkdir() + venv = tmp_path / "venvs" / "hermes" + hermes_constants.venv_python_path(venv).parent.mkdir(parents=True) + hermes_constants.venv_python_path(venv).write_text("", encoding="utf-8") + self._running_from(monkeypatch, checkout, venv) + + assert hermes_constants.project_venv_dir(checkout) == venv + + def test_foreign_root_and_in_tree_venv_are_unchanged(self, monkeypatch, tmp_path): + """A temp dir / another clone never claims the running venv; an in-tree venv still wins.""" + checkout = tmp_path / "hermes-agent" + checkout.mkdir() + venv = tmp_path / "venvs" / "hermes" + hermes_constants.venv_python_path(venv).parent.mkdir(parents=True) + hermes_constants.venv_python_path(venv).write_text("", encoding="utf-8") + self._running_from(monkeypatch, checkout, venv) + other = tmp_path / "not-our-checkout" + other.mkdir() + + assert hermes_constants.project_venv_dir(other) is None + (checkout / ".venv").mkdir() + assert hermes_constants.project_venv_dir(checkout) == checkout / ".venv"