From f9f235ed1adcc58f194e14cc8add145f865a1175 Mon Sep 17 00:00:00 2001 From: ethernet Date: Fri, 25 Sep 2026 01:17:12 -0400 Subject: [PATCH] fix(pm): never adopt another install's venv for a checkout project_venv_dir() fell back to the running interpreter's venv whenever hermes_constants was loaded from the checkout. Where the code was loaded from says nothing about who owns the interpreter: with `PYTHONPATH= /venv/bin/python -m hermes_cli.main` (a shell wrapper around a dev tree), PROJECT_ROOT is the dev checkout but the venv is the Desktop install's. base_venv() then returned the app's venv, `hermes update` synced the dev tree into it, and the Desktop install's venv became an editable install of the dev checkout. From then on the Desktop shell tracked ~/.hermes/hermes-agent while its backend and its handed-off `hermes update` ran and pulled the dev tree, so every in-app update "succeeded" without moving the install. The same misread let running_from_selected_environment() accept lazy extras into that venv. Only fall back to the running venv when its own hermes-agent install records this checkout in direct_url.json, which every install of a checkout into a venv writes (installers, uv sync). Otherwise the checkout gets its own environment, as it did before 4f6c04cd07. The #116148 out-of-tree layout (a venv installed from the checkout) still resolves to the running interpreter. --- hermes_constants.py | 30 +++++++++++++++++++++++++++++- tests/test_hermes_constants.py | 32 ++++++++++++++++++++++++++++---- 2 files changed, 57 insertions(+), 5 deletions(-) diff --git a/hermes_constants.py b/hermes_constants.py index 6a69179fba..4ebe47e714 100644 --- a/hermes_constants.py +++ b/hermes_constants.py @@ -1307,11 +1307,39 @@ def project_venv_dir(project_root) -> Path | None: running = Path(sys.prefix) if (Path(__file__).resolve().parent == root.resolve() and sys.prefix != sys.base_prefix - and venv_python_path(running).is_file()): + and venv_python_path(running).is_file() + and _venv_installs_checkout(running, root)): return running return None +def _venv_installs_checkout(venv: Path, root: Path) -> bool: + """Is *venv*'s own ``hermes-agent`` installed from *root*? + + Where this module was loaded from does not answer that: ``PYTHONPATH= + /bin/python`` runs one checkout's code on another install's interpreter, + and adopting that venv made a dev checkout's update rewrite the Desktop install's venv + into an editable install of the dev tree. Every install of a checkout into a venv + (installers, ``uv sync``) records the source tree in ``direct_url.json``. + """ + import json + from importlib.metadata import distributions + from urllib.parse import urlparse + from urllib.request import url2pathname + + from pm.environments import site_packages + + for dist in distributions(name="hermes-agent", path=[str(site_packages(venv))]): + try: + raw = dist.read_text("direct_url.json") # windows-footgun: ok — importlib.metadata API, reads utf-8, no encoding= + url = json.loads(raw or "{}").get("url", "") + except ValueError: + continue + if url.startswith("file:") and Path(url2pathname(urlparse(url).path)).resolve() == root.resolve(): + return True + return False + + def venv_python_path(venv_dir, *, windows: bool | None = None) -> Path: """Frozen updater surface: pre-PM updaters import this name; pm.environments owns it.""" from pm.environments import venv_python diff --git a/tests/test_hermes_constants.py b/tests/test_hermes_constants.py index 5b256b97c0..af1919d8fe 100644 --- a/tests/test_hermes_constants.py +++ b/tests/test_hermes_constants.py @@ -1,5 +1,6 @@ """Tests for hermes_constants module.""" +import json import os import sys from pathlib import Path @@ -616,23 +617,46 @@ class TestProjectVenvDirOutOfTree: monkeypatch.setattr(sys, "prefix", str(venv)) monkeypatch.setattr(sys, "base_prefix", str(checkout / "no-such-base")) + @staticmethod + def _venv_installed_from(venv, source): + from pm.environments import site_packages + hermes_constants.venv_python_path(venv).parent.mkdir(parents=True) + hermes_constants.venv_python_path(venv).write_text("", encoding="utf-8") + dist_info = site_packages(venv) / "hermes_agent-0.0.0.dist-info" + dist_info.mkdir(parents=True) + (dist_info / "METADATA").write_text("Name: hermes-agent\nVersion: 0.0.0\n", encoding="utf-8") + (dist_info / "direct_url.json").write_text( + json.dumps({"url": source.resolve().as_uri(), "dir_info": {"editable": True}}), encoding="utf-8") + 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._venv_installed_from(venv, checkout) self._running_from(monkeypatch, checkout, venv) assert hermes_constants.project_venv_dir(checkout) == venv + def test_another_installs_interpreter_is_never_claimed(self, monkeypatch, tmp_path): + """``PYTHONPATH= /bin/python``: the code comes from the dev checkout, + but the venv belongs to the app install. Claiming it pointed the dev checkout's update sync at + the app's venv, which became an editable install of the dev tree.""" + dev = tmp_path / "dev" / "hermes-agent" + dev.mkdir(parents=True) + app = tmp_path / "app" / "hermes-agent" + app.mkdir(parents=True) + venv = tmp_path / "app" / "venv" + self._venv_installed_from(venv, app) + self._running_from(monkeypatch, dev, venv) + + assert hermes_constants.project_venv_dir(dev) is None + 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._venv_installed_from(venv, checkout) self._running_from(monkeypatch, checkout, venv) other = tmp_path / "not-our-checkout" other.mkdir()