diff --git a/hermes_cli/macos_tcc_anchor.py b/hermes_cli/macos_tcc_anchor.py index 62a973aa88..cd35be1a74 100644 --- a/hermes_cli/macos_tcc_anchor.py +++ b/hermes_cli/macos_tcc_anchor.py @@ -38,6 +38,7 @@ uv-managed. Best-effort: never raises to callers. from __future__ import annotations +import errno import logging import os import platform @@ -61,6 +62,13 @@ class _BootGateFailed(Exception): """Staged copy refused to boot; the live venv must stay untouched.""" +def _marker_value(source_file: Path) -> str: + """Canonical marker value: fully resolved so symlinked spellings of the + same store binary (``cpython-3.11-macos-*`` → ``cpython-3.11.15-macos-*``) + compare equal.""" + return os.path.realpath(str(source_file)) + + def is_macos() -> bool: return platform.system() == "Darwin" @@ -231,19 +239,34 @@ def _materialize_aliases( def _passes_boot_gate(staged: Path, venv_dir: Path) -> bool: """Launch *staged* and demand encodings + the venv prefix. - ``OSError`` (fixture / wrong arch / exec-format) is treated as a skip — - we cannot verify, and we also cannot brick a host that cannot run the - binary. A real crash (dyld, encodings, wrong prefix) refuses the install. + The probe runs with ``PYTHONHOME``/``PYTHONPATH`` scrubbed: an inherited + ``PYTHONHOME`` papers over exactly the prefix-resolution failure the gate + exists to catch. ``OSError`` is split by errno — ``ENOENT``/``ENOEXEC`` + (fixture binaries, foreign-arch images) means the binary cannot run here + at all, so the symlinked venv was equally dead and installing cannot make + things worse: skip. Anything else (notably ``EACCES`` after our own + chmod) is a broken install about to go live: refuse. """ + env = { + k: v + for k, v in os.environ.items() + if k not in ("PYTHONHOME", "PYTHONPATH", "PYTHONSTARTUP", + "__PYVENV_LAUNCHER__") + } try: proc = subprocess.run( [str(staged), "-c", "import encodings, sys; print(sys.prefix)"], capture_output=True, text=True, timeout=30, + env=env, ) - except OSError: - return True + except OSError as exc: + if exc.errno in (errno.ENOENT, errno.ENOEXEC): + logger.debug("boot gate skipped: cannot execute %s (%s)", staged, exc) + return True + logger.warning("boot gate: staged copy not executable: %s", exc) + return False except subprocess.TimeoutExpired: return False if proc.returncode != 0: @@ -282,7 +305,7 @@ def _install_anchor(venv_dir: Path, source_file: Path) -> None: f"staged copy at {tmp_path} failed encodings/prefix probe" ) os.replace(tmp_path, venv_py) - _anchor_marker(venv_bin).write_text(str(source_file), encoding="utf-8") + _anchor_marker(venv_bin).write_text(_marker_value(source_file), encoding="utf-8") _materialize_aliases(venv_bin, venv_py, refresh=True) except Exception: try: @@ -317,8 +340,8 @@ def ensure_tcc_anchor(project_root: Path | None = None) -> Path | None: if not venv_py.is_symlink(): marker = _anchor_marker(venv_py.parent) try: - if marker.is_file() and marker.read_text(encoding="utf-8").strip() == str( - source_file + if marker.is_file() and marker.read_text(encoding="utf-8").strip() == ( + _marker_value(source_file) ): _provision_libpython(venv_dir, source_file, refresh=False) if _passes_boot_gate(venv_py, venv_dir): @@ -361,7 +384,7 @@ def tcc_anchor_state(project_root: Path | None = None) -> tuple[str, str]: if not venv_py.is_symlink(): marker = _anchor_marker(venv_py.parent) source_file = _interpreter_file(source) - expected = str(source_file) if source_file is not None else source + expected = _marker_value(source_file) if source_file is not None else source try: if marker.is_file() and marker.read_text(encoding="utf-8").strip() == expected: return "active", str(venv_py) diff --git a/tests/hermes_cli/test_macos_tcc_anchor.py b/tests/hermes_cli/test_macos_tcc_anchor.py index f16640e9f6..818b2e6d2d 100644 --- a/tests/hermes_cli/test_macos_tcc_anchor.py +++ b/tests/hermes_cli/test_macos_tcc_anchor.py @@ -12,6 +12,7 @@ the existing macOS CI job, not against one-byte fixtures. from __future__ import annotations +import errno import os import platform import shutil @@ -332,15 +333,47 @@ class TestBootGate: assert not tcc._passes_boot_gate(tmp_path / "staged", tmp_path / "venv") def test_skips_unexecutable_staging(self, tmp_path, monkeypatch): - # OSError (fixture binaries, exec-format on a foreign arch) means the - # binary cannot run here at all — the symlinked venv was equally dead, - # so installing cannot make things worse. Skip, don't refuse. + # ENOENT/ENOEXEC (fixture bad-shebang binaries, wrong-arch images) + # mean the binary cannot run here at all — the symlinked venv was + # equally dead, so installing cannot make things worse. def noexec(*a, **k): - raise OSError("cannot execute") + raise OSError(errno.ENOENT, "No such file or directory") monkeypatch.setattr(tcc.subprocess, "run", noexec) assert tcc._passes_boot_gate(tmp_path / "staged", tmp_path / "venv") + def test_refuses_eacces_after_chmod(self, tmp_path, monkeypatch): + # EACCES after copy2+chmod is a broken install about to go live — + # refuse, never skip (review: kokhlo on #95605). + def denied(*a, **k): + raise PermissionError(errno.EACCES, "Permission denied") + + monkeypatch.setattr(tcc.subprocess, "run", denied) + assert not tcc._passes_boot_gate(tmp_path / "staged", tmp_path / "venv") + + def test_scrubs_pythonhome_pythonpath(self, tmp_path, monkeypatch): + # A staged copy that dies with `No module named 'encodings'` boots + # fine under PYTHONHOME= — the inherited var papers over the + # exact failure the gate exists to catch (review: kokhlo on #95605, + # verified live). The probe must run with a scrubbed environment. + captured = {} + + def spy(*a, **k): + captured.update(k) + return self._proc(a[0], 0, f"{tmp_path / 'venv'}\n", "") + + monkeypatch.setattr(tcc.subprocess, "run", spy) + monkeypatch.setenv("PYTHONHOME", "/poison") + monkeypatch.setenv("PYTHONPATH", "/poison") + venv = tmp_path / "venv" + venv.mkdir(exist_ok=True) + + assert tcc._passes_boot_gate(tmp_path / "staged", venv) + env = captured.get("env") + assert env is not None, "gate must pass an explicit scrubbed env" + assert "PYTHONHOME" not in env + assert "PYTHONPATH" not in env + def test_accepts_matching_prefix(self, tmp_path, monkeypatch): venv = tmp_path / "venv" venv.mkdir() @@ -352,6 +385,29 @@ class TestBootGate: class TestTccAnchorState: + def test_state_active_through_unpatched_home_symlink(self, tmp_path, monkeypatch): + # The managed-runtime layout symlinks cpython-3.11-macos-* → + # cpython-3.11.15-macos-*, so pyvenv.cfg home and the marker record + # different spellings of the same binary. State must resolve both + # sides before comparing, or a fresh install reports stale + # (review: kokhlo on #95605, hit on a live venv). + _darwin(monkeypatch) + patched = _build_store(tmp_path, version="3.11.15") + versionless = patched.parent.parent / "cpython-3.11-macos-aarch64-none" + os.symlink(patched.parent, versionless) + home = versionless / "bin" + + root = tmp_path / "checkout" + venv_bin = root / ".venv" / "bin" + venv_bin.mkdir(parents=True) + (root / ".venv" / "pyvenv.cfg").write_text(f"home = {home}\n") + os.symlink(home / "python3.11", venv_bin / "python") + + tcc.ensure_tcc_anchor(root) + + status, _ = tcc.tcc_anchor_state(root) + assert status == "active" + def test_state_missing_then_active(self, tmp_path, monkeypatch): _darwin(monkeypatch) store_bin = _build_store(tmp_path)