Prepare dependency generations before selecting them. Keep shipped tool bytes separate from writable additions, and store facts beside their entries. Validate proposed plugin sets before config publication. Restore the previous config if the facts write fails. Consolidate duplicate updater, backup, setup, and voice helpers. Repair launcher selection, dependency consumers, download ownership, update feeds, and native Windows process and file handling. Verification: 206 changed/prior-failing Python files reported 4630 passed, one failed, and 330 skipped. Fix the remaining Hindsight fixture boundary. The final targeted rerun reported 234 passed and two skipped. The store review regression batch reported 83 passed and one skipped. Desktop TypeScript checks, 56 selected Electron tests, 24 release tests, and the removed-import/compatibility guards passed. This is an integration checkpoint, not full audit acceptance. The complete Python suite has not run on this fixed tree. Crash-atomic plugin publication, generation cleanup, receipt correlation, and packaged lifecycle acceptance remain open in docs/pm-audit-status.md.
152 lines
6.7 KiB
Python
152 lines
6.7 KiB
Python
"""Hermes-owned PYTHONPATH stripping for child processes. Launchers prepend the repo
|
|
root and the Hermes venv's site-packages so the backend can ``import tools``; leaked
|
|
into a child Python of a DIFFERENT version they load the backend's C extensions and
|
|
crash. Only entries proven Hermes-owned by *path provenance* are removed — never by a
|
|
cross-version heuristic. Module state (``_hermes_repo_root_aliases``, ``_in_venv``,
|
|
``_hermes_site_packages``) lives in ``tools.environments.local`` (via :func:`_state`)
|
|
so tests monkeypatching it there keep working."""
|
|
|
|
import logging
|
|
import os
|
|
import platform
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
from tools.environments.local_env_policy import _ACTIVE_VENV_MARKER_VARS
|
|
|
|
_IS_WINDOWS = platform.system() == "Windows"
|
|
|
|
logger = logging.getLogger("tools.environments.local")
|
|
|
|
|
|
def _state():
|
|
"""Return the ``tools.environments.local`` module (owner of the caches)."""
|
|
from tools.environments import local
|
|
|
|
return local
|
|
|
|
|
|
def _same_path(left: Path, right: Path) -> bool:
|
|
"""Compare path spellings with host filesystem case semantics."""
|
|
return [os.path.normcase(p) for p in left.parts] == [os.path.normcase(p) for p in right.parts]
|
|
|
|
|
|
def _build_hermes_repo_root_aliases(
|
|
resolved_root: Path, lexical_root: Path, configured_home: Path,
|
|
) -> tuple[Path, ...]:
|
|
"""Exact repo-root spellings emitted by Hermes launchers. Mirrors
|
|
``gateway_windows._preserve_hermes_home_path`` (physical path under the resolved
|
|
HERMES_HOME -> configured spelling) so a junction-backed install matches without
|
|
treating arbitrary HERMES_HOME descendants as Hermes-owned. A repo-level junction
|
|
(possibly cross-drive) is accepted only when a strict resolve proves
|
|
<root>/<repo dirname> is the physical root (fail-closed)."""
|
|
candidates = [resolved_root, lexical_root]
|
|
# Profile re-home: with --profile the configured home is <root>/profiles/<name>
|
|
# and the repo lives beside the profiles dir (as get_default_hermes_root() does).
|
|
home_candidates = [configured_home]
|
|
if configured_home.parent.name == "profiles":
|
|
home_candidates.append(configured_home.parent.parent)
|
|
for home in home_candidates:
|
|
try:
|
|
resolved_home = home.resolve()
|
|
home_key = os.path.normcase(str(resolved_home))
|
|
if os.path.commonpath([home_key, os.path.normcase(str(resolved_root))]) == home_key:
|
|
candidates.append(home / os.path.relpath(str(resolved_root), str(resolved_home)))
|
|
except (OSError, ValueError):
|
|
pass
|
|
# Repo-level junction recovery (commonpath raises across drives, so the
|
|
# home-relative mapping above cannot express a cross-drive link).
|
|
for home in home_candidates:
|
|
repo_candidate = home / resolved_root.name
|
|
try:
|
|
if repo_candidate.resolve(strict=True) == resolved_root.resolve(strict=True):
|
|
candidates.append(repo_candidate)
|
|
except OSError:
|
|
pass
|
|
aliases: list[Path] = []
|
|
for candidate in candidates:
|
|
if not any(_same_path(candidate, existing) for existing in aliases):
|
|
aliases.append(candidate)
|
|
return tuple(aliases)
|
|
|
|
|
|
def _validated_runtime_venv(env: dict) -> Path | None:
|
|
"""Producer-owned runtime venv identified by VIRTUAL_ENV, or None. The variable
|
|
alone is not provenance (users carry unrelated venvs): require the legacy Windows
|
|
base-Python producer's exact ``<repo>/venv`` layout AND a real ``pyvenv.cfg``."""
|
|
from hermes_cli.runtime_paths import runtime_facts_path, selected_venv
|
|
|
|
root = Path(__file__).resolve().parents[2]
|
|
if runtime_facts_path(root).is_file():
|
|
return selected_venv(root)
|
|
candidate = Path(env.get("VIRTUAL_ENV") or "")
|
|
if not env.get("VIRTUAL_ENV") or not any(
|
|
_same_path(candidate, root / "venv") for root in _state()._hermes_repo_root_aliases):
|
|
return None
|
|
try:
|
|
return candidate if (candidate / "pyvenv.cfg").is_file() else None
|
|
except OSError:
|
|
return None
|
|
|
|
|
|
def _get_hermes_site_packages(env: dict) -> list[Path]:
|
|
"""Exact site-packages dirs owned by the Hermes runtime (cached):
|
|
``site.getsitepackages()`` with a ``sys.prefix`` fallback, plus a validated
|
|
Windows base-interpreter launch's ``VIRTUAL_ENV/Lib/site-packages``."""
|
|
local = _state()
|
|
if local._hermes_site_packages is None:
|
|
result: list[Path] = []
|
|
if local._in_venv:
|
|
try:
|
|
import site
|
|
result.extend(Path(sp) for sp in site.getsitepackages())
|
|
except Exception:
|
|
pass
|
|
if not result:
|
|
pyver = f"python{sys.version_info[0]}.{sys.version_info[1]}"
|
|
result.append(Path(sys.prefix) / "Lib" / "site-packages" if _IS_WINDOWS
|
|
else Path(sys.prefix) / "lib" / pyver / "site-packages")
|
|
local._hermes_site_packages = list(result)
|
|
result = list(local._hermes_site_packages)
|
|
|
|
runtime_venv = _validated_runtime_venv(env)
|
|
if runtime_venv is not None:
|
|
from hermes_cli.runtime_paths import site_packages
|
|
runtime_site_packages = site_packages(runtime_venv)
|
|
if not any(_same_path(runtime_site_packages, existing) for existing in result):
|
|
result.append(runtime_site_packages)
|
|
return result
|
|
|
|
|
|
def _strip_hermes_owned_pythonpath_and_runtime_markers(env: dict) -> None:
|
|
"""Strip Hermes-owned PYTHONPATH entries, then the runtime marker vars. Order is
|
|
load-bearing: PYTHONPATH filtering runs BEFORE the markers go so a validated Windows
|
|
base-interpreter launch (VIRTUAL_ENV -> <repo>/venv) can still prove ownership."""
|
|
_strip_hermes_owned_pythonpath(env)
|
|
for _marker in _ACTIVE_VENV_MARKER_VARS:
|
|
env.pop(_marker, None)
|
|
|
|
|
|
def _strip_hermes_owned_pythonpath(env: dict) -> None:
|
|
"""Remove Hermes-owned PYTHONPATH entries: only exact matches of the repo root
|
|
(any launcher spelling) and runtime site-packages — never descendants, which are
|
|
user paths. Empty components (= cwd) and everything else are preserved.
|
|
|
|
Everything else -- user libs, Nix plugin paths, a pythonX.Y/site-packages entry meant for a DIFFERENT
|
|
child version -- is preserved byte-for-byte: ownership is decided by path provenance, never by a
|
|
cross-version heuristic (#74817 follow-up).
|
|
"""
|
|
pp = env.get("PYTHONPATH")
|
|
if not pp:
|
|
return
|
|
owned_paths = [*_get_hermes_site_packages(env), *_state()._hermes_repo_root_aliases]
|
|
entries = pp.split(os.pathsep)
|
|
stripped = [e for e in entries if e and any(_same_path(Path(e), p) for p in owned_paths)]
|
|
kept = [e for e in entries if e not in stripped]
|
|
if kept:
|
|
env["PYTHONPATH"] = os.pathsep.join(kept)
|
|
else:
|
|
env.pop("PYTHONPATH", None)
|
|
if stripped:
|
|
logger.debug("Stripped Hermes-owned entries from PYTHONPATH: %s", stripped)
|