From 2f9e18700159ba5df1ec8a69e8e5a2e7ceb368e9 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 26 Aug 2026 06:36:18 -0700 Subject: [PATCH] =?UTF-8?q?revert(macos):=20remove=20the=20TCC=20interpret?= =?UTF-8?q?er=20anchor=20=E2=80=94=20anchored=20copies=20could=20not=20loa?= =?UTF-8?q?d=20libpython?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts the interpreter-anchor halves of #95131 and #95478 (the anchor module, its doctor check, and the update-time refresh). On real Macs the anchored real-file copy of the uv interpreter dies in dyld: its LC_RPATH (@executable_path/../lib) resolves into venv/lib/, which holds no libpython — bricking EVERY hermes command including update and doctor (#95425), and the re-pointed python3 aliases lost the stdlib (ModuleNotFoundError: encodings, #95541). Linux CI could not catch this: the fixture interpreters were one-byte fakes with no dynamic linking. Kept: managed_uv._macos_sign_managed_python (#82529, @notkisk) — the identifier-DR signing of repair generations is independent of the anchor and unaffected by the dyld issue (it signs binaries IN PLACE in their store, where their rpath is valid). Added: doctor's check_macos_tcc_anchor_removed() heals venvs the anchor already converted — restores bin/python to a symlink at the recorded source (the anchor's own marker file) and re-points aliases; prints the manual one-liner if the heal itself fails. Users whose CLI is fully bricked can run the workaround from #95425 directly. Re-land criteria: a dylib-complete anchor design (bundle libpython or rewrite LC_RPATH), verified on macOS hardware BEFORE merge. Credit to @kim-miram (#95358), @kokhlo (#95476), @zengzheqing (#95551) for the forward-fix diagnoses that mapped the failure, and to the #95425/#95541 reporters. --- hermes_cli/doctor.py | 89 +++--- hermes_cli/macos_tcc_anchor.py | 337 ------------------- hermes_cli/update_cmd.py | 21 +- tests/hermes_cli/test_macos_tcc_anchor.py | 355 --------------------- tests/hermes_cli/test_tcc_anchor_revert.py | 78 +++++ 5 files changed, 131 insertions(+), 749 deletions(-) delete mode 100644 hermes_cli/macos_tcc_anchor.py delete mode 100644 tests/hermes_cli/test_macos_tcc_anchor.py create mode 100644 tests/hermes_cli/test_tcc_anchor_revert.py diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index 2e45db5a6c..ba831dd378 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -1154,52 +1154,54 @@ def _macos_desktop_dr(app: Path) -> str | None: return (proc.stdout or "") + (proc.stderr or "") -def check_macos_tcc_anchor(should_fix: bool = False) -> None: - """macOS TCC anchor check (issue #85345). +def check_macos_tcc_anchor_removed() -> None: + """Detect and repair a venv bricked by the reverted TCC anchor. - TCC keys permission grants to the interpreter's resolved path; uv-managed - interpreters move on every patch bump, orphaning grants and re-triggering - the permission-prompt storm after each update. A stable real-file copy of - the interpreter inside the venv keeps the TCC client path constant. - Silent on non-macOS; informational when the interpreter already has a - stable path. + 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. """ - try: - from hermes_cli.macos_tcc_anchor import ensure_tcc_anchor, tcc_anchor_state - - status, detail = tcc_anchor_state() - if status == "skip": - if detail == "interpreter not uv-managed (stable path)": - check_ok("Python interpreter path is stable", "(not uv-managed)") - return - if status == "active": + 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 active", - f"(interpreter pinned at {detail}; grants survive updates)", + "macOS TCC anchor removed", + f"({name}/bin/python restored to a symlink; the anchor " + "(#95425/#95541) is reverted)", ) - return - label = "stale" if status == "stale" else "missing" - if should_fix: - anchored = ensure_tcc_anchor() - if anchored is not None: - check_ok( - "macOS TCC anchor installed", - f"(interpreter pinned at {anchored}; grants survive updates)", - ) - return + except Exception as e: # diagnostics must never crash check_warn( - "macOS TCC anchor could not be installed", - "macOS will re-prompt for permissions after each Python update", + "macOS TCC anchor cleanup failed", + f"({e}) — restore manually: ln -sf $(cat {marker}) {venv_bin / 'python'}", ) - return - check_warn( - f"macOS TCC anchor {label}", - "the uv-managed interpreter path changes on every Python patch " - "bump, so macOS will re-prompt for permissions after each update. " - "Run `hermes doctor --fix` to pin the interpreter at a stable path.", - ) - except Exception as e: # diagnostics must never crash - check_warn(f"macOS TCC anchor check failed: {e}") def check_macos_full_disk_access() -> None: @@ -1421,9 +1423,10 @@ def run_doctor(args): else: check_warn("Not in virtual environment", "(recommended)") - # macOS TCC anchor (issue #85345): uv-managed interpreter paths move on - # every patch bump and orphan TCC grants. Silent on non-macOS. - check_macos_tcc_anchor(should_fix) + # 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 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 deleted file mode 100644 index 29ce9fac6b..0000000000 --- a/hermes_cli/macos_tcc_anchor.py +++ /dev/null @@ -1,337 +0,0 @@ -"""Stable macOS TCC anchor for the uv-managed Python interpreter (issue #85345). - -macOS keys TCC grants (Files & Folders, Photos, Media Library, Automation, -...) to the *resolved absolute path* of the client binary. Hermes' interpreter -is managed by uv and lives at ``~/.local/share/uv/python/cpython--macos-*/ -bin/python*``; every patch bump materializes a NEW versioned directory, so the -TCC client string changes and every prior grant is orphaned — macOS re-prompts -for all permissions after each update. - -Symlinks do not help: TCC resolves through them to the versioned store path -before matching (the venv's ``bin/python`` -> store symlink is exactly why the -client is reported as ``.../cpython-3.11.15-macos-.../bin/python3.11``). - -The anchor: replace the venv's ``bin/python`` symlink with a *real-file copy* -of the interpreter binary. The venv path (``/venv/bin/python``) is -stable across ``hermes update``, and because it is a regular file there is no -symlink for TCC to resolve — so the TCC client path stays constant across -interpreter patch bumps. ``pyvenv.cfg`` keeps pointing at the uv store (``home``), -which still provides the stdlib exactly as it does today. - -The anchor self-heals: when ``hermes update`` / ``hermes doctor`` runs and the -venv python is a symlink again (uv re-created it) or the recorded source no -longer matches the current interpreter (patch bump), the copy is refreshed. -Versioned alias symlinks (``python3``, ``python3.11``, ...) inside the venv bin -dir are re-pointed at the anchor so no alias resolves back into the versioned -store. - -All functions are no-ops on non-macOS and for interpreters that are not -uv-managed (Homebrew/system Python has a stable path already). This module is -pure/best-effort: it never raises to callers (update/doctor must never break -because of it). -""" - -from __future__ import annotations - -import logging -import os -import platform -import shutil -import tempfile -from pathlib import Path - -from hermes_constants import venv_python_path - -logger = logging.getLogger(__name__) - -# Marker file (inside the venv bin dir) recording the uv-store interpreter -# file the anchor copy was taken from. Used to detect patch-bump staleness. -_MARKER_NAME = ".tcc-anchor-source" - - -def _sibling_names() -> tuple[str, ...]: - """Alias symlinks uv creates inside the venv bin dir. - - Derived from the RUNNING interpreter's version rather than a hardcoded - minor-version list, so a future Python bump can't silently leave an alias - resolving back into the versioned store. - """ - 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. - - Versioned name first (from the running interpreter) so the real binary is - picked over the ``python3`` alias; generic fallbacks after. - """ - import sys as _sys - - return (f"python3.{_sys.version_info.minor}", "python3", "python") - - -# Path fragments that identify a MANAGED macOS CPython store layout — a -# store whose path changes across updates, orphaning path-keyed TCC grants. -# Two roots qualify: -# - uv store patch bumps: .../uv/python/cpython--macos-*/bin/... -# - CVE-repair generations: .../.hermes-runtime/python/generation-*/ -# cpython--macos-*/bin/... -# (repair_vulnerable_runtime() rebuilds the venv against a generation store, -# replacing the anchored bin/python with a fresh symlink — without the second -# root the anchor would read 'not uv-managed' after every SQLite CVE repair -# and never re-anchor, issue #82427.) -_STORE_COMMON_MARKERS = ("cpython-", "-macos-") -_STORE_ROOT_MARKERS = ("/uv/python/", "/.hermes-runtime/python/") - - -def is_macos() -> bool: - """True on macOS (the only platform with TCC).""" - return platform.system() == "Darwin" - - -def _is_uv_macos_store(path: str | Path) -> bool: - """True when *path* lives inside a managed macOS CPython store.""" - text = str(path).replace("\\", "/") - if not all(marker in text for marker in _STORE_COMMON_MARKERS): - return False - return any(root in text for root in _STORE_ROOT_MARKERS) - - -def _venv_dir(project_root: Path | None = None) -> Path | None: - """Return the checkout's venv dir, mirroring ``managed_uv``'s probing. - - ``venv`` wins when it holds an interpreter (managed layout takes - precedence); otherwise fall back to ``.venv`` (uv-default/dev checkouts). - Returns None when neither holds an interpreter. - """ - 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*. - - *src* is either a resolved store binary path (symlinked venv layout) or a - store ``bin`` dir read from ``pyvenv.cfg`` ``home`` (anchored layout). - """ - 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 - try: - if candidate.is_file(): - return candidate - except OSError: - continue - # Any other versioned binary on disk (store built by a different Python - # minor than the one running this code — e.g. after a major bump, or in - # fixtures). Sorted for determinism; versioned names only, so the - # ``python3`` alias never shadows the real binary here. - try: - for candidate in sorted(p.glob("python3.*")): - if candidate.is_file() and not candidate.name.endswith((".dSYM", ".txt")): - return candidate - except OSError: - pass - return None - - -def _interpreter_source(venv_dir: Path) -> str | None: - """Return the interpreter file the venv currently resolves to. - - A symlinked ``bin/python`` (uv's layout) resolves to the versioned store - binary. A regular-file anchor instead reads ``pyvenv.cfg`` ``home`` (the - base interpreter's bin dir) — that is what the anchor copy was taken from - and where the stdlib still comes from. - """ - venv_py = venv_python_path(venv_dir) - if venv_py.is_symlink(): - try: - resolved = venv_py.resolve(strict=False) - except OSError: - return None - if resolved.is_file(): - return str(resolved) - return None - cfg = venv_dir / "pyvenv.cfg" - try: - text = cfg.read_text(encoding="utf-8", errors="replace") - except OSError: - return None - for line in text.splitlines(): - if line.strip().lower().startswith("home"): - _, _, value = line.partition("=") - home = value.strip() - if home: - return str(_interpreter_file(Path(home))) - return None - - -def _anchor_marker(venv_bin: Path) -> Path: - return venv_bin / _MARKER_NAME - - -def _repoint_aliases(venv_bin: Path, anchor: Path) -> None: - """Re-point uv alias symlinks at the stable anchor. - - ``python3`` / ``python3.11`` inside the venv bin dir currently resolve into - the versioned store; anything spawned through them would still churn TCC. - Only symlinks that resolve into the uv store are touched. - """ - # Union of the running interpreter's expected aliases and every versioned - # alias actually on disk — a store built by a different Python minor than - # the one running this code must still get its aliases repointed. - names = set(_sibling_names()) - try: - names.update(p.name for p in venv_bin.glob("python3.*") if p.is_symlink()) - except OSError: - pass - for name in sorted(names): - alias = venv_bin / name - try: - if not alias.is_symlink(): - continue - if not _is_uv_macos_store(str(alias.resolve(strict=False))): - continue - tmp = venv_bin / f".{name}.tcc-tmp" - try: - os.symlink(anchor.name, tmp) - os.replace(tmp, alias) - except OSError: - try: - tmp.unlink(missing_ok=True) - except OSError: - pass - except OSError: - continue - - -def _install_anchor(venv_dir: Path, source_file: Path) -> None: - """Replace ``bin/python`` with a real-file copy of *source_file*. - - Atomic (temp file + rename) so a crash mid-copy cannot leave the venv - interpreter half-written. Writes the source marker and re-points alias - symlinks so the whole venv bin dir resolves to stable paths. - """ - venv_py = venv_python_path(venv_dir) - venv_bin = venv_py.parent - venv_bin.mkdir(parents=True, exist_ok=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) - # Give the anchor copy a stable identifier-pinned signature BEFORE it - # goes live. copy2 carries over the source build's signature, whose - # designated requirement is cdhash-based for ad-hoc/linker-signed - # python-build-standalone binaries — meaning every anchor REFRESH - # (patch bump, CVE repair) would still change the stored csreq and - # orphan the grant despite the stable path. Identifier-DR signing - # (same mechanism as managed_uv's generation signing, #82427) keeps - # the csreq constant across refreshes. Best-effort: a failed sign - # leaves the copy usable, just without refresh-stable signing. - 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) - os.replace(tmp_path, venv_py) - _anchor_marker(venv_bin).write_text(str(source_file), encoding="utf-8") - _repoint_aliases(venv_bin, venv_py) - 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 stable interpreter anchor for macOS TCC (issue #85345). - - No-op (returns None) on non-macOS, when no venv interpreter exists, or when - the interpreter is not uv-managed. Otherwise makes the venv's ``bin/python`` - a real-file copy of the current uv-store interpreter and returns its path. - Idempotent: a fresh anchor is returned unchanged. Best-effort — returns - None (and logs) if the copy 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(): - # Already anchored — refresh only when the interpreter changed. - marker = _anchor_marker(venv_py.parent) - try: - if marker.is_file() and marker.read_text(encoding="utf-8").strip() == str( - source_file - ): - return venv_py - except OSError: - pass - try: - _install_anchor(venv_dir, source_file) - 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) - try: - if marker.is_file() and marker.read_text(encoding="utf-8").strip() == str( - source - ): - 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 87df675839..f9e339aa4b 100644 --- a/hermes_cli/update_cmd.py +++ b/hermes_cli/update_cmd.py @@ -7080,20 +7080,13 @@ def _cmd_update_impl(args, gateway_mode: bool): "fully quit & relaunch once." ) - # ── macOS TCC anchor (issue #85345) ──────────────────────────── - # uv-managed interpreters move on every patch bump, orphaning macOS - # TCC grants and re-triggering the permission-prompt storm. Pin a - # real-file copy of the interpreter inside the venv so the TCC client - # path stays stable across updates. Best-effort only; the doctor - # check re-applies it if this runs from pre-fix code. - try: - from hermes_cli.macos_tcc_anchor import ensure_tcc_anchor - - tcc_anchored = ensure_tcc_anchor(_m().PROJECT_ROOT) - if tcc_anchored is not None: - print(f" ✓ macOS TCC anchor: interpreter pinned at {tcc_anchored}") - except Exception as _tcc_exc: - logger.debug("macOS TCC anchor refresh failed: %s", _tcc_exc) + # 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. # ── 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 deleted file mode 100644 index 8f5d12a08c..0000000000 --- a/tests/hermes_cli/test_macos_tcc_anchor.py +++ /dev/null @@ -1,355 +0,0 @@ -"""Tests for the macOS TCC anchor (issue #85345). - -The anchor makes the TCC client path stable by replacing the venv's -``bin/python`` symlink (which resolves into uv's versioned store) with a -real-file copy of the interpreter. All tests run on Linux against fake -checkout/uv-store layouts; ``platform.system`` is monkeypatched to simulate -macOS. -""" - -import os -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 - -_STORE_ROOT = "cpython-3.11.15-macos-aarch64-none" - - -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") -> 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) - return store_bin - - -def _build_checkout( - tmp_path, - *, - store_bin: Path | None = None, - version: str = "3.11.15", - anchored: bool = False, - homebrew: 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) - (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): - # repair_vulnerable_runtime() rebuilds the venv against a generation - # store under .hermes-runtime/python/ — no /uv/python/ segment. The - # anchor must recognize it or every SQLite CVE repair silently - # un-anchors the interpreter (issue #82427 integration). - 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() # untouched - - def test_install_signs_the_anchor_copy(self, tmp_path, monkeypatch): - """The anchor copy gets identifier-DR signing before going live — - without it every refresh changes the stored csreq (cdhash-based for - ad-hoc source builds) and orphans the grant despite the stable path.""" - _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 - # Signing ran on the temp copy inside the venv bin dir (pre-rename). - assert len(signed) == 1 - assert signed[0].parent == anchored.parent - - def test_anchors_repair_generation_interpreter(self, tmp_path, monkeypatch): - """A venv re-created against a .hermes-runtime CVE-repair generation - store must anchor too (issue #82427 integration).""" - _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() # preconditions: uv layout - - anchored = tcc.ensure_tcc_anchor(root) - - assert anchored == venv_py - # The venv interpreter is now a real file, not a symlink into the - # versioned store — the TCC client path is stable. - 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 records the store binary the copy came from. - marker = venv_py.parent / ".tcc-anchor-source" - assert marker.read_text(encoding="utf-8").strip() == str( - store_bin / "python3.11" - ) - # Alias symlinks no longer resolve into the versioned store. - alias = venv_py.parent / "python3" - assert not tcc._is_uv_macos_store(str(alias.resolve(strict=False))) - - 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_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") - - # Simulate `uv sync` bumping 3.11.15 -> 3.11.16: uv re-links the venv - # interpreter to the new store and rewrites pyvenv.cfg home. - 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) - - 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() # untouched: stable identity already - - 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) - - # pyvenv.cfg still points stdlib at the uv store — the anchor only - # changes the executable identity, not where the stdlib loads from. - assert f"home = {store_bin}" in cfg.read_text(encoding="utf-8") - - -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) - # Simulate a patch bump where pyvenv.cfg now points at a new store - # while the venv still holds the previous anchor copy. - 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" - # ensure_tcc_anchor() refreshes the copy from the new interpreter. - 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) # must not raise - out = capsys.readouterr().out - assert "macOS TCC anchor check failed" in out diff --git a/tests/hermes_cli/test_tcc_anchor_revert.py b/tests/hermes_cli/test_tcc_anchor_revert.py new file mode 100644 index 0000000000..8218a28526 --- /dev/null +++ b/tests/hermes_cli/test_tcc_anchor_revert.py @@ -0,0 +1,78 @@ +"""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"