revert(macos): remove the TCC interpreter anchor — anchored copies could not load libpython
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.
This commit is contained in:
+46
-43
@@ -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.
|
||||
|
||||
@@ -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-<ver>-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 (``<checkout>/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-<ver>-macos-*/bin/...
|
||||
# - CVE-repair generations: .../.hermes-runtime/python/generation-*/
|
||||
# cpython-<ver>-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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user