fix(tools): tighten Hermes PYTHONPATH ownership semantics

Adversarial review of the previous two commits (and #78917 itself)
found three ownership-boundary issues; this commit addresses them:

1. Repo direct-child over-strip (Finding A)
   No launcher injects <repo>/tools or another direct child as an
   independent PYTHONPATH entry - audited all four producers (Electron
   electron-main.mjs, gateway/run.py::_ensure_windows_gateway_venv_imports,
   cron/scheduler.py::_windows_cron_python_invocation,
   tui_gateway/host_supervisor.py).  The depth<=1 rule deleted user paths
   that merely live under the repo directory; only the EXACT repo root is
   now stripped.

2. Windows junction/symlink alias (Finding B)
   The gateway launcher renders Hermes-owned paths under the configured
   HERMES_HOME spelling (gateway_windows.py::_preserve_hermes_home_path),
   which may be a junction to another drive, so it differs lexically from
   the resolved repo root.  _hermes_repo_root_aliases now carries both the
   resolved and unresolved spellings; both are recognized as Hermes-owned.

3. Stale abstraction rename (Phase 4)
   _strip_mismatched_site_packages -> _strip_hermes_owned_pythonpath:
   the cross-version heuristic is gone, so the old name misdescribes the
   behavior (ownership-based, not version-based).

Tests: direct-child now preserved; junction alias stripped (lexical pair
monkeypatched); Windows-only real-semantics test added (POSIX test remains
a safety test); mixed-ordering, duplicate-Hermes, and no-scrub PYTHONHOME
contract tests added.  Full file: 52 passed / 16 failed (identical failure
set to base, all isolation-venv environment issues).
This commit is contained in:
Xinyu Du
2026-08-09 22:14:07 +08:00
committed by Teknium
parent 850686a515
commit 73b49f473a
3 changed files with 232 additions and 66 deletions

View File

@@ -9,6 +9,7 @@ See: https://github.com/NousResearch/hermes-agent/issues/1264
"""
import os
import sys
import threading
from pathlib import Path
from unittest.mock import MagicMock, patch
@@ -321,7 +322,7 @@ class TestPythonpathSelectiveStrip:
venv's site-packages (Python 3.11) into PYTHONPATH. When this leaks
into subprocesses running a different Python (e.g. 3.13), 3.11 C
extensions appear on sys.path and crash with ImportError.
``_strip_mismatched_site_packages`` surgically removes only the
``_strip_hermes_owned_pythonpath`` surgically removes only the
entries Hermes itself owns (repo root, own venv site-packages),
preserving user paths — including user paths whose names merely
contain another Python version.
@@ -329,7 +330,7 @@ class TestPythonpathSelectiveStrip:
def test_hermes_venv_site_packages_stripped(self):
"""A site-packages entry under the Hermes venv is removed."""
from tools.environments.local import _strip_mismatched_site_packages
from tools.environments.local import _strip_hermes_owned_pythonpath
import sys
# Construct a path that looks like the Hermes venv site-packages.
@@ -342,7 +343,7 @@ class TestPythonpathSelectiveStrip:
env = {
"PYTHONPATH": os.pathsep.join([venv_sp, "/home/user/my-lib"]),
}
_strip_mismatched_site_packages(env)
_strip_hermes_owned_pythonpath(env)
assert "PYTHONPATH" in env
entries = env["PYTHONPATH"].split(os.pathsep)
assert venv_sp not in entries
@@ -350,10 +351,10 @@ class TestPythonpathSelectiveStrip:
def test_user_pythonpath_preserved(self):
"""User PYTHONPATH entries pass through untouched."""
from tools.environments.local import _strip_mismatched_site_packages
from tools.environments.local import _strip_hermes_owned_pythonpath
user_pp = os.pathsep.join(["/opt/my-lib", "/another/path"])
env = {"PYTHONPATH": user_pp}
_strip_mismatched_site_packages(env)
_strip_hermes_owned_pythonpath(env)
assert env.get("PYTHONPATH") == user_pp
def test_other_version_site_packages_preserved(self):
@@ -365,7 +366,7 @@ class TestPythonpathSelectiveStrip:
judged against the BACKEND's interpreter version (P2, #74817
follow-up). Regression: prior Check 1 stripped these.
"""
from tools.environments.local import _strip_mismatched_site_packages
from tools.environments.local import _strip_hermes_owned_pythonpath
import sys
# Use a version different from the running interpreter.
@@ -378,7 +379,7 @@ class TestPythonpathSelectiveStrip:
env = {
"PYTHONPATH": os.pathsep.join([other_sp, "/home/user/my-lib"]),
}
_strip_mismatched_site_packages(env)
_strip_hermes_owned_pythonpath(env)
assert "PYTHONPATH" in env
entries = env["PYTHONPATH"].split(os.pathsep)
assert other_sp in entries
@@ -388,11 +389,11 @@ class TestPythonpathSelectiveStrip:
"""A user's python2.7/site-packages entry is preserved — path
ownership, not version, decides stripping (P2, #74817 follow-up).
"""
from tools.environments.local import _strip_mismatched_site_packages
from tools.environments.local import _strip_hermes_owned_pythonpath
env = {
"PYTHONPATH": "/old/lib/python2.7/site-packages:/home/user/lib",
}
_strip_mismatched_site_packages(env)
_strip_hermes_owned_pythonpath(env)
assert "PYTHONPATH" in env
entries = env["PYTHONPATH"].split(os.pathsep)
assert "/old/lib/python2.7/site-packages" in entries
@@ -403,14 +404,14 @@ class TestPythonpathSelectiveStrip:
must never be stripped (P1, #74817 follow-up). Regression: prior
Check 1 deleted these because it keyed on the version component alone.
"""
from tools.environments.local import _strip_mismatched_site_packages
from tools.environments.local import _strip_hermes_owned_pythonpath
user_pp = os.pathsep.join([
"/opt/tools/python3.13/bin",
"/opt/downloads/python3.13",
"/custom/python3.13",
])
env = {"PYTHONPATH": user_pp}
_strip_mismatched_site_packages(env)
_strip_hermes_owned_pythonpath(env)
assert env.get("PYTHONPATH") == user_pp
def test_windows_backslash_paths(self):
@@ -423,9 +424,10 @@ class TestPythonpathSelectiveStrip:
(including site-packages paths for another Python version) are
never destroyed. On a real Windows host, Path splits on
backslashes and Hermes venv site-packages entries are stripped
by the same Hermes-owned check.
by the same Hermes-owned check (covered by the Windows-only test
below).
"""
from tools.environments.local import _strip_mismatched_site_packages
from tools.environments.local import _strip_hermes_owned_pythonpath
import sys
pyver = f"python{sys.version_info[0]}.{sys.version_info[1]}"
@@ -436,7 +438,7 @@ class TestPythonpathSelectiveStrip:
}
# Mock os.pathsep to ';' (Windows) just for the strip call.
with patch("os.pathsep", ";"):
_strip_mismatched_site_packages(env)
_strip_hermes_owned_pythonpath(env)
assert "PYTHONPATH" in env
entries = env["PYTHONPATH"].split(";")
# Both survive on POSIX: user paths must always be preserved, and
@@ -444,26 +446,106 @@ class TestPythonpathSelectiveStrip:
assert hermes_win in entries
assert user_win in entries
@pytest.mark.windows_only
def test_windows_hermes_owned_paths_stripped(self):
"""On Windows, a Hermes venv site-packages entry written with
backslashes is stripped by the same Hermes-owned check, while a
user Windows path is preserved. Windows-only: POSIX ``Path`` does
not split on backslashes, so this cannot be meaningfully simulated
on a POSIX host."""
from tools.environments.local import _strip_hermes_owned_pythonpath
import sys
pyver = f"python{sys.version_info[0]}.{sys.version_info[1]}"
venv_sp = str(
__import__("pathlib").Path(sys.prefix) / "Lib" / "site-packages"
)
# Windows form: C:\...\venv\Lib\site-packages (backslashes)
hermes_win = venv_sp.replace("\\", "\\\\").replace("/", "\\\\")
user_win = "D:\\\\user\\\\lib"
env = {
"PYTHONPATH": ";".join([hermes_win, user_win]),
}
_strip_hermes_owned_pythonpath(env)
entries = env["PYTHONPATH"].split(";")
assert hermes_win not in entries
assert user_win in entries
def test_empty_pythonpath_unchanged(self):
"""An empty PYTHONPATH is a no-op (falsy -> early return)."""
from tools.environments.local import _strip_mismatched_site_packages
from tools.environments.local import _strip_hermes_owned_pythonpath
env = {"PYTHONPATH": ""}
_strip_mismatched_site_packages(env)
_strip_hermes_owned_pythonpath(env)
# Empty string is falsy, so the function returns early without
# modifying the dict. The key stays as-is (empty string).
assert env.get("PYTHONPATH") == ""
def test_mixed_ordering_user_and_hermes_preserves_user_order(self):
"""Mixed user/Hermes/user entries: user entries survive in their
original relative order after Hermes-owned entries are removed."""
from tools.environments.local import _strip_hermes_owned_pythonpath
import sys
pyver = f"python{sys.version_info[0]}.{sys.version_info[1]}"
venv_sp = str(
__import__("pathlib").Path(sys.prefix) / "lib" / pyver / "site-packages"
)
local_file = Path(
__import__("tools.environments.local", fromlist=["__file__"]).__file__
).resolve()
repo_root = str(local_file.parents[2])
env = {
"PYTHONPATH": os.pathsep.join([
"/first/user/lib",
repo_root,
"/second/user/lib",
venv_sp,
"/third/user/lib",
]),
}
_strip_hermes_owned_pythonpath(env)
pp = env.get("PYTHONPATH", "")
entries = pp.split(os.pathsep) if pp else []
assert entries == ["/first/user/lib", "/second/user/lib", "/third/user/lib"]
def test_duplicate_hermes_entries_all_stripped(self):
"""Every duplicate Hermes-owned entry is removed; user duplicates
follow the existing contract (no unrelated dedup)."""
from tools.environments.local import _strip_hermes_owned_pythonpath
import sys
pyver = f"python{sys.version_info[0]}.{sys.version_info[1]}"
venv_sp = str(
__import__("pathlib").Path(sys.prefix) / "lib" / pyver / "site-packages"
)
local_file = Path(
__import__("tools.environments.local", fromlist=["__file__"]).__file__
).resolve()
repo_root = str(local_file.parents[2])
env = {
"PYTHONPATH": os.pathsep.join([
venv_sp,
"/user/lib",
venv_sp, # duplicate Hermes entry
"/user/lib", # duplicate user entry — preserved as-is
]),
}
_strip_hermes_owned_pythonpath(env)
pp = env.get("PYTHONPATH", "")
entries = pp.split(os.pathsep) if pp else []
assert entries == ["/user/lib", "/user/lib"]
def test_no_pythonpath_key(self):
"""Missing PYTHONPATH key is a no-op."""
from tools.environments.local import _strip_mismatched_site_packages
from tools.environments.local import _strip_hermes_owned_pythonpath
env = {"PATH": "/usr/bin"}
_strip_mismatched_site_packages(env)
_strip_hermes_owned_pythonpath(env)
assert "PYTHONPATH" not in env
def test_all_entries_stripped_removes_key(self):
"""If all entries are Hermes-owned and stripped, PYTHONPATH key is
removed entirely."""
from tools.environments.local import _strip_mismatched_site_packages
from tools.environments.local import _strip_hermes_owned_pythonpath
import sys
pyver = f"python{sys.version_info[0]}.{sys.version_info[1]}"
@@ -472,7 +554,7 @@ class TestPythonpathSelectiveStrip:
)
env = {"PYTHONPATH": venv_sp}
_strip_mismatched_site_packages(env)
_strip_hermes_owned_pythonpath(env)
assert "PYTHONPATH" not in env
def test_make_run_env_strips_hermes_venv_pythonpath(self):
@@ -537,11 +619,11 @@ class TestPythonpathSelectiveStrip:
def test_scrub_child_env_strips_hermes_venv_pythonpath(self):
"""execute_code's _scrub_child_env path: after scrubbing, Hermes venv
site-packages entries should be stripped when
_strip_mismatched_site_packages is applied (as the spawn path does),
_strip_hermes_owned_pythonpath is applied (as the spawn path does),
while user entries (even for another Python version) are preserved.
"""
from tools.code_execution_tool import _scrub_child_env
from tools.environments.local import _strip_mismatched_site_packages
from tools.environments.local import _strip_hermes_owned_pythonpath
import sys
pyver = f"python{sys.version_info[0]}.{sys.version_info[1]}"
@@ -558,7 +640,7 @@ class TestPythonpathSelectiveStrip:
# The scrubber passes PYTHONPATH through (it's in _SAFE_ENV_PREFIXES).
assert "PYTHONPATH" in scrubbed
# Now apply the selective strip (as the spawn path does).
_strip_mismatched_site_packages(scrubbed)
_strip_hermes_owned_pythonpath(scrubbed)
pp = scrubbed.get("PYTHONPATH", "")
entries = pp.split(os.pathsep) if pp else []
assert venv_sp not in entries
@@ -577,7 +659,7 @@ class TestPythonpathSelectiveStrip:
(e.g. ``parents[1]`` resolving to ``tools/``) would cause this test
to fail instead of silently passing.
"""
from tools.environments.local import _strip_mismatched_site_packages
from tools.environments.local import _strip_hermes_owned_pythonpath
# Independently compute the real repo root: local.py lives at
# tools/environments/local.py, so the repo root is parents[2].
@@ -587,22 +669,25 @@ class TestPythonpathSelectiveStrip:
env = {
"PYTHONPATH": os.pathsep.join([real_repo_root, "/home/user/my-lib"]),
}
_strip_mismatched_site_packages(env)
_strip_hermes_owned_pythonpath(env)
pp = env.get("PYTHONPATH", "")
entries = pp.split(os.pathsep) if pp else []
assert real_repo_root not in entries
assert "/home/user/my-lib" in entries
def test_repo_root_direct_child_stripped(self):
"""A direct child of the repo root (depth=1) is stripped.
def test_repo_root_direct_child_preserved(self):
"""A direct child of the repo root (depth=1) is PRESERVED.
Check 3's depth rule is ``depth <= 1``: the repo root itself is
depth=0 (covered above), and a top-level package directory like
``<repo>/tools`` is depth=1. Both are stripped because Electron
prepends exactly these shallow paths so ``import tools`` works in
the backend, and they shadow user packages of the same name.
Independent audit of every real launcher producer (Electron
``electron-main.mjs``, ``gateway/run.py::_ensure_windows_gateway_venv_imports``,
``cron/scheduler.py::_windows_cron_python_invocation``,
``tui_gateway/host_supervisor.py``) shows they all inject the exact
repo root and/or the venv site-packages — none injects
``<repo>/tools`` or another direct child as an independent
PYTHONPATH entry. A user path that merely happens to live under
the repo directory must therefore be preserved.
"""
from tools.environments.local import _strip_mismatched_site_packages
from tools.environments.local import _strip_hermes_owned_pythonpath
local_file = Path(__import__("tools.environments.local", fromlist=["__file__"]).__file__).resolve()
real_repo_root = local_file.parents[2]
@@ -611,20 +696,63 @@ class TestPythonpathSelectiveStrip:
env = {
"PYTHONPATH": os.pathsep.join([direct_child, "/home/user/my-lib"]),
}
_strip_mismatched_site_packages(env)
_strip_hermes_owned_pythonpath(env)
pp = env.get("PYTHONPATH", "")
entries = pp.split(os.pathsep) if pp else []
assert direct_child not in entries
assert direct_child in entries
assert "/home/user/my-lib" in entries
def test_repo_root_junction_alias_stripped(self):
"""A PYTHONPATH entry using the unresolved (HERMES_HOME/junction)
spelling of the repo root is stripped.
On Windows the gateway launcher renders Hermes-owned paths under the
configured HERMES_HOME spelling, which may be a symlink/junction to
another drive (``_preserve_hermes_home_path`` in
``hermes_cli/gateway_windows.py``). ``_hermes_repo_root`` is the
RESOLVED physical path, so the launcher's spelling differs lexically;
the alias set must still recognize it as Hermes-owned. This test
monkeypatches the aliases to a pair whose lexical forms differ, the
way a junction would, and verifies the launcher's spelling is
stripped while a nested user path under the same prefix is not.
"""
from tools.environments.local import (
_strip_hermes_owned_pythonpath,
_hermes_repo_root_aliases,
)
from pathlib import Path as _Path
resolved_root = _Path("/data/hermes/hermes-agent")
junction_root = _Path("/home/u/AppData/Local/hermes/hermes-agent")
with patch(
"tools.environments.local._hermes_repo_root_aliases",
(resolved_root, junction_root),
):
# Launcher spelling (junction form) of the exact repo root.
env = {"PYTHONPATH": str(junction_root)}
_strip_hermes_owned_pythonpath(env)
assert "PYTHONPATH" not in env
# Nested user path under the same junction prefix survives.
env = {"PYTHONPATH": os.pathsep.join([
str(junction_root / "user-data"),
"/home/user/my-lib",
])}
_strip_hermes_owned_pythonpath(env)
pp = env.get("PYTHONPATH", "")
entries = pp.split(os.pathsep) if pp else []
assert str(junction_root / "user-data") in entries
assert "/home/user/my-lib" in entries
def test_deep_path_under_repo_root_preserved(self):
"""A deeper path under the repo root (depth=2) is preserved.
``<repo>/tools/environments`` is depth=2, past the ``depth <= 1``
cutoff. Such a path is not something Electron injects and may be
a legitimate user library path, so it must survive Check 3.
a legitimate user library path, so it must survive the repo-root
ownership check.
"""
from tools.environments.local import _strip_mismatched_site_packages
from tools.environments.local import _strip_hermes_owned_pythonpath
local_file = Path(__import__("tools.environments.local", fromlist=["__file__"]).__file__).resolve()
real_repo_root = local_file.parents[2]
@@ -633,7 +761,7 @@ class TestPythonpathSelectiveStrip:
env = {
"PYTHONPATH": os.pathsep.join([deep_path, "/home/user/my-lib"]),
}
_strip_mismatched_site_packages(env)
_strip_hermes_owned_pythonpath(env)
pp = env.get("PYTHONPATH", "")
entries = pp.split(os.pathsep) if pp else []
assert deep_path in entries
@@ -696,6 +824,28 @@ class TestPythonhomeSanitized:
from tools.environments.local import _ACTIVE_VENV_MARKER_VARS
assert "PYTHONHOME" in _ACTIVE_VENV_MARKER_VARS
def test_build_subprocess_env_no_scrub_preserves_pythonhome(self):
"""``build_subprocess_env(scrub_secrets=False)`` is the documented
byte-for-byte escape hatch: no key is removed, so PYTHONHOME (and
everything else) survives there by contract, not by omission.
Callers that explicitly opt out of scrubbing (git credential flows,
secret CLIs) must not have their environment silently altered — this
test pins that exception as intentional.
"""
from tools.environments.local import build_subprocess_env
base = {
"PATH": "/usr/bin:/bin",
"HOME": "/home/user",
"PYTHONHOME": "/opt/hermes-venv",
"VIRTUAL_ENV": "/opt/hermes-venv",
"SERVICE_TOKEN": "s3cr3t",
}
result = build_subprocess_env(base, scrub_secrets=False)
assert result.get("PYTHONHOME") == "/opt/hermes-venv"
assert result.get("VIRTUAL_ENV") == "/opt/hermes-venv"
assert result.get("SERVICE_TOKEN") == "s3cr3t"
class TestProfileScopedPassthrough:
def test_make_run_env_uses_active_profile_for_passthrough(self, monkeypatch):

View File

@@ -1483,16 +1483,15 @@ def execute_code(
# external venv; exposing Hermes's site-packages to that interpreter
# can mix incompatible compiled extensions (for example, Python 3.12
# NumPy with a Python 3.9 project interpreter).
# Before re-injecting PYTHONPATH, strip any mismatched site-packages
# entries that leaked through _scrub_child_env (PYTHONPATH is in
# _SAFE_ENV_PREFIXES so it passes the scrub). Cross-version entries
# (e.g. python3.11 site-packages injected by systemd/Electron) would
# poison the sandbox's sys.path with ABI-incompatible C extensions
# (#74817); Hermes venv/repo-root entries are redundant because the
# correct ones are re-added below, gated on the child interpreter
# actually being the Hermes environment.
from tools.environments.local import _strip_mismatched_site_packages
_strip_mismatched_site_packages(child_env)
#
# Before re-injecting PYTHONPATH, strip Hermes-owned entries that
# leaked through _scrub_child_env (PYTHONPATH is in _SAFE_ENV_PREFIXES
# so it passes the scrub). The sandbox runs the SAME Python as
# Hermes, so the Hermes venv entries are redundant — and if they
# came from a different Hermes venv they would poison the sandbox's
# sys.path with ABI-incompatible C extensions (#74817).
from tools.environments.local import _strip_hermes_owned_pythonpath
_strip_hermes_owned_pythonpath(child_env)
_hermes_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_existing_pp = child_env.get("PYTHONPATH", "")
_pp_parts = [tmpdir]

View File

@@ -357,7 +357,7 @@ _HERMES_PROVIDER_ENV_BLOCKLIST = _build_provider_env_blocklist()
# child can set it explicitly in the command.
#
# PYTHONPATH is NOT included here — it's handled by
# _strip_mismatched_site_packages() which removes only Hermes-owned entries,
# _strip_hermes_owned_pythonpath() which removes only Hermes-owned entries,
# preserving user-set paths.
_ACTIVE_VENV_MARKER_VARS = ("VIRTUAL_ENV", "CONDA_PREFIX", "PYTHONHOME")
@@ -519,7 +519,7 @@ def _sanitize_subprocess_env(base_env: dict | None, extra_env: dict | None = Non
for _marker in _ACTIVE_VENV_MARKER_VARS:
sanitized.pop(_marker, None)
_strip_mismatched_site_packages(sanitized)
_strip_hermes_owned_pythonpath(sanitized)
_apply_windows_msys_bash_env_defaults(sanitized)
@@ -650,7 +650,7 @@ def hermes_subprocess_env(*, inherit_credentials: bool = False) -> dict[str, str
for _marker in _ACTIVE_VENV_MARKER_VARS:
env.pop(_marker, None)
_strip_mismatched_site_packages(env)
_strip_hermes_owned_pythonpath(env)
_apply_windows_msys_bash_env_defaults(env)
@@ -1338,7 +1338,7 @@ def _make_run_env(env: dict) -> dict:
for _marker in _ACTIVE_VENV_MARKER_VARS:
run_env.pop(_marker, None)
_strip_mismatched_site_packages(run_env)
_strip_hermes_owned_pythonpath(run_env)
_apply_windows_msys_bash_env_defaults(run_env)
@@ -1381,6 +1381,19 @@ _hermes_venv_root: Path = Path(sys.prefix)
#: can shadow local packages.
_hermes_repo_root: Path = Path(__file__).resolve().parents[2]
#: Alternate spellings of the repo root that Hermes launchers may emit.
#: ``Path(__file__).resolve()`` canonicalizes symlinks/junctions, but the
#: Windows gateway launcher deliberately renders Hermes-owned paths under
#: the configured HERMES_HOME spelling (which may be a junction to another
#: drive — see ``hermes_cli/gateway_windows.py::_preserve_hermes_home_path``).
#: ``Path(__file__)`` (unresolved) keeps that spelling, so a PYTHONPATH
#: entry written by the launcher still matches even though it differs
#: lexically from the resolved root.
_hermes_repo_root_aliases: tuple[Path, ...] = (
_hermes_repo_root,
Path(__file__).parents[2],
)
#: Whether the current interpreter is running inside a venv. On Python 3.3+
#: ``sys.base_prefix != sys.prefix`` indicates a venv (or virtualenv).
#: ``sys.real_prefix`` is the old virtualenv (<20) marker.
@@ -1435,7 +1448,7 @@ def _get_hermes_site_packages() -> list[Path]:
_SITE_PACKAGES_RE = re.compile(r"(?:^|[\\/])site-packages(?:[\\/]|$)")
def _strip_mismatched_site_packages(env: dict) -> None:
def _strip_hermes_owned_pythonpath(env: dict) -> None:
"""Remove Hermes-owned PYTHONPATH entries from subprocess environments.
The Desktop Electron process (and other Hermes launchers) prepend the
@@ -1454,7 +1467,9 @@ def _strip_mismatched_site_packages(env: dict) -> None:
1. **Hermes repo root** - the path the Electron app prepends so the
backend can ``import tools``. Subprocesses don't need it and it can
shadow local packages of the same name.
shadow local packages of the same name. Only the exact root is
stripped; direct children (``<repo>/tools`` etc.) are never injected
by any launcher and are treated as user paths.
2. **Hermes venv site-packages** - entries under the running
interpreter's own venv site-packages directory. Redundant for
@@ -1506,18 +1521,20 @@ def _strip_mismatched_site_packages(env: dict) -> None:
# --- Check 2: Hermes repo root ---
# The Electron app prepends the repo root so ``import tools`` works
# in the backend. Subprocesses don't need it and it can shadow
# local packages of the same name.
if not should_strip and _is_path_under(entry_path, _hermes_repo_root):
# Only strip if the entry IS the repo root or a direct package
# dir under it (e.g. ``.../hermes-agent/tools``). Don't strip
# arbitrary user paths that happen to be nested deeper.
rel = entry_path.resolve() if entry_path.exists() else entry_path
try:
depth = len(rel.relative_to(_hermes_repo_root).parts)
except (ValueError, OSError):
depth = -1
if depth <= 1:
should_strip = True
# local packages of the same name. Only the EXACT root is stripped:
# no launcher injects a direct child (``<repo>/tools`` etc.) as an
# independent PYTHONPATH entry, and user paths that merely happen to
# live under the repo directory must be preserved. Both the
# resolved and unresolved (HERMES_HOME/junction) spellings count as
# Hermes-owned.
if not should_strip:
for repo_root in _hermes_repo_root_aliases:
if _is_path_under(entry_path, repo_root):
# Exact root only (same path), not children.
rel = entry_path.relative_to(repo_root)
if len(rel.parts) == 0:
should_strip = True
break
if should_strip:
stripped.append(entry)