File size: 6,657 Bytes
5655a42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
"""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``."""
    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:
        runtime_site_packages = runtime_venv / "Lib" / "site-packages"
        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)