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=<dev checkout> <app install>/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.
This commit is contained in:
ethernet
2026-09-25 01:17:12 -04:00
parent 69948c0057
commit f9f235ed1a
2 changed files with 57 additions and 5 deletions

View File

@@ -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=<checkout>
<other install>/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

View File

@@ -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=<dev checkout> <app venv>/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()