test(tools): make subprocess env regressions Windows-portable
The PYTHONPATH/PATH sanitization suite was written POSIX-centric and failed on real Windows 11 (reproduced natively: 4 failures before this change). Fix the tests to express the true per-platform contract: - test_other_major_version_site_packages_preserved / test_make_run_env_injects_hermes_bin_dir: build inputs with os.pathsep instead of hardcoded ':'. - test_make_run_env_appends_homebrew_on_minimal_path: split on os.pathsep, neutralise Git Bash dir prepending, and assert the documented Windows passthrough (_append_missing_sane_path_entries is a no-op off POSIX) instead of the Homebrew append. - test_make_run_env_real_launchd_path_gains_homebrew: mark macos_only per repo OS-marker policy (the regression is the macOS launchd PATH; the merge is a passthrough on Windows). - test_configured_home_alias_matches_launcher_output: create the configured-home link via a helper that falls back to an unprivileged directory junction (cmd /c mklink /J) when symlink creation raises WinError 1314, and skips with a clear reason if no mechanism exists. Also correct a stale comment in execute_code: the child is not always the same Python as Hermes (project mode can select an external venv), so the strip is about compatibility, not redundancy.
This commit is contained in:
@@ -9,6 +9,7 @@ See: https://github.com/NousResearch/hermes-agent/issues/1264
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
@@ -323,6 +324,33 @@ class TestActiveVenvMarkerStripping:
|
||||
assert "CONDA_PREFIX" in _ACTIVE_VENV_MARKER_VARS
|
||||
|
||||
|
||||
def _make_directory_link(link: Path, target: Path) -> None:
|
||||
"""Create a directory link without requiring symlink privileges.
|
||||
|
||||
POSIX: Path.symlink_to. Windows: try symlink_to first (works with
|
||||
Developer Mode enabled), then fall back to an unprivileged directory
|
||||
junction via `cmd /c mklink /J` -- junctions do not require the
|
||||
SeCreateSymbolicLinkPrivilege. Raises the original error when no
|
||||
mechanism is available so callers can skip with a clear reason.
|
||||
"""
|
||||
try:
|
||||
link.symlink_to(target, target_is_directory=True)
|
||||
return
|
||||
except OSError:
|
||||
if sys.platform != "win32":
|
||||
raise
|
||||
# Binary capture: on a localized Windows the junction message is in the
|
||||
# console code page (e.g. GBK), which would raise UnicodeDecodeError in
|
||||
# the reader thread under UTF-8 mode. Only the exit code matters.
|
||||
result = subprocess.run(
|
||||
["cmd", "/c", "mklink", "/J", str(link), str(target)],
|
||||
capture_output=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
detail = result.stderr.decode("utf-8", errors="replace").strip()
|
||||
raise OSError(detail or f"mklink /J failed: {result.returncode}")
|
||||
|
||||
|
||||
class TestPythonpathSelectiveStrip:
|
||||
"""PYTHONPATH Hermes-owned entry stripping (#74817).
|
||||
|
||||
@@ -418,7 +446,10 @@ class TestPythonpathSelectiveStrip:
|
||||
"""
|
||||
from tools.environments.local import _strip_hermes_owned_pythonpath
|
||||
env = {
|
||||
"PYTHONPATH": "/old/lib/python2.7/site-packages:/home/user/lib",
|
||||
"PYTHONPATH": os.pathsep.join([
|
||||
"/old/lib/python2.7/site-packages",
|
||||
"/home/user/lib",
|
||||
]),
|
||||
}
|
||||
_strip_hermes_owned_pythonpath(env)
|
||||
assert "PYTHONPATH" in env
|
||||
@@ -865,7 +896,10 @@ class TestPythonpathSelectiveStrip:
|
||||
physical_root = physical_home / "hermes-agent"
|
||||
physical_root.mkdir(parents=True)
|
||||
configured_home = tmp_path / "configured-home"
|
||||
configured_home.symlink_to(physical_home, target_is_directory=True)
|
||||
try:
|
||||
_make_directory_link(configured_home, physical_home)
|
||||
except OSError as exc:
|
||||
pytest.skip(f"directory link unavailable on this host: {exc}")
|
||||
monkeypatch.setenv("HERMES_HOME", str(configured_home))
|
||||
|
||||
launcher_entry = Path(_preserve_hermes_home_path(physical_root))
|
||||
@@ -1217,25 +1251,42 @@ class TestSanePathIncludesHomebrew:
|
||||
assert "/opt/homebrew/bin" in _SANE_PATH
|
||||
|
||||
|
||||
def test_make_run_env_appends_homebrew_on_minimal_path(self):
|
||||
"""When PATH is minimal, _make_run_env appends missing sane entries."""
|
||||
def test_make_run_env_appends_homebrew_on_minimal_path(self, monkeypatch):
|
||||
"""When PATH is minimal, _make_run_env appends missing sane entries.
|
||||
|
||||
POSIX: the sane-path merge appends the Homebrew dirs. Windows:
|
||||
_append_missing_sane_path_entries is a documented passthrough (the
|
||||
native PATH must not be touched), so the assertion is the unchanged
|
||||
input. Git Bash dir prepending is neutralised so the merged PATH
|
||||
layout is deterministic on every host.
|
||||
"""
|
||||
from tools.environments import local as local_mod
|
||||
from tools.environments.local import _SANE_PATH, _make_run_env
|
||||
monkeypatch.setattr(local_mod, "_git_bash_bin_dirs", lambda: [])
|
||||
minimal_env = {"PATH": "/some/custom/bin"}
|
||||
with patch.dict(os.environ, minimal_env, clear=True):
|
||||
result = _make_run_env({})
|
||||
path_entries = result["PATH"].split(":")
|
||||
path_entries = result["PATH"].split(os.pathsep)
|
||||
assert path_entries[0] == "/some/custom/bin"
|
||||
for entry in _SANE_PATH.split(":"):
|
||||
assert entry in path_entries
|
||||
if sys.platform == "win32":
|
||||
assert result["PATH"] == "/some/custom/bin"
|
||||
else:
|
||||
for entry in _SANE_PATH.split(os.pathsep):
|
||||
assert entry in path_entries
|
||||
|
||||
|
||||
@pytest.mark.macos_only
|
||||
def test_make_run_env_real_launchd_path_gains_homebrew(self):
|
||||
"""The literal macOS launchd PATH is the production trigger for #35613."""
|
||||
"""The literal macOS launchd PATH is the production trigger for #35613.
|
||||
|
||||
macOS-only: the regression is the launchd environment on macOS, and
|
||||
the sane-path merge is a documented passthrough on Windows.
|
||||
"""
|
||||
from tools.environments.local import _make_run_env
|
||||
launchd_env = {"PATH": "/usr/bin:/bin:/usr/sbin:/sbin"}
|
||||
launchd_env = {"PATH": os.pathsep.join(["/usr/bin", "/bin", "/usr/sbin", "/sbin"])}
|
||||
with patch.dict(os.environ, launchd_env, clear=True):
|
||||
result = _make_run_env({})
|
||||
path_entries = result["PATH"].split(":")
|
||||
path_entries = result["PATH"].split(os.pathsep)
|
||||
assert "/opt/homebrew/bin" in path_entries
|
||||
assert "/opt/homebrew/sbin" in path_entries
|
||||
# Original entries keep their leading precedence.
|
||||
@@ -1298,7 +1349,11 @@ class TestHermesBinDirOnPath:
|
||||
from tools.environments.local import _make_run_env
|
||||
self._reset_cache()
|
||||
local_mod._HERMES_BIN_DIR = "/opt/hermes/bin"
|
||||
with patch.dict(os.environ, {"PATH": "/usr/bin:/bin"}, clear=True):
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{"PATH": os.pathsep.join(["/usr/bin", "/bin"])},
|
||||
clear=True,
|
||||
):
|
||||
result = _make_run_env({})
|
||||
entries = result["PATH"].split(os.pathsep)
|
||||
assert entries[0] == "/opt/hermes/bin"
|
||||
|
||||
@@ -1486,10 +1486,10 @@ def execute_code(
|
||||
#
|
||||
# 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).
|
||||
# so it passes the scrub). They are redundant for same-Hermes-
|
||||
# environment children and may be incompatible with external
|
||||
# interpreters (project mode can select a different venv), so they
|
||||
# must not shadow or poison the child's sys.path (#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__)))
|
||||
|
||||
Reference in New Issue
Block a user