Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions hermes_constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>``, 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:
Expand Down
38 changes: 38 additions & 0 deletions tests/test_hermes_constants.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Tests for hermes_constants module."""

import os
import sys
from pathlib import Path
from types import SimpleNamespace

Expand Down Expand Up @@ -1240,3 +1241,40 @@ def fake_heal():
# 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/<name>``
(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 ``<checkout>/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"
Loading