From aa72df4b426891d9dc1d92ee4f4d95773ace29cb Mon Sep 17 00:00:00 2001 From: Zheqing Zeng Date: Wed, 26 Aug 2026 07:17:39 -0700 Subject: [PATCH] fix(macos): re-land dylib-complete TCC interpreter anchor The first landing (#95131/#95478, reverted in #95563) copied the uv-store interpreter into venv/bin/python so TCC grants would stick to a stable path. On real Macs that copy bricked every hermes command two ways: dynamically-linked builds died in dyld because @executable_path/../lib/libpython resolved into venv/lib/ (#95425), and alias symlinks to the copy made CPython getpath lose the venv prefix (#95541, ModuleNotFoundError: encodings). Re-land: - Keep the signed real-file copy of bin/python (identifier-pinned via _macos_sign_managed_python). - Materialize python3 / python3.N as real-file copies, never symlinks. Copies boot on every build we could reproduce and keep the TCC identity. - Hardlink store libpython* into venv/lib/ when present (copy across devices). Existing LC_RPATH already points there. - Pre-install boot gate: launch the staged copy, demand encodings plus the venv prefix, abort and leave the live venv untouched on failure. Doctor reports/installs the new anchor (the revert-era heal is removed). Update refreshes it after a successful code swap. Tests cover layout, idempotence, predecessor-symlink repair, libpython hardlink, boot-gate refusal, and a macos_only real-interpreter E2E. Closes #95596. --- hermes_cli/doctor.py | 78 ++-- hermes_cli/macos_tcc_anchor.py | 371 +++++++++++++++++ hermes_cli/update_cmd.py | 15 +- tests/hermes_cli/test_macos_tcc_anchor.py | 452 +++++++++++++++++++++ tests/hermes_cli/test_tcc_anchor_revert.py | 78 ---- 5 files changed, 859 insertions(+), 135 deletions(-) create mode 100644 hermes_cli/macos_tcc_anchor.py create mode 100644 tests/hermes_cli/test_macos_tcc_anchor.py delete mode 100644 tests/hermes_cli/test_tcc_anchor_revert.py diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index ba831dd378..dec9f8801a 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -1154,54 +1154,33 @@ def _macos_desktop_dr(app: Path) -> str | None: return (proc.stdout or "") + (proc.stderr or "") -def check_macos_tcc_anchor_removed() -> None: - """Detect and repair a venv bricked by the reverted TCC anchor. +def check_macos_tcc_anchor(should_fix: bool = False) -> None: + """Report (and optionally install) the dylib-complete TCC anchor (#95596). - The anchor (#95131/#95478, reverted) replaced ``venv/bin/python`` with a - real-file copy of the uv-store interpreter. On real Macs that copy could - not start: its ``LC_RPATH`` (``@executable_path/../lib``) resolved to - ``venv/lib/``, which holds no libpython — every hermes command died in - dyld (#95425), and re-pointed aliases lost the stdlib (#95541). The - revert stops NEW anchors; this check heals venvs the anchor already - converted, by restoring ``bin/python`` to a symlink pointing at the - recorded source interpreter (the marker file the anchor wrote). - Silent on non-macOS and on venvs the anchor never touched. + Silent on non-macOS and for interpreters that are not uv-managed. Never + raises — a failed check must not crash doctor. Install is gated by the + module's pre-install boot probe, so ``--fix`` cannot brick the CLI. """ - if sys.platform != "darwin": - return - # Resolved at call time via the module global so tests can retarget it. - root = Path(globals()["__file__"]).resolve().parents[1] - for name in ("venv", ".venv"): - venv_bin = root / name / "bin" - marker = venv_bin / ".tcc-anchor-source" - if not marker.is_file(): - continue - try: - source = Path(marker.read_text(encoding="utf-8").strip()) - venv_py = venv_bin / "python" - if source.is_file() and venv_py.is_file() and not venv_py.is_symlink(): - tmp = venv_bin / ".python-unanchor-tmp" - tmp.unlink(missing_ok=True) - os.symlink(source, tmp) - os.replace(tmp, venv_py) - # Restore versioned aliases to point at bin/python. - for alias in venv_bin.glob("python3*"): - if alias.is_symlink() or alias.is_file(): - alias_tmp = venv_bin / f".{alias.name}.unanchor-tmp" - alias_tmp.unlink(missing_ok=True) - os.symlink("python", alias_tmp) - os.replace(alias_tmp, alias) - marker.unlink(missing_ok=True) - check_ok( - "macOS TCC anchor removed", - f"({name}/bin/python restored to a symlink; the anchor " - "(#95425/#95541) is reverted)", - ) - except Exception as e: # diagnostics must never crash - check_warn( - "macOS TCC anchor cleanup failed", - f"({e}) — restore manually: ln -sf $(cat {marker}) {venv_bin / 'python'}", - ) + try: + from hermes_cli import macos_tcc_anchor as tcc + + status, detail = tcc.tcc_anchor_state() + if status == "skip": + return + if status == "active": + check_ok("macOS TCC anchor active", f"({detail})") + return + if should_fix: + anchored = tcc.ensure_tcc_anchor() + if anchored is not None: + check_ok("macOS TCC anchor installed", f"({anchored})") + return + check_warn( + "macOS TCC anchor missing" if status == "missing" else "macOS TCC anchor stale", + f"({detail})", + ) + except Exception as e: # diagnostics must never crash + check_warn("macOS TCC anchor check failed", f"({e})") def check_macos_full_disk_access() -> None: @@ -1423,10 +1402,9 @@ def run_doctor(args): else: check_warn("Not in virtual environment", "(recommended)") - # macOS TCC anchor REVERTED (#95425/#95541: anchored copies couldn't load - # libpython — every hermes command died in dyld). This heals venvs the - # anchor already converted. Silent on non-macOS. - check_macos_tcc_anchor_removed() + # macOS TCC interpreter anchor (#95596): dylib-complete re-land of the + # mechanism reverted in #95563. Silent on non-macOS. + check_macos_tcc_anchor(should_fix=should_fix) # macOS Full Disk Access (issue #52010 follow-up): one grant silences # every per-folder prompt permanently. Silent on non-macOS. diff --git a/hermes_cli/macos_tcc_anchor.py b/hermes_cli/macos_tcc_anchor.py new file mode 100644 index 0000000000..62a973aa88 --- /dev/null +++ b/hermes_cli/macos_tcc_anchor.py @@ -0,0 +1,371 @@ +"""Stable macOS TCC anchor for the uv-managed Python interpreter (#95596). + +Re-land of the interpreter anchor reverted in #95563. macOS keys TCC grants +to the resolved absolute path of the client binary. Hermes' interpreter is +managed by uv and lives at a versioned store path; every patch bump orphans +every prior grant (#85345). + +The first landing copied the interpreter into ``venv/bin/python`` but left +two holes that bricked real Macs: + +* Dynamically-linked builds look up ``libpython`` via + ``@executable_path/../lib``. That resolved into ``venv/lib/``, which had + no dylib — every hermes command, including update/doctor, died in dyld + (#95425). +* Alias names (``python3``, ``python3.N``) were re-pointed at the copy as + *symlinks*. Invoking the copied interpreter through a symlink makes + CPython getpath lose the venv prefix on affected python-build-standalone + builds — startup dies with ``ModuleNotFoundError: encodings`` and the + stdlib resolves to the build-time ``/install`` prefix (#95541). Console + scripts exec ``python3``, so the entire CLI surface died. + +This re-land keeps the copy + identifier-pinned signature (TCC attribution +stays on the stable venv path) and closes both holes: + +1. Aliases are materialized as real-file copies of the anchor, never + symlinks. +2. If the store ships ``libpython*``, it is hardlinked into ``venv/lib/`` + (copy if the store is on another device). Existing ``LC_RPATH`` already + points at ``@executable_path/../lib`` — no rewrite. +3. A pre-install boot gate actually launches the staged copy and demands + ``import encodings`` plus ``sys.prefix == ``. Failure rolls the + staging file back and leaves the live venv untouched, so a bad anchor + can never brick update/doctor again. + +All functions are no-ops on non-macOS and for interpreters that are not +uv-managed. Best-effort: never raises to callers. +""" + +from __future__ import annotations + +import logging +import os +import platform +import re +import shutil +import subprocess +import tempfile +from pathlib import Path + +from hermes_constants import venv_python_path + +logger = logging.getLogger(__name__) + +_MARKER_NAME = ".tcc-anchor-source" + +_STORE_COMMON_MARKERS = ("cpython-", "-macos-") +_STORE_ROOT_MARKERS = ("/uv/python/", "/.hermes-runtime/python/") + + +class _BootGateFailed(Exception): + """Staged copy refused to boot; the live venv must stay untouched.""" + + +def is_macos() -> bool: + return platform.system() == "Darwin" + + +def _sibling_names() -> tuple[str, ...]: + """Alias names uv creates inside the venv bin dir.""" + import sys as _sys + + return ("python3", f"python3.{_sys.version_info.minor}") + + +def _store_bin_names() -> tuple[str, ...]: + """Preferred interpreter file names inside a store ``bin`` dir.""" + import sys as _sys + + return (f"python3.{_sys.version_info.minor}", "python3", "python") + + +def _is_uv_macos_store(path: str) -> bool: + normalized = path.replace("\\", "/") + if not all(marker in normalized for marker in _STORE_COMMON_MARKERS): + return False + return any(marker in normalized for marker in _STORE_ROOT_MARKERS) + + +def _venv_dir(project_root: Path | None = None) -> Path | None: + root = ( + Path(project_root) + if project_root is not None + else Path(__file__).resolve().parents[1] + ) + for name in ("venv", ".venv"): + candidate = root / name + venv_py = venv_python_path(candidate) + if venv_py.is_file() or venv_py.is_symlink(): + return candidate + return None + + +def _interpreter_file(src: str | Path) -> Path | None: + """Return the interpreter binary file at/inside *src*.""" + p = Path(src) + if p.is_file(): + return p + if not p.is_dir(): + return None + for name in _store_bin_names(): + candidate = p / name + if candidate.is_file(): + return candidate + try: + for candidate in sorted(p.glob("python3.*")): + if candidate.is_file() and not candidate.name.endswith((".dSYM", ".txt")): + return candidate + except OSError: + return None + return None + + +def _interpreter_source(venv_dir: Path) -> str | None: + """Return the interpreter file the venv currently resolves to.""" + venv_py = venv_python_path(venv_dir) + if venv_py.is_symlink(): + try: + resolved = venv_py.resolve(strict=False) + except OSError: + return None + return str(resolved) + cfg = venv_dir / "pyvenv.cfg" + if not cfg.is_file(): + return None + home = "" + try: + for line in cfg.read_text(encoding="utf-8").splitlines(): + if line.lower().startswith("home"): + _, _, home = line.partition("=") + home = home.strip() + break + except OSError: + return None + if not home: + return None + interp = _interpreter_file(home) + return str(interp) if interp is not None else None + + +def _anchor_marker(venv_bin: Path) -> Path: + return venv_bin / _MARKER_NAME + + +def _store_root(source_file: Path) -> Path: + # .../cpython--macos-*/bin/python3.N → store root + return source_file.resolve(strict=False).parent.parent + + +def _provision_libpython( + venv_dir: Path, source_file: Path, *, refresh: bool = False +) -> None: + """Hardlink (else copy) store ``libpython*`` into ``venv/lib/``. + + Provision-if-present: a surplus hardlink on a statically-linked build is + free; a missed detection is the only way #95425 returns. + """ + src_lib = _store_root(source_file) / "lib" + if not src_lib.is_dir(): + return + dst_lib = venv_dir / "lib" + try: + dst_lib.mkdir(parents=True, exist_ok=True) + for src in src_lib.glob("libpython*"): + if not src.is_file(): + continue + dst = dst_lib / src.name + if dst.exists() or dst.is_symlink(): + if not refresh: + continue + try: + dst.unlink() + except OSError: + continue + try: + os.link(src, dst) + except OSError: + try: + shutil.copy2(src, dst) + except OSError: + logger.debug("libpython provision failed for %s", src, exc_info=True) + except OSError: + logger.debug("libpython provision skipped", exc_info=True) + + +def _copy_alias(venv_bin: Path, name: str, anchor: Path) -> None: + """Materialize *name* as a real-file copy of *anchor* (atomic rename).""" + tmp = venv_bin / f".{name}.tcc-tmp" + try: + shutil.copy2(anchor, tmp) + os.chmod(tmp, anchor.stat().st_mode | 0o111) + os.replace(tmp, venv_bin / name) + except OSError: + try: + tmp.unlink(missing_ok=True) + except OSError: + pass + + +def _materialize_aliases( + venv_bin: Path, anchor: Path, *, refresh: bool = False +) -> None: + """Materialize uv alias names as real-file copies of the anchor.""" + names = set(_sibling_names()) + try: + names.update( + p.name + for p in venv_bin.glob("python3*") + if re.fullmatch(r"python3(\.\d+)?", p.name) + ) + except OSError: + pass + for name in sorted(names): + alias = venv_bin / name + try: + if refresh or alias.is_symlink() or not alias.exists(): + _copy_alias(venv_bin, name, anchor) + except OSError: + continue + + +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. + """ + try: + proc = subprocess.run( + [str(staged), "-c", "import encodings, sys; print(sys.prefix)"], + capture_output=True, + text=True, + timeout=30, + ) + except OSError: + return True + except subprocess.TimeoutExpired: + return False + if proc.returncode != 0: + return False + printed = (proc.stdout or "").strip().splitlines() + if not printed: + return False + try: + return Path(printed[-1]).resolve() == venv_dir.resolve() + except OSError: + return str(venv_dir) in printed[-1] + + +def _install_anchor(venv_dir: Path, source_file: Path) -> None: + """Replace ``bin/python`` with a signed copy, gated on a real boot.""" + venv_py = venv_python_path(venv_dir) + venv_bin = venv_py.parent + venv_bin.mkdir(parents=True, exist_ok=True) + + _provision_libpython(venv_dir, source_file, refresh=True) + + fd, tmp_name = tempfile.mkstemp(prefix=".python-tcc-", dir=str(venv_bin)) + os.close(fd) + tmp_path = Path(tmp_name) + try: + shutil.copy2(source_file, tmp_path) + os.chmod(tmp_path, source_file.stat().st_mode | 0o111) + try: + from hermes_cli.managed_uv import _macos_sign_managed_python + + _macos_sign_managed_python(tmp_path) + except Exception: # pragma: no cover - never block the anchor + logger.debug("anchor copy signing skipped", exc_info=True) + if not _passes_boot_gate(tmp_path, venv_dir): + raise _BootGateFailed( + 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") + _materialize_aliases(venv_bin, venv_py, refresh=True) + except Exception: + try: + tmp_path.unlink(missing_ok=True) + except OSError: + pass + raise + + +def ensure_tcc_anchor(project_root: Path | None = None) -> Path | None: + """Pin a dylib-complete interpreter anchor for macOS TCC (#95596). + + No-op (returns None) on non-macOS, when no venv interpreter exists, or + when the interpreter is not uv-managed. Idempotent. Best-effort — + returns None (and logs) if the copy or boot-gate fails; callers must + never depend on success. + """ + if not is_macos(): + return None + venv_dir = _venv_dir(project_root) + if venv_dir is None: + return None + venv_py = venv_python_path(venv_dir) + if not (venv_py.is_file() or venv_py.is_symlink()): + return None + source = _interpreter_source(venv_dir) + if source is None or not _is_uv_macos_store(source): + return None + source_file = _interpreter_file(source) + if source_file is None: + return 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 + ): + _provision_libpython(venv_dir, source_file, refresh=False) + if _passes_boot_gate(venv_py, venv_dir): + _materialize_aliases(venv_py.parent, venv_py) + return venv_py + except OSError: + pass + try: + _install_anchor(venv_dir, source_file) + except _BootGateFailed as exc: + logger.warning("macOS TCC anchor boot-gate refused install: %s", exc) + return None + except Exception as exc: # best-effort: never break update/doctor + logger.warning("macOS TCC anchor install failed: %s", exc) + return None + return venv_py + + +def tcc_anchor_state(project_root: Path | None = None) -> tuple[str, str]: + """Report the anchor state for ``hermes doctor``. + + Returns ``(status, detail)`` with status one of: + + - ``"skip"`` — not applicable (non-macOS, no venv, or not uv-managed) + - ``"active"`` — venv interpreter is pinned at a stable real-file anchor + - ``"stale"`` — pinned but the interpreter changed since the last copy + - ``"missing"`` — uv-managed interpreter with no stable anchor installed + """ + if not is_macos(): + return "skip", "not macOS" + venv_dir = _venv_dir(project_root) + if venv_dir is None: + return "skip", "no venv interpreter" + venv_py = venv_python_path(venv_dir) + if not (venv_py.is_file() or venv_py.is_symlink()): + return "skip", "no venv interpreter" + source = _interpreter_source(venv_dir) + if source is None or not _is_uv_macos_store(source): + return "skip", "interpreter not uv-managed (stable path)" + 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 + try: + if marker.is_file() and marker.read_text(encoding="utf-8").strip() == expected: + return "active", str(venv_py) + except OSError: + pass + return "stale", str(venv_py) + return "missing", str(venv_py) diff --git a/hermes_cli/update_cmd.py b/hermes_cli/update_cmd.py index dba0f3af59..46d4ea449a 100644 --- a/hermes_cli/update_cmd.py +++ b/hermes_cli/update_cmd.py @@ -8467,13 +8467,14 @@ def _cmd_update_impl(args, gateway_mode: bool): "fully quit & relaunch once." ) - # NOTE: the macOS TCC interpreter anchor that used to refresh here - # (#95131/#95478) is REVERTED: the anchored real-file copy could not - # load libpython (LC_RPATH resolved into venv/lib/), bricking every - # hermes command on real Macs (#95425), and re-pointed aliases lost - # the stdlib (#95541). `hermes doctor` now heals already-anchored - # venvs back to symlinks. Re-land requires a dylib-complete design - # verified on macOS hardware first. + # macOS TCC interpreter anchor (#95596): dylib-complete re-land. + # Boot-gated — a failed probe leaves the venv untouched. + try: + from hermes_cli.macos_tcc_anchor import ensure_tcc_anchor + + ensure_tcc_anchor() + except Exception: + logger.debug("macOS TCC anchor refresh skipped", exc_info=True) # ── Post-update state.db integrity guard (#68474) ───────────────── # Verify that state.db survived the update intact. If the live file diff --git a/tests/hermes_cli/test_macos_tcc_anchor.py b/tests/hermes_cli/test_macos_tcc_anchor.py new file mode 100644 index 0000000000..54b783f29e --- /dev/null +++ b/tests/hermes_cli/test_macos_tcc_anchor.py @@ -0,0 +1,452 @@ +"""Tests for the dylib-complete macOS TCC anchor (issue #95596). + +Re-land of the interpreter anchor reverted in #95563. The first landing +bricked real Macs two ways: dynamically-linked builds died in dyld because +``@executable_path/../lib/libpython`` resolved into ``venv/lib/`` (#95425), +and alias symlinks to the copied interpreter lost the venv prefix (#95541). + +Linux tests use fake checkout/uv-store layouts with ``platform.system`` +monkeypatched. The real-interpreter E2E is ``macos_only`` so it runs on +the existing macOS CI job, not against one-byte fixtures. +""" + +from __future__ import annotations + +import os +import platform +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +import hermes_cli.doctor as doctor +import hermes_cli.macos_tcc_anchor as tcc +from hermes_constants import venv_python_path + + +def _darwin(monkeypatch): + monkeypatch.setattr(tcc.platform, "system", lambda: "Darwin") + + +def _linux(monkeypatch): + monkeypatch.setattr(tcc.platform, "system", lambda: "Linux") + + +def _build_store(tmp_path, version: str = "3.11.15", *, with_libpython: bool = False) -> Path: + store = ( + tmp_path + / "uv-store" + / "uv" + / "python" + / f"cpython-{version}-macos-aarch64-none" + ) + store_bin = store / "bin" + store_bin.mkdir(parents=True) + store_py = store_bin / "python3.11" + store_py.write_bytes(f"#!fake interpreter {version}".encode()) + store_py.chmod(0o755) + if with_libpython: + lib = store / "lib" + lib.mkdir(parents=True) + (lib / "libpython3.11.dylib").write_bytes(b"fake dylib") + return store_bin + + +def _build_checkout( + tmp_path, + *, + store_bin: Path | None = None, + version: str = "3.11.15", + anchored: bool = False, + homebrew: bool = False, + with_libpython: bool = False, +) -> Path: + root = tmp_path / "checkout" + venv = root / ".venv" + venv_bin = venv / "bin" + venv_bin.mkdir(parents=True) + if homebrew: + brew = tmp_path / "opt" / "homebrew" / "bin" + brew.mkdir(parents=True) + brew_py = brew / "python3.14" + brew_py.write_bytes(b"#!homebrew") + brew_py.chmod(0o755) + (venv / "pyvenv.cfg").write_text(f"home = {brew}\n") + os.symlink(brew_py, venv_bin / "python") + os.symlink(brew_py, venv_bin / "python3") + return root + if store_bin is None: + store_bin = _build_store(tmp_path, version, with_libpython=with_libpython) + (venv / "pyvenv.cfg").write_text(f"home = {store_bin}\n") + store_py = store_bin / "python3.11" + if anchored: + venv_py = venv_bin / "python" + venv_py.write_bytes(store_py.read_bytes()) + venv_py.chmod(0o755) + (venv_bin / ".tcc-anchor-source").write_text(str(store_py), encoding="utf-8") + os.symlink(venv_py, venv_bin / "python3") + else: + os.symlink(store_py, venv_bin / "python") + os.symlink(store_py, venv_bin / "python3") + return root + + +class TestUvStoreDetection: + def test_matches_uv_macos_store_path(self): + path = ( + "/Users/u/.local/share/uv/python/" + "cpython-3.11.15-macos-aarch64-none/bin/python3.11" + ) + assert tcc._is_uv_macos_store(path) + + def test_matches_hermes_runtime_repair_generation(self): + path = ( + "/Users/u/hermes-agent/.hermes-runtime/python/" + "generation-a1b2c3/cpython-3.11.15-macos-aarch64-none/bin/python3.11" + ) + assert tcc._is_uv_macos_store(path) + + def test_rejects_homebrew_interpreter(self): + path = ( + "/opt/homebrew/Cellar/python@3.14/3.14.6/Frameworks/" + "Python.framework/Versions/3.14/bin/python3.14" + ) + assert not tcc._is_uv_macos_store(path) + + def test_rejects_linux_interpreter(self): + assert not tcc._is_uv_macos_store("/usr/bin/python3") + + def test_rejects_uv_store_on_linux(self): + path = ( + "/home/u/.local/share/uv/python/" + "cpython-3.11.15-x86_64-unknown-linux-gnu/bin/python3.11" + ) + assert not tcc._is_uv_macos_store(path) + + +class TestEnsureTccAnchor: + def test_noop_on_non_macos(self, tmp_path, monkeypatch): + _linux(monkeypatch) + root = _build_checkout(tmp_path, store_bin=_build_store(tmp_path)) + venv_py = venv_python_path(root / ".venv") + + assert tcc.ensure_tcc_anchor(root) is None + assert venv_py.is_symlink() + + def test_install_signs_the_anchor_copy(self, tmp_path, monkeypatch): + _darwin(monkeypatch) + signed = [] + import hermes_cli.managed_uv as managed_uv + + monkeypatch.setattr( + managed_uv, "_macos_sign_managed_python", lambda p: signed.append(Path(p)) or True + ) + store_bin = _build_store(tmp_path) + root = _build_checkout(tmp_path, store_bin=store_bin) + + anchored = tcc.ensure_tcc_anchor(root) + + assert anchored is not None + assert len(signed) == 1 + assert signed[0].parent == anchored.parent + + def test_anchors_repair_generation_interpreter(self, tmp_path, monkeypatch): + _darwin(monkeypatch) + store = ( + tmp_path + / "checkout" + / ".hermes-runtime" + / "python" + / "generation-a1b2c3" + / "cpython-3.11.15-macos-aarch64-none" + ) + store_bin = store / "bin" + store_bin.mkdir(parents=True) + store_py = store_bin / "python3.11" + store_py.write_bytes(b"#!fake generation interpreter") + store_py.chmod(0o755) + root = _build_checkout(tmp_path, store_bin=store_bin) + venv_py = venv_python_path(root / ".venv") + assert venv_py.is_symlink() + + anchored = tcc.ensure_tcc_anchor(root) + + assert anchored == venv_py + assert not venv_py.is_symlink() + assert venv_py.read_bytes() == store_py.read_bytes() + + def test_anchors_uv_managed_interpreter(self, tmp_path, monkeypatch): + _darwin(monkeypatch) + store_bin = _build_store(tmp_path) + root = _build_checkout(tmp_path, store_bin=store_bin) + venv_py = venv_python_path(root / ".venv") + assert venv_py.is_symlink() + + anchored = tcc.ensure_tcc_anchor(root) + + assert anchored == venv_py + assert venv_py.is_file() and not venv_py.is_symlink() + assert venv_py.read_bytes() == (store_bin / "python3.11").read_bytes() + assert os.access(venv_py, os.X_OK) + marker = venv_py.parent / ".tcc-anchor-source" + assert marker.read_text(encoding="utf-8").strip() == str( + store_bin / "python3.11" + ) + alias = venv_py.parent / "python3" + assert alias.is_file() and not alias.is_symlink() + assert alias.read_bytes() == venv_py.read_bytes() + + def test_idempotent(self, tmp_path, monkeypatch): + _darwin(monkeypatch) + store_bin = _build_store(tmp_path) + root = _build_checkout(tmp_path, store_bin=store_bin, anchored=True) + venv_py = venv_python_path(root / ".venv") + marker = venv_py.parent / ".tcc-anchor-source" + before = marker.read_text(encoding="utf-8") + + anchored = tcc.ensure_tcc_anchor(root) + + assert anchored == venv_py + assert venv_py.is_file() and not venv_py.is_symlink() + assert marker.read_text(encoding="utf-8") == before + + def test_repairs_alias_symlinks_left_by_predecessor(self, tmp_path, monkeypatch): + _darwin(monkeypatch) + store_bin = _build_store(tmp_path) + root = _build_checkout(tmp_path, store_bin=store_bin, anchored=True) + venv_bin = root / ".venv" / "bin" + venv_py = venv_bin / "python" + assert (venv_bin / "python3").is_symlink() + + anchored = tcc.ensure_tcc_anchor(root) + + assert anchored == venv_py + alias = venv_bin / "python3" + assert alias.is_file() and not alias.is_symlink() + assert alias.read_bytes() == venv_py.read_bytes() + + def test_reanchors_after_patch_bump(self, tmp_path, monkeypatch): + _darwin(monkeypatch) + old_bin = _build_store(tmp_path, version="3.11.15") + root = _build_checkout(tmp_path, store_bin=old_bin, anchored=True) + venv_py = venv_python_path(root / ".venv") + + new_bin = _build_store(tmp_path, version="3.11.16") + new_py = new_bin / "python3.11" + venv_py.unlink() + os.symlink(new_py, venv_py) + (root / ".venv" / "pyvenv.cfg").write_text(f"home = {new_bin}\n") + + anchored = tcc.ensure_tcc_anchor(root) + + assert anchored == venv_py + assert not venv_py.is_symlink() + assert venv_py.read_bytes() == new_py.read_bytes() + marker = venv_py.parent / ".tcc-anchor-source" + assert marker.read_text(encoding="utf-8").strip() == str(new_py) + alias = venv_py.parent / "python3" + assert alias.is_file() and not alias.is_symlink() + assert alias.read_bytes() == new_py.read_bytes() + + def test_skips_homebrew_interpreter(self, tmp_path, monkeypatch): + _darwin(monkeypatch) + root = _build_checkout(tmp_path, homebrew=True) + venv_py = venv_python_path(root / ".venv") + + assert tcc.ensure_tcc_anchor(root) is None + assert venv_py.is_symlink() + + def test_no_venv_returns_none(self, tmp_path, monkeypatch): + _darwin(monkeypatch) + assert tcc.ensure_tcc_anchor(tmp_path / "missing") is None + + def test_preserves_stdlib_source_home(self, tmp_path, monkeypatch): + _darwin(monkeypatch) + store_bin = _build_store(tmp_path) + root = _build_checkout(tmp_path, store_bin=store_bin) + cfg = root / ".venv" / "pyvenv.cfg" + + tcc.ensure_tcc_anchor(root) + + assert f"home = {store_bin}" in cfg.read_text(encoding="utf-8") + + def test_provisions_libpython_as_hardlink_when_present(self, tmp_path, monkeypatch): + _darwin(monkeypatch) + store_bin = _build_store(tmp_path, with_libpython=True) + root = _build_checkout(tmp_path, store_bin=store_bin) + src_dylib = store_bin.parent / "lib" / "libpython3.11.dylib" + + tcc.ensure_tcc_anchor(root) + + dst = root / ".venv" / "lib" / "libpython3.11.dylib" + assert dst.is_file() + assert dst.read_bytes() == src_dylib.read_bytes() + assert dst.stat().st_ino == src_dylib.stat().st_ino + + def test_boot_gate_refusal_leaves_venv_untouched(self, tmp_path, monkeypatch): + _darwin(monkeypatch) + store_bin = _build_store(tmp_path) + root = _build_checkout(tmp_path, store_bin=store_bin) + venv_py = venv_python_path(root / ".venv") + monkeypatch.setattr(tcc, "_passes_boot_gate", lambda *a, **k: False) + + assert tcc.ensure_tcc_anchor(root) is None + assert venv_py.is_symlink() + assert not (venv_py.parent / ".tcc-anchor-source").exists() + + +class TestTccAnchorState: + def test_state_missing_then_active(self, tmp_path, monkeypatch): + _darwin(monkeypatch) + store_bin = _build_store(tmp_path) + root = _build_checkout(tmp_path, store_bin=store_bin) + + status, detail = tcc.tcc_anchor_state(root) + assert status == "missing" + assert str(venv_python_path(root / ".venv")) in detail + + tcc.ensure_tcc_anchor(root) + + status, detail = tcc.tcc_anchor_state(root) + assert status == "active" + + def test_state_skip_on_linux(self, tmp_path, monkeypatch): + _linux(monkeypatch) + store_bin = _build_store(tmp_path) + root = _build_checkout(tmp_path, store_bin=store_bin) + status, detail = tcc.tcc_anchor_state(root) + assert status == "skip" + assert detail == "not macOS" + + def test_state_skip_for_homebrew(self, tmp_path, monkeypatch): + _darwin(monkeypatch) + root = _build_checkout(tmp_path, homebrew=True) + status, detail = tcc.tcc_anchor_state(root) + assert status == "skip" + assert "not uv-managed" in detail + + def test_state_stale_after_patch_bump(self, tmp_path, monkeypatch): + _darwin(monkeypatch) + old_bin = _build_store(tmp_path, version="3.11.15") + root = _build_checkout(tmp_path, store_bin=old_bin, anchored=True) + new_bin = _build_store(tmp_path, version="3.11.16") + (root / ".venv" / "pyvenv.cfg").write_text(f"home = {new_bin}\n") + status, _ = tcc.tcc_anchor_state(root) + assert status == "stale" + anchored = tcc.ensure_tcc_anchor(root) + assert anchored == venv_python_path(root / ".venv") + assert (root / ".venv" / "bin" / "python").read_bytes() == ( + new_bin / "python3.11" + ).read_bytes() + status, _ = tcc.tcc_anchor_state(root) + assert status == "active" + + +class TestDoctorCheck: + def test_missing_warns_without_fix(self, monkeypatch, capsys): + monkeypatch.setattr( + tcc, "tcc_anchor_state", lambda *a, **k: ("missing", "/x/.venv/bin/python") + ) + doctor.check_macos_tcc_anchor(should_fix=False) + out = capsys.readouterr().out + assert "macOS TCC anchor missing" in out + + def test_fix_installs_anchor(self, monkeypatch, capsys): + monkeypatch.setattr( + tcc, "tcc_anchor_state", lambda *a, **k: ("missing", "/x/.venv/bin/python") + ) + monkeypatch.setattr( + tcc, "ensure_tcc_anchor", lambda *a, **k: Path("/x/.venv/bin/python") + ) + doctor.check_macos_tcc_anchor(should_fix=True) + out = capsys.readouterr().out + assert "macOS TCC anchor installed" in out + + def test_active_reports_ok(self, monkeypatch, capsys): + monkeypatch.setattr( + tcc, "tcc_anchor_state", lambda *a, **k: ("active", "/x/.venv/bin/python") + ) + doctor.check_macos_tcc_anchor(should_fix=False) + out = capsys.readouterr().out + assert "macOS TCC anchor active" in out + + def test_skip_is_silent_on_non_macos(self, monkeypatch, capsys): + monkeypatch.setattr( + tcc, "tcc_anchor_state", lambda *a, **k: ("skip", "not macOS") + ) + doctor.check_macos_tcc_anchor(should_fix=False) + assert capsys.readouterr().out == "" + + def test_never_crashes_on_exception(self, monkeypatch, capsys): + def boom(*a, **k): + raise RuntimeError("tccd down") + + monkeypatch.setattr(tcc, "tcc_anchor_state", boom) + doctor.check_macos_tcc_anchor(should_fix=False) + out = capsys.readouterr().out + assert "macOS TCC anchor check failed" in out + + +@pytest.mark.macos_only +class TestAnchoredAliasesBootE2E: + """Real-interpreter proof that the re-land stays bootable (#95596). + + Copies the running interpreter's real base binary into a fake uv-store + layout (stdlib via a ``lib`` symlink) and actually executes every + entry point after anchoring. ``macos_only`` so Linux CI cannot + greenwash this with a fixture. + """ + + def test_python_entry_points_boot_after_anchor(self, tmp_path): + minor = f"python3.{sys.version_info.minor}" + base = Path(sys.base_prefix) + real_py = base / "bin" / minor + if not real_py.is_file() or real_py.is_symlink(): + resolved = real_py.resolve() if real_py.exists() else None + if resolved is None or not resolved.is_file(): + pytest.skip(f"no real base interpreter binary at {real_py}") + real_py = resolved + if not (base / "lib" / minor / "os.py").is_file(): + pytest.skip("base stdlib not in the expected lib layout") + + store = ( + tmp_path + / "uv" + / "python" + / f"cpython-{platform.python_version()}-macos-aarch64-none" + ) + store_bin = store / "bin" + store_bin.mkdir(parents=True) + shutil.copy2(real_py, store_bin / minor) + os.symlink(base / "lib", store / "lib") + + root = tmp_path / "checkout" + venv = root / ".venv" + venv_bin = venv / "bin" + venv_bin.mkdir(parents=True) + (venv / "lib" / minor / "site-packages").mkdir(parents=True) + (venv / "pyvenv.cfg").write_text( + f"home = {store_bin}\nversion = {platform.python_version()}\n", + encoding="utf-8", + ) + os.symlink(store_bin / minor, venv_bin / "python") + os.symlink("python", venv_bin / "python3") + os.symlink("python", venv_bin / minor) + + anchored = tcc.ensure_tcc_anchor(root) + assert anchored is not None + + for name in ("python", "python3", minor): + probe = subprocess.run( + [str(venv_bin / name), "-c", + "import encodings, sys; print(sys.prefix)"], + capture_output=True, + text=True, + timeout=120, + ) + assert probe.returncode == 0, ( + f"{name} failed to boot after anchoring:\n{probe.stderr}" + ) + assert str(venv) in probe.stdout diff --git a/tests/hermes_cli/test_tcc_anchor_revert.py b/tests/hermes_cli/test_tcc_anchor_revert.py deleted file mode 100644 index 8218a28526..0000000000 --- a/tests/hermes_cli/test_tcc_anchor_revert.py +++ /dev/null @@ -1,78 +0,0 @@ -"""Tests for the TCC-anchor revert heal (#95425 / #95541). - -The interpreter anchor replaced venv/bin/python with a real-file copy that -could not load libpython on real Macs, bricking the CLI. The anchor is -reverted; doctor's check_macos_tcc_anchor_removed() restores anchored venvs -to symlinks using the marker the anchor left behind. -""" - -import contextlib -import io -import os -from pathlib import Path - -import hermes_cli.doctor as doctor_mod - - -def _capture(fn): - buf = io.StringIO() - with contextlib.redirect_stdout(buf): - fn() - return buf.getvalue() - - -def _build_anchored_checkout(tmp_path): - """A checkout whose venv the anchor converted: real-file python + marker.""" - root = tmp_path / "checkout" - store_bin = tmp_path / "store" / "cpython-3.12.1-macos" / "bin" - store_bin.mkdir(parents=True) - source = store_bin / "python3.12" - source.write_bytes(b"#!store interpreter") - source.chmod(0o755) - venv_bin = root / "venv" / "bin" - venv_bin.mkdir(parents=True) - venv_py = venv_bin / "python" - venv_py.write_bytes(b"#!anchored copy (broken on real macs)") - venv_py.chmod(0o755) - (venv_bin / ".tcc-anchor-source").write_text(str(source), encoding="utf-8") - os.symlink(venv_py, venv_bin / "python3") - return root, source, venv_py - - -def test_silent_on_non_macos(monkeypatch, tmp_path): - monkeypatch.setattr(doctor_mod.sys, "platform", "linux") - assert _capture(doctor_mod.check_macos_tcc_anchor_removed) == "" - - -def test_silent_when_never_anchored(monkeypatch, tmp_path): - monkeypatch.setattr(doctor_mod.sys, "platform", "darwin") - root = tmp_path / "checkout" - (root / "venv" / "bin").mkdir(parents=True) - monkeypatch.setattr( - doctor_mod, "__file__", str(root / "hermes_cli" / "doctor.py") - ) - - out = _capture(doctor_mod.check_macos_tcc_anchor_removed) - - assert out == "" - - -def test_heals_anchored_venv(monkeypatch, tmp_path): - monkeypatch.setattr(doctor_mod.sys, "platform", "darwin") - root, source, venv_py = _build_anchored_checkout(tmp_path) - - # Point the check's root resolution at the fixture checkout. - monkeypatch.setattr( - doctor_mod, "__file__", str(root / "hermes_cli" / "doctor.py") - ) - - out = _capture(doctor_mod.check_macos_tcc_anchor_removed) - - assert "TCC anchor removed" in out - assert venv_py.is_symlink() - assert Path(os.readlink(venv_py)) == source - assert not (venv_py.parent / ".tcc-anchor-source").exists() - # Aliases restored to point at bin/python. - alias = venv_py.parent / "python3" - assert alias.is_symlink() - assert os.readlink(alias) == "python"