diff --git a/hermes_bootstrap.py b/hermes_bootstrap.py index ecd2b0402e..e5e931121b 100644 --- a/hermes_bootstrap.py +++ b/hermes_bootstrap.py @@ -106,12 +106,18 @@ suppress_platform_ver_console() # Every entry point imports this module before its dependency graph. from pathlib import Path from hermes_cli.runtime_paths import activate_dependencies +from hermes_cli._early_recovery import recover_if_needed -try: - activate_dependencies(Path(__file__).resolve().parent) -except RuntimeError as exc: - # The repair command must remain usable even when the committed tree - # disappeared. Other commands must not silently run the wrong libraries. - if sys.argv[1:3] not in (["pm", "install"], ["pm", "doctor"]): - print(f"hermes: {exc}; run `hermes pm install` to repair", file=sys.stderr) - raise SystemExit(1) from None +from hermes_cli._parser import command_argv + +_root = Path(__file__).resolve().parent +# Repair needs only stdlib. Do not activate the damaged tree to reach it. +_pm_repair = command_argv(sys.argv[1:])[:2] == ["pm", "repair"] +if not _pm_repair: + recover_if_needed(_root) + try: + activate_dependencies(_root) + except (RuntimeError, OSError) as exc: + if command_argv(sys.argv[1:])[:1] != ["pm"]: + print(f"hermes: {exc}; run `hermes pm repair`", file=sys.stderr) + raise SystemExit(1) from None diff --git a/hermes_cli/_early_recovery.py b/hermes_cli/_early_recovery.py index e8bfb61c2d..c723863d7d 100644 --- a/hermes_cli/_early_recovery.py +++ b/hermes_cli/_early_recovery.py @@ -1,46 +1,14 @@ -"""Dependency-light venv recovery that runs BEFORE hermes_cli.main's imports. - -Deliberately **stdlib-only** so importing it can never fail on a corrupted venv. ``hermes_cli.main`` -calls :func:`recover_if_needed` at the very top of its module body, before any third-party import. -Scope: repair only enough for ``hermes_cli.main`` to become importable again (force-reinstall of -the known-fragile core packages, using the pins from pyproject.toml). -""" +"""Startup requests for PM recovery and rescue of orphaned launchers.""" from __future__ import annotations -import importlib import os -import shutil -import subprocess import sys import time from pathlib import Path -# Core packages a failed lazy ``uv pip install`` is known to leave with intact distribution -# metadata but wiped import files. ``module`` is probed via a real import; ``attr`` guards against -# an empty/stub module. main.py's marker-recovery path reuses these tables — keep them here so -# both layers probe and repair the same set. -# See #57828. -LAZY_REFRESH_IMPORT_PROBES: tuple[tuple[str, str], ...] = ( - ("yaml", "SafeDumper"), ("dotenv", "load_dotenv"), ("click", "Command"), - ("certifi", "contents"), ("rich", "print"), ("cryptography", "__version__"), - ("jwt", "encode"), -) - -LAZY_REFRESH_REPAIR_PACKAGES: dict[str, str] = { - "yaml": "PyYAML", "dotenv": "python-dotenv", "click": "click", "certifi": "certifi", - "rich": "rich", "cryptography": "cryptography", "jwt": "PyJWT", -} - -# ``hermes update`` renames the live ``hermes*.exe`` shims aside (``hermes.exe.old.``) so -# uv can write replacements. Putting them BACK is the safety-critical direction: losing that rename -# leaves no ``hermes`` on PATH, and the command that would repair it IS ``hermes update``. The -# updater, the early-recovery installer and the startup orphan sweep all restore through this one -# stdlib-only helper so the retry ladder and the recovery wording cannot drift apart again. -# --- Windows entry-point shim quarantine ----------------------------------- They used to be separate -# one-shot renames with swallowed errors; the two that had messages had already drifted apart. The logic -# lives here, in the one stdlib-only module all of them can import, so the ladder and the recovery wording -# stay in lockstep. See #75584. +# Older installers left renamed launchers behind on failure. The startup +# orphan sweep still restores them. Generation installs never rename live shims. QUARANTINE_RESTORE_BACKOFF_MS: tuple[int, ...] = (0, 100, 250, 500, 1000) @@ -104,21 +72,6 @@ def _project_root() -> Path: return Path(__file__).resolve().parent.parent -def _load_pyproject_project(root: Path) -> dict | None: - """``[project]`` table of ``root/pyproject.toml``; ``None`` when missing/unreadable.""" - pyproject = root / "pyproject.toml" - if not pyproject.is_file(): - return None - try: - import tomllib - - with open(pyproject, "rb") as f: - project = tomllib.load(f).get("project", {}) - except Exception: - return None - return project if isinstance(project, dict) else None - - def _read_marker_attempts(marker_path: Path) -> int: """Attempt counter from a marker's opportunistic JSON body; corrupt/missing → 0.""" try: @@ -131,19 +84,17 @@ def _read_marker_attempts(marker_path: Path) -> int: import json return int(json.loads(raw).get("attempts", 0)) - except (ValueError, AttributeError): + except (ValueError, AttributeError, TypeError): + for line in reversed(raw.splitlines()): + key, separator, value = line.partition("=") + if separator and key.strip() == "attempts": + try: + return max(0, int(value)) + except ValueError: + return 0 return 0 -def _run_ensurepip(root: Path) -> None: - """Best-effort pip bootstrap — a killed install can leave the venv with no pip module at all.""" - try: - subprocess.run([sys.executable, "-m", "ensurepip", "--upgrade", "--default-pip"], - cwd=root, capture_output=True) - except Exception: - pass - - def _pid_is_running(pid: int) -> bool: """Best-effort stdlib-only process liveness probe. @@ -201,301 +152,105 @@ def _marker_owner_is_live(marker: Path) -> bool: return False -def _pinned_specs(packages: list[str], project_root: Path) -> list[str]: - """Map bare package names to their pinned specs from pyproject.toml. - - Naive requirement-head parsing on purpose — ``packaging`` may itself be broken in the failure - state this module exists for. Unknown packages fall back to their bare name. - """ - project = _load_pyproject_project(project_root) - if project is None: - return packages - name_to_spec: dict[str, str] = {} - for spec in project.get("dependencies", []) or []: - head = spec.split(";", 1)[0].strip() - bare = head - for op in ("==", ">=", "<=", "~=", ">", "<", "!="): - if op in bare: - bare = bare.split(op, 1)[0] - break - key = bare.strip().split("[", 1)[0].strip().lower() - if key: - name_to_spec[key] = head - return [name_to_spec.get(pkg.lower(), pkg) for pkg in packages] - - -def _certifi_bundle_broken() -> bool: - """True when certifi imports but its ``cacert.pem`` is missing/corrupt. - - A brew Python upgrade or interrupted venv rebuild can leave certifi's metadata intact while - ``cacert.pem`` is gone or a dangling symlink; an attribute probe passes in that state and every - TLS connection then fails opaquely, so validate the bundle path itself. - """ - try: - import certifi - - bundle = Path(certifi.where()) - # <1 KiB cannot hold a single PEM certificate — treat as corrupt. - return not bundle.is_file() or bundle.stat().st_size < 1024 - except Exception: - # Import failure is caught by the regular probe table; failing to even stat is broken. - return True - - -def _probe_broken_packages() -> list[str]: - """Import-probe the fragile core packages in THIS process. - - Returns repair package names (deduped, probe order) for modules that fail to import or lack - their sentinel attribute. Failed imports leave nothing in ``sys.modules``, so a post-repair - retry in the same process works. - """ - broken: list[str] = [] - for mod_name, attr in LAZY_REFRESH_IMPORT_PROBES: - try: - mod = importlib.import_module(mod_name) - if not hasattr(mod, attr): - raise ImportError(f"{mod_name} missing {attr}") - if mod_name == "certifi" and _certifi_bundle_broken(): - raise ImportError("certifi cacert.pem missing or corrupt") - except Exception: - pkg = LAZY_REFRESH_REPAIR_PACKAGES.get(mod_name) - if pkg and pkg not in broken: - broken.append(pkg) - return broken - - -def _find_uv_binary() -> str | None: - """Locate a ``uv`` binary without importing third-party modules. - - uv-managed base interpreters carry an ``EXTERNALLY-MANAGED`` marker, so the stdlib ``pip`` - fallback refuses to touch them; the only sanctioned installer is then uv itself, which Hermes - vendors (``~/.hermes/bin/uv.exe``) or the user has on PATH. - """ - exe = "uv.exe" if sys.platform == "win32" else "uv" - for sub in ((".hermes", "bin"), (".local", "bin"), (".cargo", "bin")): - path = Path.home().joinpath(*sub, exe) - if path.is_file(): - return str(path) - return shutil.which(exe) - - -def _base_interpreter_is_externally_managed() -> bool: - """True when ``sys.executable`` is a uv/standalone-builds managed install. - - Those ship an ``EXTERNALLY-MANAGED`` marker next to their stdlib (PEP 668), so - ``python -m pip install`` aborts with ``externally-managed-environment``; the early repair - must then go through uv (or explicitly override pip) or the venv stays broken. - - See #83569. - """ - try: - import sysconfig - - stdlib = Path(sysconfig.get_path("stdlib")) - # uv 0.5+ moved the marker one level up next to a ``_uv_managed`` sentinel dir. - return ((stdlib / "EXTERNALLY-MANAGED").exists() - or (stdlib.parent / "EXTERNALLY-MANAGED").exists()) - except Exception: - return False - - -def _run_installer(tool: str, cmd: list[str], root: Path, env: dict | None = None) -> bool: - """Run one installer command; captured output is replayed to stderr only on failure.""" - try: - result = subprocess.run(cmd, cwd=root, capture_output=True, text=True, encoding="utf-8", - errors="replace", env=env) - except Exception as exc: - print(f" ✗ Early venv repair could not run {tool}: {exc}", file=sys.stderr) - return False - if result.returncode == 0: - return True - tail = (result.stderr or result.stdout or "")[-2000:] - if tail: - print(tail, file=sys.stderr) - return False - - -def _run_repair_install(specs: list[str], project_root: Path) -> bool: - """``uv pip`` (or stdlib ``pip``) force-reinstall of the given specs. Never raises. - - Streams nothing to stdout (``hermes acp`` speaks JSON-RPC on stdout). uv is preferred when the - base interpreter is externally managed; without uv, pip runs with the PEP 668 override. - """ - externally_managed = _base_interpreter_is_externally_managed() - if externally_managed: - uv = _find_uv_binary() - if uv: - env = {**os.environ, "VIRTUAL_ENV": str(project_root / "venv")} - env.pop("PYTHONHOME", None) - env.pop("PYTHONPATH", None) - return _run_installer("uv", [uv, "pip", "install", "--force-reinstall", *specs], - project_root, env) - print(" ⚠ Base interpreter is externally managed and no uv binary was " - "found; retrying repair via pip with PEP 668 override.", file=sys.stderr) - _run_ensurepip(project_root) - pip_cmd = [sys.executable, "-m", "pip", "install", "--force-reinstall"] - if externally_managed: - pip_cmd.append("--break-system-packages") - return _run_installer("pip", pip_cmd + specs, project_root) - - def _pytest_owns_live_checkout(root: Path) -> bool: - """True under pytest when ``root`` is this module's own checkout — the venv running the suite. + """Keep lifecycle tests from repairing the checkout that runs the suite. - Lifecycle tests spawn real subprocesses that import ``hermes_cli.main`` with recovery armed and - inherit ``PYTEST_CURRENT_TEST``; without this guard a broken dev venv would get a REAL - ensurepip + force-reinstall from inside a running suite. tmp_path roots are unaffected. + Copied installations in tmp_path remain eligible for real recovery tests. """ return "PYTEST_CURRENT_TEST" in os.environ and root == Path(__file__).resolve().parent.parent -def recover_if_needed(project_root: Path | None = None, argv: list[str] | None = None) -> None: - """Repair wiped core packages so ``hermes_cli.main`` can import at all. - - Fast path (no marker present) is two ``lstat`` calls. Only acts when a recovery marker from a - prior ``hermes update`` exists AND an import probe confirms a core package is actually broken. - Never raises: on any failure the import of main.py proceeds and surfaces the real error. - """ +def recover_if_needed(project_root: Path | None = None, argv: list[str] | None = None, *, explicit: bool = False) -> bool: + """Ask PM to restore dependencies before activation; leave failed requests retryable.""" global _UPDATE_RETRY_RECOVERED - try: - args = sys.argv[1:] if argv is None else argv - root = _project_root() if project_root is None else project_root - if _pytest_owns_live_checkout(root): - return - core_marker = root / ".update-incomplete" - lazy_marker = root / ".lazy-refresh-incomplete" - if not core_marker.exists() and not lazy_marker.exists(): - return - # Managed/Docker/PyPI installs have no source tree here — the marker is not ours to act - # on; main.py's recovery clears it. - if not (root / "pyproject.toml").is_file(): - return + root = _project_root() if project_root is None else Path(project_root).resolve() + if not explicit and _pytest_owns_live_checkout(root): + return False + from hermes_cli._parser import command_argv - # Pending core install: complete it NOW, before any native extension is imported, so the - # WHOLE dependency set is replaced while nothing pins venv .pyd files yet (deferring to - # main()'s post-import recovery re-locks it on Windows). A live marker owner is another - # updater inside the marker-to-install window — never race it. A dead owner MUST be - # recovered even when this launch is itself `hermes update`: CLI and Desktop retries keep - # that argv, and skipping solely on argv recreates the self-lock loop. - # Bounded retries: a persistently failing install must not hammer every launch, so attempts past the - # ceiling are left for main.py's post-import recovery path (which can safely probe-import after this - # process already holds whatever extensions it needs). See #83569. - if core_marker.exists(): - if _marker_owner_is_live(core_marker): - return - if _complete_pending_core_install(root, core_marker) and "update" in args: - _UPDATE_RETRY_RECOVERED = True - return + args = command_argv(sys.argv[1:] if argv is None else argv) + if not explicit and args[:1] == ["pm"]: + return False # PM's command boundary owns the explicit repair. + from hermes_cli.runtime_paths import install_state_dir, runtime_facts_path, selected_venv, site_packages - # The lazy-refresh marker keeps the update-argv exclusion: it is not a deferred native - # install, and the active update flow owns its probe/repair lifecycle. - if "update" in args: - return + missing_marker = install_state_dir(root) / ".repair-incomplete" + marker_paths = (root / ".update-incomplete", root / ".lazy-refresh-incomplete", missing_marker) + markers = [path for path in marker_paths if path.is_file()] - broken = _probe_broken_packages() - if not broken: - return # main.py will load and run full recovery. - - # Single-flight: share main.py's recovery lock so an early repair never races a - # concurrent full recovery into the same shared venv. - if not _claim_recovery_lock(root): - return + def missing_environment(): + if not runtime_facts_path(root).is_file(): + return False try: - specs = _pinned_specs(broken, root) - print("⚠ Core package(s) broken by an interrupted update — " - f"repairing before launch: {', '.join(broken)}", file=sys.stderr) - if _run_repair_install(specs, root) and not _probe_broken_packages(): - print(" ✓ Core packages repaired.", file=sys.stderr) - else: - print(" ✗ Automatic repair incomplete. Recover manually with:", file=sys.stderr) - print(f" {sys.executable} -m pip install --force-reinstall " + " ".join(specs), - file=sys.stderr) - finally: - _release_recovery_lock(root) - except Exception: - pass # Never block launch — the import of main.py will surface the truth. + return not site_packages(selected_venv(root)).is_dir() + except RuntimeError: + return True # PM validates the recorded state before rebuilding. + + if not (root / "pyproject.toml").is_file() or not (explicit or markers or missing_environment()): + return False + lock = _claim_recovery_lock(root) + if lock is None: + return False + try: + # Recheck after locking: another launch can finish between discovery and claim. + markers = [path for path in marker_paths if path.is_file()] + if not markers: + if not explicit and not missing_environment(): + return False + missing_marker.write_text('{"attempts": 0}', encoding="utf-8") + markers = [missing_marker] + if any(_marker_owner_is_live(marker) for marker in markers): + return False + if not explicit and any(_read_marker_attempts(marker) >= _EARLY_CORE_INSTALL_MAX_ATTEMPTS for marker in markers): + print("hermes: automatic dependency repair retry limit reached; run `hermes pm repair`", file=sys.stderr) + return False + from pm.recovery import repair_dependencies + + print("hermes: repairing the recorded dependency environment...", file=sys.stderr) + repair_dependencies(root) + for marker in markers: + marker.unlink(missing_ok=True) + _UPDATE_RETRY_RECOVERED = args[:1] == ["update"] + print("hermes: dependency environment repaired", file=sys.stderr) + return True + except Exception as exc: + import json + + for marker in markers: + try: + attempts = _read_marker_attempts(marker) + 1 + body = marker.read_text(encoding="utf-8-sig") + if any(line.startswith("pid=") for line in body.splitlines()): + lines = [line for line in body.splitlines() if not line.startswith("attempts=")] + body = "\n".join([*lines, f"attempts={attempts}"]) + "\n" + else: + body = json.dumps({"attempts": attempts}) + marker.write_text(body, encoding="utf-8") + except OSError: + pass + print(f"hermes: dependency repair failed: {exc}; run `hermes pm repair`", file=sys.stderr) + return False + finally: + os.close(lock) -# Cap on automatic early-pass install retries: a persistently failing install (network down) must -# not reinstall-hammer every launch. Past this the marker is left to main.py's post-import recovery, -# which presents the manual command. The counter lives in the marker's JSON body. +# A failed network or build must not retry on every launch forever. _EARLY_CORE_INSTALL_MAX_ATTEMPTS = 3 -def _claim_recovery_lock(root: Path) -> bool: - """Single-flight claim on the shared recovery lock. Never raises.""" - lock_path = root / ".update-incomplete.lock" +def _claim_recovery_lock(root: Path) -> int | None: + """Hold a kernel lock in writable state; process exit releases it.""" + from hermes_cli.runtime_paths import install_state_dir + from hermes_cli.runtime_state import _lock + + state = install_state_dir(root) + state.mkdir(parents=True, exist_ok=True) + fd = os.open(state / ".recovery.lock", os.O_CREAT | os.O_RDWR, 0o600) try: - fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY) - os.write(fd, f"{os.getpid()}\n".encode()) + if _lock(fd, wait=False): + return fd + except BaseException: os.close(fd) - return True - except FileExistsError: - try: - if time.time() - lock_path.stat().st_mtime > 3600: - lock_path.unlink() - except OSError: - pass - return False - except OSError: - # Read-only fs / perms — proceed unlocked; the install itself surfaces the real problem. - return True - - -def _release_recovery_lock(root: Path) -> None: - """Best-effort release of the shared recovery lock.""" - try: - (root / ".update-incomplete.lock").unlink() - except OSError: - pass - - -def _complete_pending_core_install(root: Path, core_marker: Path) -> bool: - """Run the pending core install BEFORE main.py can import native modules. - - Never raises: any failure leaves the marker for the post-import path and returns ``False``. - Returns ``True`` only after the install succeeds. - - ``recover_if_needed`` invokes this when ``.update-incomplete`` exists — a prior ``hermes update`` (or - the self-lock preflight, #83569) left the dependency sync deliberately unfinished. Completing it here - matters on Windows: the deferral exists precisely because the process that wrote the marker had a native - venv extension mapped; this process, running before ``hermes_cli.main``'s third-party imports, maps - nothing yet, so the installer can replace ``.pyd`` files without hitting the lock. - """ - try: - from hermes_cli import _install_repair as ir - - # Upstream refactor: attempt counter read moved into the helper below. - # Kept our utf-8-sig read fix inside the helper (BOM-tolerant marker). - attempts = _read_marker_attempts(core_marker) - if attempts >= _EARLY_CORE_INSTALL_MAX_ATTEMPTS: - print("⚠ Pending interrupted-update install has already failed " - f"{attempts} times in the early pass — leaving it for the " - "post-import recovery path.", file=sys.stderr) - return False - if not _claim_recovery_lock(root): - return False - try: - print("⚠ A previous `hermes update` was interrupted mid-install — " - "finishing dependency installation now (before any native " - "extensions load)...", file=sys.stderr) - ir.run_core_install(root) - except Exception as exc: - new_attempts = ir.bump_marker_attempts(core_marker) - print(f" ✗ Early interrupted-install completion failed (attempt " - f"{new_attempts}/{_EARLY_CORE_INSTALL_MAX_ATTEMPTS}): {exc}", file=sys.stderr) - print(" The next launch will retry; hermes will keep working from " - "the current venv in the meantime.", file=sys.stderr) - return False - finally: - _release_recovery_lock(root) - - try: - core_marker.unlink() - except OSError: - pass - print(" ✓ Dependency installation completed in the early pass.", file=sys.stderr) - return True - except Exception: - return False # Never block launch — the marker stays for the post-import path. + raise + os.close(fd) + return None diff --git a/hermes_cli/_install_repair.py b/hermes_cli/_install_repair.py index 31dcebe6e8..5c5958abc2 100644 --- a/hermes_cli/_install_repair.py +++ b/hermes_cli/_install_repair.py @@ -1,103 +1,16 @@ -"""Dependency install execution shared between early recovery and full recovery. - -Both callers need to run the same core ``.[all]`` reinstall: - -- ``hermes_cli._early_recovery.recover_if_needed`` — stdlib-only, runs BEFORE - ``hermes_cli.main``'s third-party imports, so it can complete a pending - update while no native extension is mapped yet (#83569). -- ``hermes_cli.main._recover_core_update_marker_locked`` — the historical - post-import recovery path. Kept as a fallback for installs the early pass - could not complete (marker left in place on failure). - -This module is deliberately **stdlib-only** so importing it can never fail in -the corrupted-venv state it exists to repair. ``hermes_cli.main`` imports -``pm``, ``hermes_constants``, and friends only in its late path; the -early path must not. Where the late path uses ``pm.uv()`` to -realize uv if missing, the early path uses the stdlib -:func:`hermes_cli._early_recovery._find_uv_binary` lookup and falls back to -plain pip when uv is absent — a degraded but working installer (the late -recovery will bootstrap uv on the next launch if it ever matters). -""" +"""Repair Windows launchers and their registered PATH entries.""" from __future__ import annotations import contextlib -import json import os -import subprocess import sys -import time from pathlib import Path -# Single source of truth for the recovery-lock lifecycle and uv lookup — -# _early_recovery already owns both, and importing it is free (stdlib-only). -from hermes_cli import _early_recovery as _er - - def _is_windows() -> bool: return sys.platform == "win32" -def _stdout_to_stderr(): - """Route fd 1 (and sys.stdout) to stderr for the duration of an install. - - ``hermes acp`` speaks JSON-RPC on stdout; an inherited-fd install child - writing there would corrupt the protocol. Mirrors - ``main.py::_recover_from_interrupted_install``. - """ - saved_fd = None - saved_sys_stdout = sys.stdout - try: - saved_fd = os.dup(1) - os.dup2(2, 1) - except OSError: - saved_fd = None - sys.stdout = sys.stderr - try: - yield - finally: - sys.stdout = saved_sys_stdout - if saved_fd is not None: - try: - os.dup2(saved_fd, 1) - except OSError: - pass - try: - os.close(saved_fd) - except OSError: - pass - - -def _resolve_install_target(root: Path) -> tuple[list[str], dict | None]: - """(install_cmd_prefix, env) for the project venv — stdlib uv lookup. - - Mirrors ``main.py::_default_venv_install_target`` but without ``pm``. ``VIRTUAL_ENV`` steers ``uv pip`` at the project venv even - when invoked from the base interpreter (the early-recovery case). - """ - uv_bin = _er._find_uv_binary() - if uv_bin: - from hermes_constants import project_venv_dir - - env = {**os.environ, "VIRTUAL_ENV": str(project_venv_dir(root) or root / "venv")} - return [uv_bin, "pip"], env - return [sys.executable, "-m", "pip"], None - - -def _venv_scripts_dir(root: Path) -> Path | None: - """Project venv Scripts/bin dir, when present. stdlib-only.""" - # hermes_constants is stdlib-only, so the canonical layout helpers are safe - # to use from this corrupted-venv repair path (#76105: never open-code - # the Scripts/bin split). - from hermes_constants import project_venv_dir, venv_bin_dir - - venv_dir = project_venv_dir(root) - if venv_dir is None: - return None - - scripts = venv_bin_dir(venv_dir, windows=_is_windows()) - return scripts if scripts.is_dir() else None - - #: Launcher command names install.ps1's Set-PathVariable exposes from the #: managed binary dir (the default Hermes root's ``bin``, next to uv.exe) #: on the user PATH. Keep in lockstep with WINDOWS_BIN_LAUNCHERS in @@ -424,239 +337,3 @@ def migrate_windows_bin_path( file=sys.stderr, ) return True - - -def _load_console_script_names(root: Path) -> list[str]: - """``[project.scripts]`` names from pyproject.toml (tomllib, 3.11+).""" - try: - import tomllib - except ImportError: # pragma: no cover - return [] - pyproject = root / "pyproject.toml" - if not pyproject.is_file(): - return [] - try: - with open(pyproject, "rb") as f: - data = tomllib.load(f) - scripts = data.get("project", {}).get("scripts", {}) or {} - return [str(name) for name in scripts if name] - except Exception: - return [] - - -class ShimQuarantineError(RuntimeError): - """A live shim could not be renamed aside — the venv is contended (#87331). - - Raised BEFORE the install command runs. Callers (early-pass recovery, - core-marker recovery) catch it like any install failure: the - update-incomplete marker survives and a later launch retries once the - holder exits — the contended venv is never mutated. - """ - - def __init__(self, failed_shims: list[str]): - self.failed_shims = list(failed_shims) - super().__init__( - "could not quarantine live shim(s): " + ", ".join(self.failed_shims) - ) - - -def _quarantine_running_hermes_exe( - scripts_dir: Path, *, failed_out: list[str] | None = None -) -> list[tuple[Path, Path]]: - """Rename live hermes*.exe shims aside so the installer can rewrite them. - - Windows blocks REPLACE on a running .exe but allows RENAME. Best-effort: - silently skips anything that cannot be renamed. Returns (original, - quarantined) pairs. stdlib-only — the console-script set comes from - pyproject ``[project.scripts]`` (fallback: the well-known trio). - - ``failed_out``: when provided, names of shims that could not be renamed - are appended so the caller can refuse instead of mutating a contended - venv (#87331 fail-closed). - """ - if not _is_windows(): - return [] - names = set(_load_console_script_names(scripts_dir.parent.parent)) or { - "hermes", - "hermes-agent", - "hermes-acp", - } - names.add("hermes-gateway") - moved: list[tuple[Path, Path]] = [] - for name in sorted(names): - shim = scripts_dir / f"{name}.exe" - if not shim.exists(): - continue - quarantined = shim.with_name(f"{name}.exe.old.{int(time.time() * 1000)}") - try: - os.rename(shim, quarantined) - moved.append((shim, quarantined)) - except OSError: - if failed_out is not None: - failed_out.append(shim.name) - return moved - - -def _restore_quarantined_exes(moved: list[tuple[Path, Path]]) -> None: - """Put quarantined shims back when the installer did not replace them. - - Delegates to the shared helper in the stdlib-only ``_early_recovery`` - module: one retry ladder and one recovery message for every restore site, - instead of the near-identical copies that had already drifted (#75584). - Warnings land on stderr — this module runs in the early-recovery path and - ``hermes acp`` speaks JSON-RPC on stdout. - """ - _er.restore_quarantined_shims(moved) - - -def _run_install_cmd(cmd: list[str], *, env: dict | None, root: Path) -> None: - """Run an install command with quarantine protection for venv shims. - - Fail-closed (#87331): when any live shim cannot be renamed aside, the - venv is contended and the installer would die partway on the same locks - — raise :class:`ShimQuarantineError` WITHOUT running it. The caller's - marker-keeping failure handling turns that into "retry next launch". - - Raises CalledProcessError on install failure (callers implement the - per-extra fallback ladder). - """ - scripts_dir = _venv_scripts_dir(root) if _is_windows() else None - failed: list[str] = [] - moved = ( - _quarantine_running_hermes_exe(scripts_dir, failed_out=failed) - if scripts_dir - else [] - ) - if failed: - _restore_quarantined_exes(moved) - raise ShimQuarantineError(failed) - try: - subprocess.run(cmd, cwd=root, check=True, env=env) - finally: - # Restore runs on success AND failure: a SUCCESSFUL install can still - # skip the entry-points step entirely (uv audits an already-satisfied - # editable install as a no-op and rewrites nothing), which would leave - # the quarantined shims renamed aside and `hermes` gone from PATH - # (#75584). _restore_quarantined_exes only renames back when the - # installer did NOT write a fresh shim, so this is safe in both cases. - if scripts_dir is not None: - _restore_quarantined_exes(moved) - - -def _load_installable_optional_extras(root: Path, group: str) -> list[str]: - """Optional extras referenced by a dependency group (all).""" - try: - import tomllib - - with (root / "pyproject.toml").open("rb") as handle: - project = tomllib.load(handle).get("project", {}) - except Exception: - return [] - optional_deps = project.get("optional-dependencies", {}) - if not isinstance(optional_deps, dict): - return [] - refs = optional_deps.get(group, []) - referenced: list[str] = [] - for ref in refs: - if "[" in ref and "]" in ref: - name = ref.split("[", 1)[1].split("]", 1)[0] - if name in optional_deps: - referenced.append(name) - return referenced - - -def run_core_install(root: Path) -> None: - """Full core ``.[all]`` editable reinstall — the recovery install. - - Equal in behavior to the install half of - ``main.py::_recover_core_update_marker_locked``: - - - bootstrap pip via ensurepip (a killed install can leave the venv with no - pip module at all) - - prefer ``uv pip`` with VIRTUAL_ENV pointed at the project venv; fall back - to ``python -m pip`` when no uv binary is available - - target ``.[all]`` with the per-extra fallback ladder when the combined - extras resolve fails - - quarantine live ``hermes*.exe`` shims on Windows so they can be replaced - - route ALL install output to stderr (acp/JSON-RPC safety) - - Raises ``subprocess.CalledProcessError`` when even the base install fails; - callers own marker lifecycle (clear on success, keep on failure). - """ - prefix, env = _resolve_install_target(root) - group = "all" - - with _stdout_to_stderr(): - try: - subprocess.run( - [sys.executable, "-m", "ensurepip", "--upgrade", "--default-pip"], - cwd=root, - capture_output=True, - ) - except Exception: - pass - - try: - _run_install_cmd( - prefix + ["install", "-e", f".[{group}]"], env=env, root=root - ) - return - except subprocess.CalledProcessError: - print( - " ⚠ Optional extras failed, reinstalling base dependencies " - "and retrying extras individually..." - ) - - _run_install_cmd(prefix + ["install", "-e", "."], env=env, root=root) - - failed_extras: list[str] = [] - installed_extras: list[str] = [] - for extra in _load_installable_optional_extras(root, group): - try: - _run_install_cmd( - prefix + ["install", "-e", f".[{extra}]"], env=env, root=root - ) - installed_extras.append(extra) - except subprocess.CalledProcessError: - failed_extras.append(extra) - if installed_extras: - print( - " ✓ Reinstalled optional extras individually: " - + ", ".join(installed_extras) - ) - if failed_extras: - print( - " ⚠ Skipped optional extras that still failed: " - + ", ".join(failed_extras) - ) - - -# --------------------------------------------------------------------------- -# Marker metadata (attempt counter for early-pass retry backoff) -# --------------------------------------------------------------------------- - - -def bump_marker_attempts(marker_path: Path) -> int: - """Increment an attempts counter stored inside the marker file. - - The marker's existence is the signal; opportunistic JSON body carries the - retry count so a persistently failing install can back off instead of - reinstall-hammering every launch. Corrupt/missing bodies restart at 1. - Returns the new attempt count. Never raises. - """ - attempts = 0 - try: - raw = marker_path.read_text(encoding="utf-8-sig", errors="replace").strip() - if raw: - try: - attempts = int(json.loads(raw).get("attempts", 0)) - except (ValueError, AttributeError): - attempts = 0 - except OSError: - attempts = 0 - attempts += 1 - try: - marker_path.write_text(json.dumps({"attempts": attempts}), encoding="utf-8") - except OSError: - pass - return attempts diff --git a/hermes_cli/_parser.py b/hermes_cli/_parser.py index 35a48e2a3c..8cf960badd 100644 --- a/hermes_cli/_parser.py +++ b/hermes_cli/_parser.py @@ -50,6 +50,21 @@ def top_level_value_flag_sets() -> tuple[frozenset[str], frozenset[str]]: return _VALUE_FLAGS_FALLBACK, _OPTIONAL_VALUE_FLAGS_FALLBACK +def command_argv(argv: list[str]) -> list[str]: + """Subcommand and its arguments, excluding top-level flags and their values.""" + required, optional = top_level_value_flag_sets() + value_flags = required | optional | {flag for flag, takes_value in PRE_ARGPARSE_INHERITED_FLAGS if takes_value} + i = 0 + while i < len(argv): + token = argv[i] + if token == "--": + return argv[i + 1:] + if not token.startswith("-"): + return argv[i:] + i += 2 if "=" not in token and token in value_flags and i + 1 < len(argv) else 1 + return [] + + def _inherited_flag(parser, *args, **kwargs): """``parser.add_argument`` + tag the Action ``inherit_on_relaunch`` for ``hermes_cli.relaunch``.""" action = parser.add_argument(*args, **kwargs) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 2a7fde20f7..1828d127be 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -35,24 +35,6 @@ if _bootstrap_root not in sys.path: sys.path.insert(0, _bootstrap_root) from hermes_cli import _startup_fast # noqa: E402 -# Early venv self-heal — MUST run before any third-party import below. A prior -# ``hermes update`` may have left a recovery marker with a core package wiped; -# the hermes_cli.config/env_loader imports further down would then crash before -# main() reaches _recover_from_interrupted_install(). ``_early_recovery`` is -# stdlib-only (safe on a corrupted venv) and repairs just enough to finish this -# import; the marker lifecycle stays with the full recovery path. Its own -# import is unguarded on purpose: same package dir, so if IT can't import -# nothing in hermes_cli can. -# It is also the canonical home of the probe/repair tables reused by the full recovery path below. See -# #57828. -from hermes_cli import _early_recovery as _early_recovery_mod - -try: - _early_recovery_mod.recover_if_needed() -except Exception: - pass - - # Startup-liveness watchdog: for gateway runs, arm BEFORE the heavy import # graph below — an import-time deadlock (native-extension init, contended # import lock) is exactly the "wedged before the event loop, no logs, live @@ -545,6 +527,12 @@ def _apply_profile_override() -> None: _apply_profile_override() +# PM runs after profile resolution but before application dependency imports. +if sys.argv[1:2] == ["pm"]: + from pm.cli import main as _pm_main + + raise SystemExit(_pm_main(sys.argv[2:])) + # Windows launcher self-heal — the ``hermes`` command is a COPY of the venv # console script staged into the managed bin dir (outside the checkout, since # ``hermes update``'s autostash once swept ``\bin`` copies off disk; @@ -710,33 +698,22 @@ from hermes_cli.main_provider_setup import ( _prompt_provider_choice, _remove_custom_provider, ) -from hermes_cli.main_install_repair import ( - _cleanup_quarantined_exes, - _recover_from_interrupted_install, -) +from hermes_cli.main_install_repair import _cleanup_quarantined_exes from hermes_cli.main_install_repair import ( # frozen updater surface: update_cmd*.py resolve these via _m() - ShimQuarantineError, _UPDATE_REEXEC_ENV, _clear_lazy_refresh_incomplete_marker, _clear_marker_file, _clear_update_incomplete_marker, - _install_python_dependencies_with_optional_fallback, _is_termux_env, _is_windows, _is_windows_npm_path, _lazy_refresh_marker_path, _pytest_owns_live_checkout, _reexec_dependency_sync_off_windows_shim, - _repair_venv_via_import_probes, - _resolve_install_target_python, _resolve_node_runtime_npm, _resolve_update_branch, - _run_install_with_heartbeat, - _run_package_only_install, _update_marker_path, _venv_scripts_dir, - _verify_console_scripts_installed, - _verify_core_dependencies_installed, ) from hermes_cli.main_desktop import ( cmd_gui, @@ -2637,21 +2614,10 @@ def _first_positional_argv() -> str | None: Not a full argparse simulation: an unknown ``--foo bar`` may classify ``bar`` as positional, which at worst forces a one-time plugin discovery. """ - from hermes_cli._parser import top_level_value_flag_sets + from hermes_cli._parser import command_argv - required_value_flags, optional_value_flags = top_level_value_flag_sets() - value_flags = required_value_flags | optional_value_flags - argv = sys.argv[1:] - i = 0 - while i < len(argv): - tok = argv[i] - if tok == "--": # everything after is positional - return argv[i + 1] if i + 1 < len(argv) else None - if not tok.startswith("-"): - return tok - # ``--flag=value`` is a single token; a known value flag consumes the next. - i += 2 if ("=" not in tok and tok in value_flags and i + 1 < len(argv)) else 1 - return None + args = command_argv(sys.argv[1:]) + return args[0] if args else None def _plugin_cli_discovery_needed() -> bool: @@ -3346,7 +3312,7 @@ def main(): pass # Sweep stale ``hermes.exe.old.*`` quarantine files from previous Windows - # updates (see ``_quarantine_running_hermes_exe``). No-op elsewhere. + # updates. No-op elsewhere. try: _cleanup_quarantined_exes() except Exception: @@ -3356,19 +3322,9 @@ def main(): # process resolves fresh source against old bytecode. Never raises. _sweep_stale_bytecode_if_checkout_changed() - # Self-heal a venv left half-built by an interrupted ``hermes update``, and - # hint (never restart) about a fleet the interrupted update never - # restarted. Both skipped while the user is *running* update — that flow - # owns its marker and a recovery install must not race the real one. The - # substring match is deliberately loose: over-matching (``hermes skills - # install update``) only defers recovery one launch; under-matching - # (``hermes -p work update``) would race. Never raises. - # See #95294. + # Dependency recovery already ran before imports. Report any fleet restart + # still owed by a previous update without restarting services here. if "update" not in sys.argv[1:]: - try: - _recover_from_interrupted_install() - except Exception: - pass try: from hermes_cli.update_cmd_fleet import _warn_pending_fleet_restart_on_startup @@ -3389,12 +3345,6 @@ def main(): if _try_fast_chat_launch(): return - # pm owns its own tiny argparse tree; dispatch before the heavy parser. - if sys.argv[1:2] == ["pm"]: - from pm.cli import main as pm_main - - sys.exit(pm_main(sys.argv[2:])) - # The startup check: O(1) stamp comparisons, no network, no installs. # One loud line when the install is damaged; never blocks the command. # Then provision: prepend the store's tool dirs to PATH so reactive diff --git a/hermes_cli/main_install_repair.py b/hermes_cli/main_install_repair.py index a558e3a07f..df5abc51ba 100644 --- a/hermes_cli/main_install_repair.py +++ b/hermes_cli/main_install_repair.py @@ -1,17 +1,11 @@ -"""Install/update recovery: interrupted-install markers, lazy-refresh repair, Windows shim quarantine, dependency verification. - -Split out of ``hermes_cli/main.py``. Names that still live in main (``PROJECT_ROOT``, ...) -are imported lazily inside the functions that use them (avoids an import cycle). -""" +"""Update markers, Windows launcher recovery and subprocess handoff.""" import contextlib import logging import os -import shlex import shutil import subprocess import sys -import threading import time as _time from pathlib import Path @@ -38,50 +32,6 @@ def _pyproject_project(debug_fmt: str | None = None) -> dict | None: return project if isinstance(project, dict) else None -def _naive_requirement(spec: str) -> tuple[str, str]: - """``(name, head)`` of a ``name OP version ; marker`` spec without ``packaging``.""" - head = spec.split(";", 1)[0].strip() - bare = head - for op in ("==", ">=", "<=", "~=", ">", "<", "!="): - if op in bare: - bare = bare.split(op, 1)[0] - break - return bare.strip().split("[", 1)[0].strip(), head - - -def _parse_requirements(raw_deps: list[str]) -> list[tuple[str, "object | None", str]]: - """``(name, marker, head)`` per dep spec — ``packaging`` when importable, else a naive split.""" - parsed: list[tuple[str, "object | None", str]] = [] - try: - from packaging.requirements import Requirement # type: ignore - for spec in raw_deps: - try: - req = Requirement(spec) - except Exception: - continue - parsed.append((req.name, req.marker, spec.split(";", 1)[0].strip())) - except Exception: - for spec in raw_deps: - name, head = _naive_requirement(spec) - if name: - parsed.append((name, None, head)) - return parsed - - -def _load_installable_optional_extras(group: str = "all") -> list[str]: - """Return optional extras referenced by a dependency group (``all`` or ``termux-all``).""" - optional_deps = (_pyproject_project() or {}).get("optional-dependencies", {}) - if not isinstance(optional_deps, dict): - return [] - referenced: list[str] = [] - for ref in optional_deps.get(group, []): - if "[" in ref and "]" in ref: - name = ref.split("[", 1)[1].split("]", 1)[0] - if name in optional_deps: - referenced.append(name) - return referenced - - # Install-scoped breadcrumbs live next to the venv (not under $HERMES_HOME) # because the venv is shared across profiles. # ``.update-incomplete`` — generic core ``.[all]`` install was interrupted; @@ -127,170 +77,6 @@ def _clear_lazy_refresh_incomplete_marker() -> None: _clear_marker_file(_lazy_refresh_marker_path(), label="lazy-refresh-incomplete") -def _claim_recovery_lock(lock_path: Path) -> bool: - """Atomically claim the single-flight recovery lock; False when another process holds it. - - A crashed holder's stale lock is broken after an hour (well past any realistic install). - Failing to CREATE the lock (read-only fs, perms) proceeds unlocked — the install itself - will surface the real problem.""" - try: - fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY) - os.write(fd, f"{os.getpid()}\n".encode()) - os.close(fd) - except FileExistsError: - try: - if _time.time() - lock_path.stat().st_mtime > 3600: - lock_path.unlink() - except OSError: - pass - return False - except OSError as exc: - logger.debug("Could not create install-recovery lock: %s", exc) - return True - - -@contextlib.contextmanager -def _stdout_to_stderr(): - """Route Python prints AND the fd 1 that pip/uv inherit to stderr: launches whose stdout is - a protocol stream (``hermes acp`` speaks JSON-RPC on stdout) must never get install noise.""" - saved_stdout_fd = None - saved_sys_stdout = sys.stdout - try: - saved_stdout_fd = os.dup(1) - os.dup2(2, 1) - except OSError: - saved_stdout_fd = None - sys.stdout = sys.stderr - try: - yield - finally: - sys.stdout = saved_sys_stdout - if saved_stdout_fd is not None: - try: - os.dup2(saved_stdout_fd, 1) - os.close(saved_stdout_fd) - except OSError: - pass - - -def _recover_from_interrupted_install() -> None: - """Finish update work left half-done by a prior ``hermes update``. - - ``.update-incomplete`` recovers via full quarantined reinstall; ``.lazy-refresh-incomplete`` - via package-only import probes (cleared only when probes confirm healthy/repaired). Never - raises: on failure it prints the manual command and leaves the marker for the next launch. - Concurrent launches race on the shared venv, so an ``O_EXCL`` lockfile lets one process - recover while the others skip. - """ - from hermes_cli.main import PROJECT_ROOT - if _pytest_owns_live_checkout(PROJECT_ROOT): - return - lazy_marker = _lazy_refresh_marker_path().exists() - if not lazy_marker and not _update_marker_path().exists(): - return - # Managed/Docker installs and git-less PyPI installs never run the source-tree - # update path, so a stray marker is not ours to act on. Just clear it. - if not (PROJECT_ROOT / "pyproject.toml").is_file(): - _clear_update_incomplete_marker() - _clear_lazy_refresh_incomplete_marker() - return - lock_path = PROJECT_ROOT / ".update-incomplete.lock" - if not _claim_recovery_lock(lock_path): - return - try: - with _stdout_to_stderr(): - if lazy_marker: - _recover_lazy_refresh_marker_locked() - if _update_marker_path().exists(): - _recover_core_update_marker_locked() - finally: - try: - lock_path.unlink() - except OSError: - pass - - -def _recover_lazy_refresh_marker_locked() -> None: - """Heal ``.lazy-refresh-incomplete`` via confirmed import-probe repair.""" - print( - "⚠ A previous lazy-backend refresh may have left the venv unhealthy — " - "running import-based package repair...") - install_prefix, install_env = _default_venv_install_target() - status = _repair_venv_via_import_probes(install_prefix, env=install_env) - if status in ("healthy", "repaired"): - _clear_lazy_refresh_incomplete_marker() - print("✓ Lazy-refresh venv recovery confirmed — install is healthy again.") - return - indeterminate = status == "indeterminate" - problem = ( - "Import probes unavailable — cannot confirm venv health." if indeterminate - else "Lazy-refresh package repair incomplete.") - print(f" ⚠ {problem} Leaving `.lazy-refresh-incomplete` for the next launch.") - if indeterminate: - return - print(" Recover manually with:") - all_specs = _lazy_refresh_repair_specs(sorted(set(_LAZY_REFRESH_REPAIR_PACKAGES.values()))) - print( - f" {' '.join(install_prefix)} install --force-reinstall " - + " ".join(shlex.quote(s) for s in all_specs)) - - -def _recover_core_update_marker_locked() -> None: - """Heal ``.update-incomplete`` via full ``.[all]`` reinstall only. - - Narrow lazy-refresh import probes are not proof that a generic interrupted core - install finished — a missing dep outside that probe set would look healthy and - clear the breadcrumb too early. - """ - from hermes_cli.main import PROJECT_ROOT - print( - "⚠ A previous `hermes update` was interrupted mid-install — " - "finishing dependency installation now...") - - # Windows: a ``hermes.exe`` launch has the launcher as an ancestor; the quarantined full - # reinstall can still replace it. Package-only repair is first aid and NEVER clears the marker. - # Full editable reinstall uses quarantine so the live shim can still be replaced. Package-only import - # repair may help as first aid but must NEVER clear this core marker on its own (#58004 review). - self_locked = _windows_running_hermes_launcher_locked() - if self_locked: - install_prefix, install_env = _default_venv_install_target() - print( - " → Running from hermes.exe; applying package-only first aid, " - "then quarantined full reinstall (core marker stays until that " - "succeeds)...") - _repair_venv_via_import_probes(install_prefix, env=install_env) - try: - from hermes_cli import _install_repair as _ir - - # The early stdlib-only pass cannot restore a missing uv; PM can. - from pm.ensure import uv as pm_uv - - pm_uv(realize=True) - # Shared stdlib executor: late path and pre-import early pass run exactly the same - # reinstall. Its own stdout→stderr redirect nests harmlessly inside ours. - _ir.run_core_install(PROJECT_ROOT) - _clear_update_incomplete_marker() - print("✓ Dependency installation recovered — your install is healthy again.") - except Exception as exc: - # Leave the marker so the next launch retries; give the exact manual command. - logger.debug("Interrupted-install recovery failed: %s", exc) - print("✗ Could not auto-recover the interrupted install.") - manual = ( - " Hermes is still running from the launcher that needs " - "replacing. Close other Hermes windows, restart from a " - "different terminal, then run:", - f' cd /d "{PROJECT_ROOT}"', - f' "{sys.executable}" -m pip install -e ".[all]"', - ) if self_locked else ( - " Recover manually with:", - f" cd {PROJECT_ROOT}", - f" {sys.executable} -m ensurepip --upgrade", - f" {sys.executable} -m pip install -e '.[all]'", - ) - for line in manual: - print(line) - - def _norm_exe_path(path) -> str: """Case-folded resolved path, for comparing executables on Windows.""" try: @@ -410,84 +196,6 @@ def _reexec_dependency_sync_off_windows_shim() -> bool: return False -def _default_venv_install_target() -> tuple[list[str], dict[str, str] | None]: - """Return ``(install_cmd_prefix, env)`` for the project venv when possible.""" - from hermes_cli.main import PROJECT_ROOT - try: - from pm.ensure import uv as pm_uv - - uv_bin, _ = pm_uv(realize=True) - except Exception: - uv_bin = None - if uv_bin: - from hermes_constants import project_venv_dir - venv_dir = project_venv_dir(PROJECT_ROOT) or PROJECT_ROOT / "venv" - env = {**os.environ, "VIRTUAL_ENV": str(venv_dir)} - if _is_termux_env(env): - env.pop("PYTHONPATH", None) - env.pop("PYTHONHOME", None) - return [uv_bin, "pip"], env - return [sys.executable, "-m", "pip"], None - - -def _run_install_with_heartbeat( - cmd: list[str], *, env: dict[str, str] | None = None, heartbeat_interval_seconds: int = 30 -) -> None: - """Run a dependency install, printing an elapsed-time heartbeat while pip/uv is silent. - - Resolvers/build backends compiling Rust/C extensions can stay quiet for minutes. - """ - from hermes_cli.main import PROJECT_ROOT - done = threading.Event() - start = _time.time() - - def _heartbeat() -> None: - # Wait first, then print, so short installs don't emit noise. - while not done.wait(heartbeat_interval_seconds): - elapsed = int(_time.time() - start) - print( - f" … still installing dependencies ({elapsed}s elapsed)" - " — compiling Rust/C extensions can take several minutes", - flush=True) - - t = threading.Thread(target=_heartbeat, daemon=True) - t.start() - try: - # stderr=STDOUT: uv/pip write progress to stderr. Legacy desktop - # hand-offs (pre scripts/desktop-update/windows.ps1, which drains - # both pipes) only drain the child's stdout, so a full stderr - # pipe (~64KB) blocks the installer forever. Merged into stdout, - # the output rides the pipe old hand-offs DO drain. This module - # is imported when `hermes update` starts, so an update running - # from an old base executes the old copy regardless of the git - # reset — this protects updates initiated from bases that ship - # it. (managed_uv.py gets the same fix AND is imported lazily - # after the reset, so its sync is protected even on old bases.) - subprocess.run(cmd, cwd=PROJECT_ROOT, check=True, env=env, stderr=subprocess.STDOUT) - finally: - done.set() - t.join(timeout=0.2) - - -def _run_repair_step(run, cmd: list[str], *, log_msg: str, fail_msg: str | None, **kwargs) -> bool: - """``run(cmd, **kwargs)``; on ``CalledProcessError`` log + print the failure and return False.""" - try: - run(cmd, **kwargs) - except subprocess.CalledProcessError as e: - logger.warning(log_msg, e) - if fail_msg is not None: - print(fail_msg) - return False - return True - - -def _report_still_missing(missing: list[str], hint: str, *, ok: str) -> None: - if missing: - print(f" ⚠ Still missing after repair: {', '.join(missing)}. {hint}") - else: - print(ok) - - def _is_windows() -> bool: return sys.platform == "win32" @@ -515,67 +223,6 @@ def _hermes_exe_shims(scripts_dir: Path) -> list[Path]: return [scripts_dir / f"{name}.exe" for name in sorted(names)] -_QUARANTINE_BACKOFF_MS = (0, 100, 250, 500, 1000) - - -def _rename_with_backoff(source: Path, target: Path, attempts: int) -> OSError | None: - """Rename with the quarantine backoff ladder; returns the last ``OSError`` or ``None``.""" - for delay_ms in _QUARANTINE_BACKOFF_MS[:attempts]: - if delay_ms: - _time.sleep(delay_ms / 1000.0) - try: - source.rename(target) - return None - except OSError as e: - last_exc = e - return last_exc - - -def _quarantine_running_hermes_exe( - scripts_dir: Path, *, max_attempts: int = 4, failed_out: list[str] | None = None -) -> list[tuple[Path, Path]]: - """Pre-empt the Windows file lock on the running ``hermes.exe``. - - Windows allows RENAMING a running executable but blocks DELETE/REPLACE (uv fails with - ``Access is denied. (os error 5)``), so live shims are renamed to ``.old.`` - first; ``_cleanup_quarantined_exes`` sweeps the ``.old`` files next invocation. Rename can - still fail when another process holds the .exe without ``FILE_SHARE_DELETE`` (AV scanner: - transient; Hermes Desktop backend child: until closed) — retry with backoff, then warn - naming the likely culprit. Returns ``(original, quarantined)`` pairs for rollback; - ``failed_out`` collects shims whose rename failed every attempt so the update dependency - sync can refuse instead of stranding a half-broken venv. - - See #87331. - """ - moved: list[tuple[Path, Path]] = [] - if not _is_windows(): - return moved - stamp = int(_time.time() * 1000) - # First attempt immediate; 100/250/500ms covers the typical AV re-scan window. - attempts = max(1, min(max_attempts, len(_QUARANTINE_BACKOFF_MS))) - for shim in _hermes_exe_shims(scripts_dir): - if not shim.exists(): - continue - target = shim.with_suffix(shim.suffix + f".old.{stamp}") - last_exc = _rename_with_backoff(shim, target, attempts) - if last_exc is None: - moved.append((shim, target)) - continue - - # Every rename failed. MOVEFILE_DELAY_UNTIL_REBOOT is no fallback (needs elevation, frees - # nothing now, moves a later repaired shim aside at boot). Report; let uv try its luck. - print( - f" ⚠ Could not quarantine {shim.name} ({last_exc.__class__.__name__}: " - f"another process is holding it open).") - print( - " Close Hermes Desktop, exit other `hermes` REPLs, stop the " - "gateway, or pause AV scanning, then re-run `hermes update`.") - if failed_out is not None: - failed_out.append(shim.name) - - return moved - - _PENDING_RENAME_KEY = r"SYSTEM\CurrentControlSet\Control\Session Manager" _PENDING_RENAME_VALUE = "PendingFileRenameOperations" @@ -634,69 +281,7 @@ def _cleanup_pending_shim_renames(scripts_dir: Path) -> int: return 0 -def _restore_quarantined_exes(moved: list[tuple[Path, Path]]) -> None: - """Roll back ``_quarantine_running_hermes_exe`` if uv didn't write replacements. Safety- - critical: a failed quarantine only aborts an update; a failed restore leaves no ``hermes`` - on PATH. Delegates to the stdlib-only retrying helper shared with ``_install_repair``. - - The outbound rename already retries a lock, so this one must too rather than swallow the first - ``OSError`` in silence. See #75584. - """ - _early_recovery_mod.restore_quarantined_shims(moved) - - -class ShimQuarantineError(RuntimeError): - """A live ``hermes*.exe`` shim could not be renamed aside. Raised by - :func:`_run_quarantined_install` in ``strict_quarantine`` mode BEFORE the install runs: a - process holds the venv hard enough that the sync would die partway — refuse, don't warn. - - See #87331. - """ - - def __init__(self, failed_shims: list[str]): - self.failed_shims = list(failed_shims) - super().__init__("could not quarantine live shim(s): " + ", ".join(self.failed_shims)) - - -def _run_quarantined_install( - cmd: list[str], *, env: dict[str, str] | None = None, scripts_dir: Path | None = None, - strict_quarantine: bool = False, -) -> None: - """Run an editable install, quarantining the running ``hermes.exe`` first. - - Every editable install rewrites the entry-point shims; on Windows the live ``hermes.exe`` - can be neither deleted nor overwritten, so without quarantine ``hermes`` drops off PATH. - ``strict_quarantine=True`` (the update dependency sync): a shim whose rename failed every - retry proves a hard venv hold — the install WILL hit the same lock on .pyd files — so roll - back and raise :class:`ShimQuarantineError` without installing. Non-strict callers already - mutated the venv, so refusing buys nothing. ``scripts_dir is None`` is a pass-through. - - See #87331. - """ - moved: list[tuple[Path, Path]] = [] - failed: list[str] = [] - if scripts_dir is not None: - moved = _quarantine_running_hermes_exe(scripts_dir, failed_out=failed) - if strict_quarantine and failed: - _restore_quarantined_exes(moved) - raise ShimQuarantineError(failed) - try: - _run_install_with_heartbeat(cmd, env=env) - finally: - # Restore on FAILURE and SUCCESS: an already-satisfied editable install is a uv no-op - # that rewrites no entry points. Skips shims the installer replaced; finally re-raises. - if scripts_dir is not None: - _restore_quarantined_exes(moved) - - -# A quarantine file younger than this may belong to an update running RIGHT NOW in -# another process, whose restore step still needs it — the only copy of that shim. -# Restore shims when the installer didn't write replacements — on FAILURE (install died before the -# entry-points step) and on SUCCESS too: uv audits an already-satisfied editable install as a no-op and -# rewrites no entry points, which would otherwise leave the shims quarantined aside and `hermes` missing -# from PATH after a green install (#75584). _restore_quarantined_exes skips any shim the installer actually -# replaced, so this never clobbers fresh output. Errors are not swallowed — the finally re-raises whatever -# escaped. +# Fresh orphan files can belong to an updater still running. _QUARANTINE_GRACE_SECONDS = 15 * 60 @@ -751,246 +336,6 @@ def _cleanup_quarantined_exes(scripts_dir: Path | None = None) -> None: pass # still locked or in use — try again next run -# Import probes for venv corruption after a failed lazy ``uv pip install`` (metadata can -# look fine while ``.py`` files were removed mid-install). Canonical tables live in the -# stdlib-only ``_early_recovery`` module so the early and full recovery layers never drift. -# See #57828. -_LAZY_REFRESH_IMPORT_PROBES: tuple[tuple[str, str], ...] = ( - _early_recovery_mod.LAZY_REFRESH_IMPORT_PROBES) -_LAZY_REFRESH_REPAIR_PACKAGES: dict[str, str] = _early_recovery_mod.LAZY_REFRESH_REPAIR_PACKAGES - - -def _run_package_only_install(cmd: list[str], *, env: dict[str, str] | None = None) -> None: - """Package-only pip/uv install — no shim quarantine: ``--force-reinstall `` never rewrites - ``hermes.exe``, and the quarantine path would rename shims uv then never recreates. - - See #57828. - """ - _run_install_with_heartbeat(cmd, env=env) - - -def _lazy_refresh_repair_specs(packages: list[str]) -> list[str]: - """Map repair package names to their declared pin specs in pyproject.toml.""" - project = _pyproject_project("lazy refresh repair spec lookup failed: %s") - if project is None: - return packages - name_to_spec = { - name.lower(): head - for name, _, head in _parse_requirements(project.get("dependencies", []) or [])} - return [name_to_spec.get(pkg.lower(), pkg) for pkg in packages] - - -def _venv_probe(venv_python: Path, script: str, *args: str, env: dict[str, str] | None): - """Run ``script`` in the target venv's interpreter, capturing UTF-8 stdout.""" - return subprocess.run( - [str(venv_python), "-c", script, *args], - capture_output=True, - text=True, encoding="utf-8", errors="replace", - check=False, - env=env) - - -def _nonblank_lines(text: str) -> list[str]: - return [line.strip() for line in text.splitlines() if line.strip()] - - -def _detect_broken_lazy_refresh_imports( - install_cmd_prefix: list[str], *, env: dict[str, str] | None = None) -> list[str] | None: - """Probe lazy-refresh packages via real imports: ``[]`` all clean, ``[dist, ...]`` failures, - ``None`` when the probe could not run (no venv Python, subprocess failure, non-zero exit) - — *indeterminate*, not healthy.""" - venv_python = _resolve_install_target_python(install_cmd_prefix, env) - if venv_python is None: - return None - probe_lines = "\n".join( - f" ({mod!r}, {attr!r})," for mod, attr in _LAZY_REFRESH_IMPORT_PROBES) - check_script = ( - "import os\n" - "import sys\n" - "probes = [\n" - f"{probe_lines}\n" - "]\n" - "broken = []\n" - "for mod, attr in probes:\n" - " try:\n" - " imported = __import__(mod)\n" - " if not hasattr(imported, attr):\n" - " broken.append(mod)\n" - " elif mod == 'certifi':\n" - " # The module can import cleanly while cacert.pem is\n" - " # missing/corrupt (brew Python upgrade, interrupted venv\n" - " # rebuild) - every TLS call then fails (#29866).\n" - " bundle = imported.where()\n" - " if not os.path.isfile(bundle) or os.path.getsize(bundle) < 1024:\n" - " broken.append(mod)\n" - " except Exception:\n" - " broken.append(mod)\n" - "print('\\n'.join(broken))\n") - try: - result = _venv_probe(venv_python, check_script, env=env) - except Exception as exc: - logger.debug("lazy refresh import probe failed: %s", exc) - return None - if result.returncode != 0: - logger.debug("lazy refresh import probe exited %s: %s", - result.returncode, (result.stderr or "")[:200]) - return None - packages: list[str] = [] - for mod in _nonblank_lines(result.stdout): - pkg = _LAZY_REFRESH_REPAIR_PACKAGES.get(mod) - if pkg and pkg not in packages: - packages.append(pkg) - return packages - - -def _repair_broken_lazy_refresh_imports( - install_cmd_prefix: list[str], packages: list[str], *, env: dict[str, str] | None = None -) -> bool: - """Force-reinstall ``packages`` and re-probe imports. Never raises.""" - if not packages: - return True - specs = _lazy_refresh_repair_specs(packages) - if not _run_repair_step( - _run_package_only_install, install_cmd_prefix + ["install", "--force-reinstall", *specs], - env=env, log_msg="lazy refresh venv repair failed: %s", fail_msg=None): - return False - # Indeterminate re-probe is not confirmed success. - return _detect_broken_lazy_refresh_imports(install_cmd_prefix, env=env) == [] - - -def _repair_venv_via_import_probes( - install_cmd_prefix: list[str], *, env: dict[str, str] | None = None) -> str: - """Probe imports and force-reinstall any broken lazy-refresh packages. - - Real ``import`` checks (not distribution metadata) catch a venv where METADATA remains - but ``.py`` files were wiped mid-install. Package-only reinstall — never rewrites - ``hermes.exe``. Never raises. Returns ``"healthy"``, ``"repaired"``, ``"failed"`` - (repair did not confirm clean) or ``"indeterminate"`` (probes could not run; NOT healthy). - - See #57828. - """ - broken = _detect_broken_lazy_refresh_imports(install_cmd_prefix, env=env) - if broken is None: - print(" ⚠ Import probes unavailable — cannot confirm venv package health.") - return "indeterminate" - if not broken: - return "healthy" - print( - " → Detected corrupted venv packages via import probes: " - f"{', '.join(broken)}; repairing...") - if _repair_broken_lazy_refresh_imports(install_cmd_prefix, broken, env=env): - print(" ✓ Venv repair succeeded") - return "repaired" - manual = " ".join(shlex.quote(s) for s in _lazy_refresh_repair_specs(broken)) - print(" ⚠ Venv repair incomplete. Run manually, then `hermes update`:") - print(f" {' '.join(install_cmd_prefix)} install --force-reinstall {manual}") - return "failed" - - -def _is_uv_command(install_cmd_prefix: list[str]) -> bool: - """True for a uv/uvx binary (bare or path) or ``python -m uv`` / ``python -m uvx``.""" - if not install_cmd_prefix: - return False - first = str(install_cmd_prefix[0]).lower() - if "uv" in Path(first).name: - return True - return ( - len(install_cmd_prefix) >= 3 - and first.endswith(("python", "python.exe")) - and install_cmd_prefix[1] == "-m" - and install_cmd_prefix[2] in ("uv", "uvx")) - - -def _insert_python_pin(args: list[str]) -> list[str]: - """Insert ``--python `` into a uv command line; an explicit caller ``--python`` wins.""" - if "--python" in args: - return args - return [args[0], "--python", str(sys.executable), *args[1:]] - - -def _interpreter_scripts_dir() -> Path | None: - """Scripts/bin dir of ``sys.executable``: on a site-packages install ``PROJECT_ROOT/venv`` - does not exist and the shims uv rewrites live next to the interpreter. Layout via the - canonical ``venv_bin_dir`` (hand-rolling Scripts/bin is lint-tested against). - - See #76105. - """ - from hermes_constants import venv_bin_dir - exe = Path(sys.executable) - # sys.executable lives IN the bin/Scripts dir; parent.parent is the env root. - cand = venv_bin_dir(exe.parent.parent, windows=_is_windows()) - if cand.is_dir(): - return cand - return exe.parent if exe.parent.is_dir() else None - - -def _install_python_dependencies_with_optional_fallback( - install_cmd_prefix: list[str], *, env: dict[str, str] | None = None, group: str = "all" -) -> None: - """Install base deps plus as many optional extras as the environment supports. - - Targets ``.[all]`` by default; Termux callers pass ``group='termux-all'``. On Windows every - attempt quarantines the live ``hermes*.exe`` shims first. When ``env`` carries a - ``VIRTUAL_ENV`` that does not exist (pip / site-packages install), ``uv pip`` fails with - ``Failed to inspect Python interpreter from active virtual environment`` before doing any - work — pin the install at the running interpreter instead. - - Pin the install at the running interpreter instead so the update/recovery path succeeds on those - installs (#71510 fixed the ZIP path, #83335 fixed lazy-deps; this closes the shared helper for the - remaining callers). - """ - scripts_dir = _venv_scripts_dir() if _is_windows() else None - - # Only uv needs the explicit pin; pip resolves the target from sys.executable itself. - pin_python = bool( - env and env.get("VIRTUAL_ENV") and not Path(env["VIRTUAL_ENV"]).is_dir() - and install_cmd_prefix and _is_uv_command(install_cmd_prefix)) - if pin_python: - env = {**env} - env.pop("VIRTUAL_ENV", None) - # Pinned to sys.executable, the shims uv rewrites live in THAT interpreter's Scripts - # dir, not PROJECT_ROOT/venv; quarantining the wrong dir leaves hermes.exe locked. - if scripts_dir is None and _is_windows(): - scripts_dir = _interpreter_scripts_dir() - - def _install(args: list[str]) -> None: - if pin_python: - args = _insert_python_pin(args) - # strict_quarantine: this is the UPDATE dependency sync; ShimQuarantineError propagates - # to the sync boundary, which defers via the update-incomplete marker instead. - # A shim that cannot be renamed aside proves a hard venv hold; running uv anyway is how installs - # strand half-updated (#87331). - _run_quarantined_install( - install_cmd_prefix + args, env=env, scripts_dir=scripts_dir, strict_quarantine=True) - - try: - _install(["install", "-e", f".[{group}]"]) - _verify_console_scripts_installed(install_cmd_prefix, env=env) - return - except subprocess.CalledProcessError: - print( - " ⚠ Optional extras failed, reinstalling base dependencies and retrying extras individually..." - ) - - _install(["install", "-e", "."]) - failed_extras: list[str] = [] - installed_extras: list[str] = [] - for extra in _load_installable_optional_extras(group=group): - try: - _install(["install", "-e", f".[{extra}]"]) - installed_extras.append(extra) - except subprocess.CalledProcessError: - failed_extras.append(extra) - if installed_extras: - print(f" ✓ Reinstalled optional extras individually: {', '.join(installed_extras)}") - if failed_extras: - print(f" ⚠ Skipped optional extras that still failed: {', '.join(failed_extras)}") - # uv's incremental resolver has left newly added base deps silently missing on a half-stale - # venv, surfacing hours later as a downstream ModuleNotFoundError. Verify here instead. - _verify_core_dependencies_installed(install_cmd_prefix, env=env, group=group) - _verify_console_scripts_installed(install_cmd_prefix, env=env) - - def _load_console_script_names() -> list[str]: """Return ``[project.scripts]`` entry-point names from pyproject.toml.""" project = _pyproject_project("console script verification: failed to read pyproject.toml: %s") @@ -998,155 +343,6 @@ def _load_console_script_names() -> list[str]: return [str(name) for name in scripts if name] -def _verify_console_scripts_installed( - install_cmd_prefix: list[str], *, env: dict[str, str] | None = None) -> None: - """Ensure every declared console_script shim exists on disk after install. - - On Windows ``uv pip install -e .`` can register ``hermes.exe`` in the wheel RECORD while the - file never lands (live shim locked, launcher write skipped), so ``hermes`` drops off PATH - after a "successful" install. Missing shims get ``--reinstall -e .`` under quarantine. - - The symptom is ``hermes-agent.exe`` and ``hermes-acp.exe`` present but ``hermes.exe`` missing, so - ``hermes`` drops off PATH even though the install reported success (issue #52931). - """ - if not _is_windows(): - return - scripts_dir = _venv_scripts_dir() - names = _load_console_script_names() if scripts_dir is not None else [] - if not names: - return - - def _missing() -> list[str]: - return [name for name in names if not (scripts_dir / f"{name}.exe").is_file()] - - missing = _missing() - if not missing: - return - print( - f" ⚠ Verification: {len(missing)} console script(s) missing on disk: " - f"{', '.join(missing)}") - print(" → Reinstalling entry points with --reinstall...") - if not _run_repair_step( - _run_quarantined_install, install_cmd_prefix + ["install", "--reinstall", "-e", "."], - env=env, scripts_dir=scripts_dir, - log_msg="console script verification: repair install failed: %s", - fail_msg=( - " ⚠ Entry point repair failed; try `hermes update --force` after " - "closing other hermes processes.")): - return - _report_still_missing( - _missing(), "Workaround: python -m hermes_cli.main ", - ok=" ✓ All console entry points restored") - - -def _applicable_dependency_names(raw_deps: list[str]) -> list[str]: - """Declared dep names whose ``;`` markers apply here (else ``ptyprocess ; sys_platform != - 'win32'`` would false-positive on Windows). An unevaluable marker counts as applicable.""" - applicable: list[str] = [] - for name, marker, _ in _parse_requirements(raw_deps): - try: - if marker is None or marker.evaluate(): # type: ignore[union-attr] - applicable.append(name) - except Exception: - applicable.append(name) - return applicable - - -_MISSING_DEPS_SCRIPT = ( - "import importlib.metadata as md, sys\n" - "missing=[]\n" - "for name in sys.argv[1:]:\n" - " try: md.version(name)\n" - " except md.PackageNotFoundError: missing.append(name)\n" - "print('\\n'.join(missing))\n") - - -def _verify_core_dependencies_installed( - install_cmd_prefix: list[str], *, env: dict[str, str] | None = None, group: str = "all" -) -> None: - """Check that every base dep from pyproject.toml is installed in the target venv; if not, retry. - - Reads ``pyproject.toml`` directly (not the venv's stale metadata), drops deps whose ``;`` - markers don't apply here, and probes ``importlib.metadata.version()`` in the venv - interpreter. Missing deps trigger a base-group ``--reinstall``, then a per-package force - install. The final state is a warning, not a hard failure, so one broken-on-PyPI dep can't - block an otherwise-successful update — but the partial install is visible where it happened. - """ - project = _pyproject_project("dep verification: failed to read pyproject.toml: %s") - if project is None: - return - raw_deps = project.get("dependencies", []) or [] - applicable = _applicable_dependency_names(raw_deps) - if not applicable: - return - # Probe inside the venv Python — sys.executable may be the outer Python that drove - # ``hermes update``; the install prefix/env encode which environment we targeted. - venv_python = _resolve_install_target_python(install_cmd_prefix, env) - if venv_python is None: - return - - def _missing_deps() -> list[str]: - try: - result = _venv_probe(venv_python, _MISSING_DEPS_SCRIPT, *applicable, env=env) - except Exception as e: - logger.debug("dep verification: subprocess failed: %s", e) - return [] - return _nonblank_lines(result.stdout) - - missing = _missing_deps() - if not missing: - return - print( - f" ⚠ Verification: {len(missing)} declared dep(s) missing after install: " - f"{', '.join(missing[:8])}{'...' if len(missing) > 8 else ''}") - print(" → Reinstalling base group with --reinstall to repair...") - # Base group only, not ``[{group}]``: the missing dep is a *base* dep and the all-extras - # install costs minutes. Quarantine first: ``--reinstall -e .`` rewrites the shims. - scripts_dir = _venv_scripts_dir() if _is_windows() else None - if not _run_repair_step( - _run_quarantined_install, install_cmd_prefix + ["install", "--reinstall", "-e", "."], - env=env, scripts_dir=scripts_dir, - log_msg="dep verification: repair install failed: %s", - fail_msg=" ⚠ Repair install failed; check `hermes update` output above."): - return - still_missing = _missing_deps() - if not still_missing: - print(" ✓ All declared core dependencies now installed") - return - # Last-ditch: install each remaining missing dep with its pin directly — uv's - # resolver can think the env is satisfied while on-disk metadata disagrees. - name_to_spec = dict(_naive_requirement(spec) for spec in raw_deps) - specs = [name_to_spec.get(n, n) for n in still_missing] - print(f" → Force-installing remaining missing dep(s): {', '.join(specs)}") - if not _run_repair_step( - _run_install_with_heartbeat, install_cmd_prefix + ["install", "--reinstall", *specs], - env=env, - log_msg="dep verification: per-package repair failed: %s", - fail_msg=( - f" ⚠ Could not install: {', '.join(still_missing)}. " - "Run `hermes update --force` after closing other hermes processes.")): - return - _report_still_missing( - _missing_deps(), "Run `hermes update --force` after closing other hermes processes.", - ok=" ✓ All declared core dependencies now installed") - - -def _resolve_install_target_python( - install_cmd_prefix: list[str], env: dict[str, str] | None) -> Path | None: - """Python interpreter the install targeted: ``VIRTUAL_ENV`` from ``env`` for the - ``[uv, pip]`` shape, else ``install_cmd_prefix[0]`` for ``[sys.executable, -m, pip]``.""" - if env and "VIRTUAL_ENV" in env: - from hermes_constants import venv_python_path - candidate = venv_python_path(Path(env["VIRTUAL_ENV"]), windows=_is_windows()) - if candidate.exists(): - return candidate - if install_cmd_prefix: - first = Path(install_cmd_prefix[0]) - if first.exists() and "uv" not in first.name.lower(): - return first - return None - - def _is_termux_env(env: dict[str, str] | None = None) -> bool: from hermes_cli.main import _is_termux_startup_environment return _is_termux_startup_environment(env) diff --git a/hermes_cli/setup_terminal.py b/hermes_cli/setup_terminal.py index f938667f95..f80658ae74 100644 --- a/hermes_cli/setup_terminal.py +++ b/hermes_cli/setup_terminal.py @@ -6,7 +6,6 @@ import json import logging import os import shutil -import sys from pathlib import Path from tools import tool_backend_helpers from hermes_cli import nous_subscription @@ -99,20 +98,6 @@ def _existing_secret_keeps(env_var: str, label: str, question: str) -> bool: return not _setup.prompt_yes_no(question, False) -def _pip_install_vercel(package): - """uv when Hermes has one ($HERMES_HOME/bin is never on PATH, so which() misses it and - bootstrapping mid-wizard is fine), else pip — a `uv venv` venv may not even have pip.""" - import subprocess - - from pm.ensure import uv as pm_uv - - # Missing uv can be provisioned by PM during setup. - uv_bin, _ = pm_uv(realize=True) - cmd = ([uv_bin, "pip", "install", "--python", sys.executable, package] if uv_bin - else [sys.executable, "-m", "pip", "install", package]) - return subprocess.run(cmd, **_RUN_KW) - - def _ensure_sdk(package: str, manual_hint: str, *, show_stderr: bool = False, install=None) -> None: """Import *package*; if missing, install it (default: the venv pip ladder).""" try: @@ -227,7 +212,7 @@ def _setup_backend_vercel(config: dict) -> None: _setup.print_success("Terminal backend: Vercel Sandbox") _setup._info("Cloud microVM sandboxes with snapshot-backed filesystem persistence.", "Requires the optional SDK: pip install 'hermes-agent[vercel]'") - _ensure_sdk("vercel", "pip install 'hermes-agent[vercel]'", show_stderr=True, install=_pip_install_vercel) + _ensure_sdk("vercel", "pip install 'hermes-agent[vercel]'", show_stderr=True) _prompt_vercel_sandbox_settings(config) diff --git a/hermes_cli/tools_config_cua.py b/hermes_cli/tools_config_cua.py index 5afdd6fb5e..f7f28e3a5e 100644 --- a/hermes_cli/tools_config_cua.py +++ b/hermes_cli/tools_config_cua.py @@ -146,22 +146,31 @@ def _pip_install(args: List[str], *, timeout: int = 300, capture_output: bool = """Install Python packages: ``uv pip install`` (needs no pip in the venv), then ``python -m pip``, then ``ensurepip --upgrade`` + retry — the Windows installer creates the venv via ``uv venv``, which does NOT seed pip, so bare ``-m pip`` failed on fresh installs.""" - venv_root = Path(sys.executable).parent.parent + from hermes_constants import venv_python_path + from hermes_cli.runtime_paths import selected_venv + from pm.paths import repo_root + + venv_root = selected_venv(repo_root()) + python = str(venv_python_path(venv_root)) install_flags = _post_setup_no_window_flags(streams_to_console=not capture_output) # Resolve uv and its target environment through PM, not ambient PATH. from pm.ensure import uv as pm_uv + from pm.package import InstallError - uv_bin, uv_env = pm_uv(realize=True, venv=venv_root) + try: + uv_bin, uv_env = pm_uv(realize=True, venv=venv_root) + except InstallError as exc: + return subprocess.CompletedProcess(args, 1, stdout="", stderr=str(exc)) try: # a failed uv run falls through to pip — it may have failed for a reason pip can handle - result = uv_bin and _run_text([uv_bin, "pip", "install", *args], timeout=timeout, + result = uv_bin and _run_text([uv_bin, "pip", "install", "--python", str(venv_root), *args], timeout=timeout, capture_output=capture_output, creationflags=install_flags, env=uv_env) if result and result.returncode == 0: return result except (subprocess.TimeoutExpired, FileNotFoundError): pass - pip_cmd = [sys.executable, "-m", "pip"] + pip_cmd = [python, "-m", "pip"] try: # Probe for pip; bootstrap via ensurepip if missing (uv venv lacks it). probe = _run_text(pip_cmd + ["--version"], timeout=15, @@ -170,7 +179,7 @@ def _pip_install(args: List[str], *, timeout: int = 300, capture_output: bool = raise FileNotFoundError("pip not in venv") except (subprocess.TimeoutExpired, FileNotFoundError): try: - _run_text([sys.executable, "-m", "ensurepip", "--upgrade", "--default-pip"], + _run_text([python, "-m", "ensurepip", "--upgrade", "--default-pip"], timeout=120, check=True, creationflags=_post_setup_no_window_flags()) except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e: # Synthesize a result so callers see a clean failure path. diff --git a/hermes_cli/update_cmd.py b/hermes_cli/update_cmd.py index 211f8e1e5c..a91ed3cfbd 100644 --- a/hermes_cli/update_cmd.py +++ b/hermes_cli/update_cmd.py @@ -349,48 +349,6 @@ def _format_update_failure_stage(exc: subprocess.CalledProcessError) -> str: return "Update step failed" -def _shim_quarantine_error_type() -> "type[BaseException]": - """The strict-quarantine refusal type, resolved lazily through ``_m()``. - - Falls back to a never-raised private type when main.py lacks it (torn - mid-update tree), so the ``except`` clause stays valid. - """ - cls = getattr(_m(), "ShimQuarantineError", None) - if isinstance(cls, type) and issubclass(cls, BaseException): - return cls - - class _Never(Exception): - pass - - return _Never - - -def _refuse_update_for_contended_shims(exc: BaseException) -> None: - """Refuse the dependency sync when live shims could not be quarantined. - - #87331 fail-closed half: a shim rename that failed every retry proves a - process holds the venv without FILE_SHARE_DELETE — running the installer - anyway is exactly how the venv ends up stranded between versions. The - code swap (when one happened) is already committed; only the dependency - install is deferred, via the update-incomplete marker, to the next fresh - launch after the holder exits. Exits 2 (refused) so the command-boundary - receipt net records it as a refusal, not a failure. - """ - print("✗ Cannot continue the update: live Hermes launcher(s) could not be") - print(" moved aside:") - for name in getattr(exc, "failed_shims", []) or ["hermes.exe"]: - print(f" {name}") - print(" Another process is holding this install's venv — typically Hermes") - print(" Desktop, a gateway, or another hermes REPL — and mutating the venv") - print(" now would strand it half-updated.") - print(" The dependency install has been deferred: close the process(es)") - print(" above, then run any `hermes` command to finish it automatically.") - # Idempotent: the git path already dropped the marker before the sync; - # this covers the ZIP/repair paths so the deferral is never silent. - _write_update_incomplete_marker() - sys.exit(2) - - def _should_zip_fallback_on_update_error(exc: BaseException) -> bool: """ZIP fallback is for Windows git file-I/O breakage, not later stages. @@ -1033,8 +991,11 @@ def _repair_venv_on_current_checkout( import pm try: + # A matching stamp cannot certify missing files. Restore the recorded + # graph first, then refresh it against the current checkout's inputs. + pm.sync_venv(repair=True) pm.sync_venv(["all"] + list(active_lazy_features or []), explicit=True) - except pm.InstallError as _sync_err: + except (pm.InstallError, OSError, ValueError) as _sync_err: print(f" ✗ {_sync_err}") return False healthy_after, detail_after = _venv_core_imports_healthy() @@ -1806,10 +1767,6 @@ def _cmd_update_impl(args, gateway_mode: bool): had_desktop_app_before_update=had_desktop_app_before_update, pre_update_snapshot_id=pre_update_snapshot_id, _pre_update_plan=_pre_update_plan, _windows_gateway_resume=_windows_gateway_resume) - except _shim_quarantine_error_type() as e: - # Strict quarantine refused BEFORE any installer ran — defer via marker, exit 2, no ZIP. - # See #87331. - _refuse_update_for_contended_shims(e) except subprocess.CalledProcessError as e: _handle_update_called_process_error(e, args, gateway_mode, had_desktop_app_before_update) diff --git a/hermes_cli/update_cmd_git.py b/hermes_cli/update_cmd_git.py index a29abb46a1..293e3a9a20 100644 --- a/hermes_cli/update_cmd_git.py +++ b/hermes_cli/update_cmd_git.py @@ -398,7 +398,7 @@ def _portable_git_candidates() -> list: profile-scoped HERMES_HOME (``/profiles/``), so a profile-scoped ``hermes update`` must look there (monerostar review, #87876). """ - from hermes_cli.update_cmd import get_default_hermes_root, get_hermes_home + from hermes_constants import get_default_hermes_root, get_hermes_home candidates = [] with suppress(Exception): candidates += [root / "git" / "mingw64" / "libexec" / "git-core" / "git.exe" for root in (get_default_hermes_root(), Path(get_hermes_home()))] diff --git a/plugins/memory/hindsight/embedded_runtime.py b/plugins/memory/hindsight/embedded_runtime.py index 928aba5d9c..2808cdec32 100644 --- a/plugins/memory/hindsight/embedded_runtime.py +++ b/plugins/memory/hindsight/embedded_runtime.py @@ -7,7 +7,6 @@ import logging import os import shutil import subprocess -import sys import uuid from pathlib import Path from typing import Any @@ -141,11 +140,6 @@ def _uv_bridge(venv: Path) -> tuple[str, dict[str, str]]: def _run_uv(uv_bin: str, env: dict[str, str], args: list[str], timeout: float) -> None: - # Pin the interpreter explicitly: pm's sanitized env strips UV_PYTHON, and - # without a pin uv's chooser can pick a different (e.g. PBS 3.14) runtime. - # sys.executable is the Hermes venv python — the side venv is fully - # isolated (own site-packages); this only fixes the BASE interpreter. - env = {**env, "UV_PYTHON": sys.executable} result = subprocess.run( # noqa: S603 — fixed argv, no shell [uv_bin, *args], env=env, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout, diff --git a/pm/cli.py b/pm/cli.py index 7b5aab0924..6c2f870b1f 100644 --- a/pm/cli.py +++ b/pm/cli.py @@ -1,4 +1,4 @@ -"""hermes pm: lock / install / env / doctor / gc / bundle.""" +"""hermes pm: lock / install / repair / env / doctor / gc / bundle.""" from __future__ import annotations @@ -425,6 +425,16 @@ def cmd_status(args) -> int: return 0 +def cmd_repair(args) -> int: + from hermes_cli._early_recovery import recover_if_needed + from pm.paths import repo_root + + if not recover_if_needed(repo_root(), explicit=True): + return 1 + print("Restart Hermes to use the repaired dependency environment.") + return 0 + + def cmd_bundle(args) -> int: from scripts.bundles.native import stage_native return stage_native(args) @@ -465,6 +475,9 @@ def main(argv=None) -> int: p = sub.add_parser("doctor", help="check installed state against the lockfile") p.set_defaults(func=cmd_doctor) + p = sub.add_parser("repair", help="rebuild the recorded dependency environment without changing its graph") + p.set_defaults(func=cmd_repair) + p = sub.add_parser("gc", help="remove store entries nothing references") p.set_defaults(func=cmd_gc) diff --git a/pm/ensure.py b/pm/ensure.py index 7f6e38b754..68f71a3641 100644 --- a/pm/ensure.py +++ b/pm/ensure.py @@ -448,9 +448,11 @@ def _runtime_state_matches(fact: dict, stamp: str) -> bool: return isinstance(environment, str) and (Path(environment) / "pyvenv.cfg").is_file() -def sync_venv(extras: Optional[list[str]] = None, *, explicit: bool = False, plugin_dirs=None, before_publish=None) -> None: +def sync_venv(extras: Optional[list[str]] = None, *, explicit: bool = False, plugin_dirs=None, before_publish=None, repair: bool = False) -> None: """Make the venv match uv.lock + the enabled extras. Extras union into the installed state (one ledger); no-op when the stamp already matches. + ``repair`` restores the recorded dependency graph into a fresh generation, + bypassing both that shortcut and config discovery. It cannot add features. ``explicit`` marks a deliberate install command (`hermes pm install`, `hermes update`) — those are the remedy the lazy-install policy points at, so the policy does not apply to them. @@ -477,7 +479,9 @@ def sync_venv(extras: Optional[list[str]] = None, *, explicit: bool = False, plu token = receipt.begin("sync") outcome = "failed" try: - frozen = read_features() if not lazy_installs_allowed() else None + if repair and (extras is not None or plugin_dirs is not None or before_publish is not None): + raise ValueError("repair restores the recorded environment; it cannot change features or plugins") + frozen = read_features() if repair or not lazy_installs_allowed() else None if frozen is not None and extras: outside = sorted(set(extras) - set(frozen)) if outside: @@ -493,13 +497,22 @@ def sync_venv(extras: Optional[list[str]] = None, *, explicit: bool = False, plu recover_publication(paths.repo_root()) members = plugin_dirs() if callable(plugin_dirs) else plugin_dirs inputs = {} if members is None else {"plugin_dirs": members} - facts = Facts(paths.runtime_facts_path()) + facts = Facts(paths.runtime_facts_path(), strict=repair) fact = facts.get("venv") or _facts().get("venv") or {} - enabled = sorted(set(fact.get("extras", [])) | set(extras or [])) - stamp = package.expected_stamp(enabled, **inputs) - if not explicit and not lazy_installs_allowed() and not _runtime_state_matches(fact, stamp): + if repair: + if fact and (not isinstance(fact.get("extras"), list) + or any(not isinstance(extra, str) for extra in fact["extras"]) + or not isinstance(fact.get("stamp"), str) or not fact["stamp"]): + raise InstallError("venv", "recorded dependency selection is incomplete; refusing to change its graph") + enabled = list(fact.get("extras", frozen if frozen is not None else ["all"])) + stamp = fact.get("stamp") or package.expected_stamp(enabled, plugin_dirs=[]) + inputs = {"repair": True} + else: + enabled = sorted(set(fact.get("extras", [])) | set(extras or [])) + stamp = package.expected_stamp(enabled, **inputs) + if not repair and not explicit and not lazy_installs_allowed() and not _runtime_state_matches(fact, stamp): raise _refuse_lazy("venv", str(extras) if extras else "venv out of sync") - if _runtime_state_matches(fact, stamp): + if not repair and _runtime_state_matches(fact, stamp): if before_publish is not None: publication = before_publish() if hasattr(publication, "finish"): @@ -644,7 +657,6 @@ def _store_path_dirs() -> list[str]: packages, deps-first, deduped. Includes optional packages that are *installed* (facts say so) — an installed git/gh must be on PATH even though it's not in the root closure. Never installs.""" - import os lockfile = _lockfile() target = current_target() @@ -700,40 +712,41 @@ def activate() -> None: -def uv(*, venv=None, realize: bool = True): - """TRANSITIONAL: (uv path, sanitized env) for call sites that still - drive uv themselves. Two classes remain: update/repair sites (die with - the update collapse, plan step 4) and side-venv installs — browser-use - tool venvs (tools_config, browser_use_cli) and hindsight's - local_embedded daemon — which survive until pm grows the side-venv - package kind (plan step 5's remaining half). Must not spread.""" +def uv(command: str = "uv", *, venv=None, realize: bool = True, explicit: bool = False, base_env=None): + """Return uv or uvx with its native suffix and PM's pinned Python. + + Probes never install. A command with missing prerequisites realizes the + package closure or raises; it must not fall back to host Python discovery. + ``venv`` selects the active project environment. ``uv pip`` callers must + still pass their destination interpreter explicitly. + """ from pm.packages import uv_env - env = uv_env() + if command not in ("uv", "uvx"): + raise ValueError(f"unknown uv executable: {command}") + env = uv_env(base_env) if venv is not None: env["VIRTUAL_ENV"] = str(venv) env.pop("UV_NO_CONFIG", None) + if realize: + ensure("uv", explicit=explicit) lockfile = _lockfile() - package = get_package("uv") - location = _installed_location(package, lockfile, current_target()) - if location is None: - if not realize or not lazy_installs_allowed(): + target = current_target() + binaries = {} + for name in ("uv", "python"): + package = get_package(name) + location = _installed_location(package, lockfile, target) + if location is None: return None, env - store = Store(paths.writable_store_root()) - facts = _facts() if store.root == paths.store_root() else Facts(store.root / "facts.json") - try: - _install(package, lockfile, facts, store, current_target()) - facts.reload() - except Exception: - LOG.debug("pm.uv: install failed", exc_info=True) - return None, env - else: facts, store = location - fact = facts.get("uv") - if fact is None: - return None, env - binary = package.binary(store.entry(fact["entry"]), current_target()) - if binary is None or not binary.is_file(): - return None, env - return str(binary), env + binary = package.binary(store.entry(facts.get(name)["entry"]), target) + if name == "uv" and binary is not None: + binary = binary.with_name(command + binary.suffix) + if binary is None or not binary.is_file(): + if not realize: + return None, env + raise InstallError(name, "installed binary is missing", "run `hermes pm install`") + binaries[name] = str(binary) + env["UV_PYTHON"] = binaries["python"] + return binaries["uv"], env diff --git a/pm/lock.py b/pm/lock.py index 31133afd83..cf8f8ba145 100644 --- a/pm/lock.py +++ b/pm/lock.py @@ -21,17 +21,20 @@ any machine by substitution. from __future__ import annotations import json -import os from pathlib import Path SCHEMA = 1 STORE_TOKEN = "{{store}}" -def _read(path: Path) -> dict: +def _read(path: Path, *, strict: bool = False) -> dict: try: text = path.read_text(encoding="utf-8-sig") + except FileNotFoundError: + return {"schema": SCHEMA, "packages": {}} except OSError: + if strict: + raise return {"schema": SCHEMA, "packages": {}} try: data = json.loads(text) @@ -39,6 +42,8 @@ def _read(path: Path) -> dict: return data except ValueError: pass + if strict: + raise ValueError(f"cannot read recorded package state: {path}") if text.strip(): # An unparsable-but-nonempty state file is evidence, not garbage: # the next _write would silently discard every installed-state @@ -106,9 +111,9 @@ def termux_docker_digest() -> str: class Facts: """The installed-state file. Written only by pm.""" - def __init__(self, path: Path): + def __init__(self, path: Path, *, strict: bool = False): self.path = path - self._packages = _read(path)["packages"] + self._packages = _read(path, strict=strict)["packages"] def reload(self) -> None: self._packages = _read(self.path)["packages"] diff --git a/pm/packages.py b/pm/packages.py index c801afc6ba..d8af8140ee 100644 --- a/pm/packages.py +++ b/pm/packages.py @@ -16,7 +16,6 @@ from pm.package import ( Package, StatePackage, _entry_listing, - _missing_reason, _probe_reason, ) from pm.registry import register @@ -159,6 +158,7 @@ class Uv(_BionicDebArm, BinaryPackage, DebPackage): plugin installs) and the wheelhouse's resolver in the build container.""" name = "uv" + deps = ("python",) internal = True binary_rel = {"win32": "uv.exe", "posix": "uv"} # The staged .deb's main binary: DebPackage.verify checks it. @@ -230,9 +230,10 @@ def _macos_sign_managed_python(python: Path) -> bool: @register class Python(_BionicDebArm, BinaryPackage, DebPackage): - """The payload interpreter (python-build-standalone install_only). - Optional: dev installs use their own venv's python; bundles stage this - and point the relocatable venv's pyvenv.cfg at it (pm adopt).""" + """The pinned interpreter for launchers and every PM-managed uv command. + + Optional when provisioning unrelated tools; required by uv's closure. + """ name = "python" optional = True @@ -419,12 +420,19 @@ class Venv(StatePackage): def expected_stamp(self, extras: list[str], *, plugin_dirs=None) -> str: import hashlib - import sys + import json + from pm.lock import Lockfile + from pm.paths import lockfile_path + from pm.store import current_target + lock = Lockfile(lockfile_path()) + target = current_target() + python = (lock.version("python"), target, + [artifact["sha256"] for artifact in lock.artifacts("python", target)]) h = hashlib.sha256() h.update(_uv_lock_digest(self.project_root() / "uv.lock")) h.update(",".join(sorted(extras)).encode()) - h.update(f"{sys.version_info.major}.{sys.version_info.minor}".encode()) + h.update(json.dumps(python).encode()) # Plugin members union into the venv — a changed member set must # re-sync even when extras and core lock are unchanged. from pm.workspace import enabled_member_dirs, members_stamp @@ -432,9 +440,8 @@ class Venv(StatePackage): h.update(members_stamp(enabled_member_dirs() if plugin_dirs is None else plugin_dirs).encode()) return h.hexdigest() - def apply(self, extras: list[str], *, plugin_dirs=None) -> dict: + def apply(self, extras: list[str], *, plugin_dirs=None, repair: bool = False) -> dict: """Prepare one complete environment; the caller commits its selection.""" - import sys import uuid from hermes_cli.runtime_paths import install_state_dir, runtime_facts_path from pm.ensure import uv as pm_uv @@ -444,26 +451,37 @@ class Venv(StatePackage): project = self.project_root() generation = install_state_dir(project) / "environments" / uuid.uuid4().hex candidate = generation / "venv" - uv_bin, env = pm_uv() + uv_bin, env = pm_uv(explicit=repair) if uv_bin is None: raise InstallError(self.name, "uv is not installed") env["UV_PROJECT_ENVIRONMENT"] = str(candidate) env.pop("UV_NO_CONFIG", None) # project indexes/sources belong to the project - members = enabled_member_dirs() if plugin_dirs is None else plugin_dirs + members = [] if repair else (enabled_member_dirs() if plugin_dirs is None else plugin_dirs) try: generation.mkdir(parents=True) (generation / ".lease-managed").touch() create = subprocess.run( - [uv_bin, "venv", "--relocatable", "--python", sys.executable, str(candidate)], + [uv_bin, "venv", "--relocatable", str(candidate)], env=env, capture_output=True, text=True, timeout=120, ) if create.returncode: raise InstallError(self.name, f"uv venv failed: {create.stderr[-600:]}") - prior = Facts(runtime_facts_path(project)).get("venv") or {} + prior = Facts(runtime_facts_path(project), strict=repair).get("venv") or {} + replay = None + if repair and ("environment" in prior or "resolved_lock" in prior): + if not all(isinstance(prior.get(key), str) and prior[key] for key in ("environment", "resolved_lock")): + raise InstallError(self.name, "recorded dependency paths are incomplete; refusing to drop plugins") + recorded = Path(prior["resolved_lock"]).resolve() + previous = Path(prior["environment"]).resolve().parent + generations = install_state_dir(project) / "environments" + if (previous.parent != generations.resolve() or recorded != previous / "workspace" / "uv.lock" + or not recorded.is_file()): + raise InstallError(self.name, "recorded dependency lock is missing; refusing to drop plugins") + replay = recorded.parent seed = (Path(prior["resolved_lock"]) if members and prior.get("resolved_lock") else project / "uv.lock") lock_and_sync(members, extras, venv_dir=candidate, root=generation / "workspace", - seed_lock=seed, frozen=not members, env=env) + seed_lock=seed, frozen=repair or not members, env=env, replay=replay) resolved_lock = generation / "workspace" / "uv.lock" python = candidate / ("Scripts/python.exe" if os.name == "nt" else "bin/python") checked = subprocess.run( @@ -472,6 +490,9 @@ class Venv(StatePackage): ) if checked.returncode: raise InstallError(self.name, f"dependency validation failed: {checked.stderr[-600:]}") + if repair: + from pm.recovery import validate_environment + validate_environment(python, env=env, cwd=resolved_lock.parent) except BaseException: shutil.rmtree(generation, ignore_errors=True) raise diff --git a/pm/plugins_state.py b/pm/plugins_state.py index 136867a8b8..d65190b8df 100644 --- a/pm/plugins_state.py +++ b/pm/plugins_state.py @@ -28,14 +28,17 @@ def _read_home_config(home: Path) -> Optional[dict[str, Any]]: memory.provider) derives from this one read — config.yaml is parsed once per home, not once per question. """ - try: - import utils + config_path = home / "config.yaml" + if not config_path.is_file(): + return None + # Missing YAML support is a broken runtime, not an empty plugin selection. + import utils - config_path = home / "config.yaml" - if not config_path.is_file(): - return None + try: config = utils.fast_safe_load(config_path.read_text(encoding="utf-8-sig")) return config if isinstance(config, dict) else None + except ImportError: + raise except Exception: return None diff --git a/pm/recovery.py b/pm/recovery.py new file mode 100644 index 0000000000..110cfd81db --- /dev/null +++ b/pm/recovery.py @@ -0,0 +1,58 @@ +"""Restore dependency generations without importing the damaged environment.""" +from __future__ import annotations + +import contextlib +import subprocess +import sys +from pathlib import Path + +from pm.package import InstallError + + +STARTUP_IMPORTS = ( + ("PyYAML", "yaml", "SafeDumper"), + ("python-dotenv", "dotenv", "load_dotenv"), + ("click", "click", "Command"), + ("certifi", "certifi", "contents"), + ("rich", "rich", "print"), + ("cryptography", "cryptography.hazmat.bindings._rust", "openssl"), + ("PyJWT", "jwt", "encode"), +) + + +def validate_environment(python: Path, *, env: dict, cwd: Path) -> None: + """Run startup import checks in the candidate, never the repairing process.""" + script = ( + "import importlib, importlib.metadata, pathlib, re, tomllib\n" + "project = tomllib.loads(pathlib.Path('pyproject.toml').read_text(encoding='utf-8-sig'))['project']\n" + "required = {re.split(r'[\\[<>=!~; @]', dep, 1)[0].lower().replace('_', '-')\n" + " for dep in project.get('dependencies', [])}\n" + f"checks = {STARTUP_IMPORTS!r}\n" + "for distribution, module, attribute in checks:\n" + " try:\n" + " importlib.metadata.distribution(distribution)\n" + " except importlib.metadata.PackageNotFoundError:\n" + " if distribution.lower().replace('_', '-') in required:\n" + " raise\n" + " continue\n" + " loaded = importlib.import_module(module)\n" + " getattr(loaded, attribute)\n" + " if module == 'certifi':\n" + " bundle = pathlib.Path(loaded.where())\n" + " assert bundle.is_file() and bundle.stat().st_size >= 1024, 'CA bundle is missing'\n" + ) + result = subprocess.run([str(python), "-I", "-c", script], cwd=cwd, env=env, + capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=60) + if result.returncode: + raise InstallError("venv", f"startup validation failed: {result.stderr.strip()[-1000:]}") + + +def repair_dependencies(project_root: Path) -> None: + """Restore this installation's recorded set; never repair a foreign tree.""" + from pm.ensure import sync_venv + from pm.paths import repo_root + + if Path(project_root).resolve() != repo_root().resolve(): + raise InstallError("venv", "recovery root does not match this PM installation") + with contextlib.redirect_stdout(sys.stderr): + sync_venv(repair=True) diff --git a/pm/workspace.py b/pm/workspace.py index 41289d9837..1e82449593 100644 --- a/pm/workspace.py +++ b/pm/workspace.py @@ -325,8 +325,7 @@ def install_node_sidecar( the declared sidecar install (plugin-deps plan §B item 2; wired here). Plugin-local (never a global npm prefix), pm's pinned npm when the - store has one (ambient PATH npm otherwise — same store-first, - PATH-second precedence as _uv_binary), gated by the lazy-install + store has one (ambient PATH npm otherwise), gated by the lazy-install policy, receipt-noted. Returns None on success, else why not. """ package_json = plugin_dir / "package.json" @@ -392,6 +391,7 @@ def lock_and_sync( env: Optional[dict] = None, seed_lock: Optional[Path] = None, frozen: bool = False, + replay: Optional[Path] = None, ) -> None: """Build the root, then `uv lock` + `uv sync --frozen --extra ...`. @@ -401,32 +401,37 @@ def lock_and_sync( existing lock seeds the extension (default: the root's current extended lock, else the committed core lock). ``env`` replaces the ambient base environment when supplied; either way the subprocess - gets a COPY — the live process environment is never mutated. + gets a COPY — the live process environment is never mutated. ``replay`` + copies a recorded sibling workspace and uses its lock without resolution + or plugin discovery; it is reserved for restoring an existing selection. Raises a CLASSIFIED InstallError on failure: ResolutionConflict only for a confirmed resolver conflict; network, build and tool failures stay generic InstallError — they are not evidence of a dependency conflict and must not disable plugins. """ - from pm.packages import uv_env + from pm.ensure import uv as pm_uv - generated, changed = _generate_pyproject(plugin_dirs, root) - if changed: - _seed_lock(generated, seed_lock) + if replay is None: + generated, changed = _generate_pyproject(plugin_dirs, root) + if changed: + _seed_lock(generated, seed_lock) + else: + import shutil - uv_bin = _uv_binary() + if root is None or not (replay / "pyproject.toml").is_file() or not (replay / "uv.lock").is_file(): + raise InstallError("venv", f"recorded workspace is missing: {replay}") + # Generation workspaces are siblings at the same depth. External + # member paths still resolve. Generated members move with the copy. + shutil.copytree(replay, root, ignore=shutil.ignore_patterns("__pycache__", ".venv", "build", "*.egg-info")) + generated = root + frozen = True + + uv_bin, run_env = pm_uv(base_env=env) if uv_bin is None: - raise InstallError("venv", "uv is not installed (pm ensure uv)") - - # uv_env SANITIZES the base: when the parent staged an env it is the - # base (ambient VIRTUAL_ENV/UV_* leakage stripped); otherwise the live - # environment is sanitized. Either way this is a fresh COPY — the live - # process environment is never mutated. - run_env = uv_env(env) + raise InstallError("venv", "PM's uv and Python are required; run `hermes pm install`") run_env.pop("UV_NO_CONFIG", None) run_env["UV_PROJECT_ENVIRONMENT"] = str(venv_dir) - import sys - run_env["UV_PYTHON"] = sys.executable import subprocess @@ -454,17 +459,3 @@ def lock_and_sync( raise classify_uv_failure( "sync", sync.returncode, sync.stderr or sync.stdout ) - - -def _uv_binary() -> Optional[str]: - """Store uv first (pinned), PATH uv second (dev machines, test envs). - A dev machine with uv on PATH but no provisioned store is the normal - pre-`pm install` state — 'uv is not installed' there is a lie.""" - from pm.ensure import uv as pm_uv - - uv_bin, _env = pm_uv(realize=False) - if uv_bin: - return uv_bin - import shutil - - return shutil.which("uv") diff --git a/scripts/ci/setup_toolchain.py b/scripts/ci/setup_toolchain.py index 3e536a8696..61b02430d7 100644 --- a/scripts/ci/setup_toolchain.py +++ b/scripts/ci/setup_toolchain.py @@ -174,7 +174,6 @@ def dependencies(args) -> None: raise ValueError(f"unknown project extras: {sorted(unknown)}") uv_bin, environment = uv() environment.pop("UV_NO_CONFIG", None) # keep project indexes and exclude-newer - environment["UV_PYTHON"] = sys.executable # PM's frozen sync must not turn a stale project lock into a green job. subprocess.run([uv_bin, "lock", "--check"], cwd=project, env=environment, check=True, timeout=1800) sync_venv(args.extras, explicit=True, plugin_dirs=[]) diff --git a/tests/hermes_cli/test_checkout_mutation_guards.py b/tests/hermes_cli/test_checkout_mutation_guards.py index 5c40d2513d..4992f702d9 100644 --- a/tests/hermes_cli/test_checkout_mutation_guards.py +++ b/tests/hermes_cli/test_checkout_mutation_guards.py @@ -18,8 +18,6 @@ from __future__ import annotations from pathlib import Path import hermes_cli.main as main_mod -import hermes_cli.main_install_repair as hermes_cli_main_install_repair -from hermes_cli import main_install_repair from hermes_cli import update_cmd from hermes_cli import _early_recovery as er @@ -76,37 +74,41 @@ class TestEarlyRecovery: def test_skips_live_checkout_before_any_probe_or_lock(self, monkeypatch): # A probe call would mean recovery is proceeding against the live # checkout; the guard must return before ANY side-effectful step. - def _boom(): - raise AssertionError("probe ran against the live checkout") + # PM generation: recovery's repair action is pm.recovery. + # repair_dependencies, reached through er.recover_if_needed. + def _boom(*a, **k): + raise AssertionError("repair ran against the live checkout") - monkeypatch.setattr(er, "_probe_broken_packages", _boom) - monkeypatch.setattr(er, "_run_repair_install", lambda *a, **k: _boom()) - er.recover_if_needed(project_root=CHECKOUT_ROOT, argv=[]) + import pm.recovery as pm_recovery + + monkeypatch.setattr(pm_recovery, "repair_dependencies", _boom) + assert er.recover_if_needed(project_root=CHECKOUT_ROOT, argv=[]) is False def test_sandboxed_root_still_recovers(self, tmp_path, monkeypatch): # The guard must not disable recovery for sandboxed roots: with a - # marker present and a broken probe, the repair path still runs. - (tmp_path / ".lazy-refresh-incomplete").write_text("started=1\npid=1\n") + # marker present, PM repair still runs and clears the marker. + import pm.recovery as pm_recovery + + (tmp_path / ".lazy-refresh-incomplete").write_text("started=1\npid=0\n") (tmp_path / "pyproject.toml").write_text("[project]\nname='x'\n") - monkeypatch.setattr(er, "_probe_broken_packages", lambda: ["PyYAML"]) - monkeypatch.setattr(er, "_pinned_specs", lambda broken, root: broken) - installs = [] + repairs = [] monkeypatch.setattr( - er, "_run_repair_install", lambda specs, root: installs.append(specs) or True + pm_recovery, "repair_dependencies", lambda root: repairs.append(root) ) - er.recover_if_needed(project_root=tmp_path, argv=[]) - assert installs, "sandboxed recovery was wrongly disabled by the guard" + assert er.recover_if_needed(project_root=tmp_path, argv=[]) is True + assert repairs == [tmp_path], "sandboxed recovery was wrongly disabled by the guard" + assert not (tmp_path / ".lazy-refresh-incomplete").exists() class TestLaunchRecovery: - def test_recover_from_interrupted_install_noops_on_live_checkout( - self, monkeypatch - ): - # PROJECT_ROOT is the live checkout in-suite; the launch-time - # recovery must return before touching markers or spawning installs. - def _boom(*a, **k): - raise AssertionError("launch recovery ran against the live checkout") + def test_recover_if_needed_noops_on_live_checkout(self, monkeypatch): + # PROJECT_ROOT is the live checkout in-suite. Startup recovery must + # return before touching markers, locks, or repair dependencies. + import pm.recovery as pm_recovery - monkeypatch.setattr(main_mod, "_update_marker_path", _boom) - monkeypatch.setattr(hermes_cli_main_install_repair, "_update_marker_path", _boom) - main_mod._recover_from_interrupted_install() + def _boom(*a, **k): + raise AssertionError("startup recovery ran against the live checkout") + + monkeypatch.setattr(er, "_project_root", lambda: CHECKOUT_ROOT) + monkeypatch.setattr(pm_recovery, "repair_dependencies", _boom) + assert er.recover_if_needed(argv=[]) is False diff --git a/tests/hermes_cli/test_cmd_update.py b/tests/hermes_cli/test_cmd_update.py index 0d00b1e5a2..8926aba6a6 100644 --- a/tests/hermes_cli/test_cmd_update.py +++ b/tests/hermes_cli/test_cmd_update.py @@ -1139,9 +1139,6 @@ class TestNodeRuntimeNpmResolution: monkeypatch.setattr(hm, "_clear_bytecode_cache", lambda *_args: 0) monkeypatch.setattr(hm, "_record_bytecode_fingerprint", lambda: None) monkeypatch.setattr(hm, "_refresh_bootstrap_cache_scripts", lambda _branch: None) - monkeypatch.setattr( - hm, "_install_python_dependencies_with_optional_fallback", lambda *_args, **_kwargs: None - ) monkeypatch.setattr(hm, "_refresh_active_memory_provider_dependencies", lambda: None) monkeypatch.setattr(hm, "_build_web_ui", lambda *_args: None) monkeypatch.setattr(update_cmd, "_discard_lockfile_churn", lambda *_args: None) @@ -1558,15 +1555,16 @@ class TestGitTrampolineSelfHeal: # PortableGit tree lives under the SHARED root (monerostar review on # #88136). The candidate list must check get_default_hermes_root() # before the profile home. - from hermes_cli import update_cmd + import hermes_constants + from hermes_cli.update_cmd_git import _portable_git_candidates root = tmp_path / "root" profile_home = root / "profiles" / "foo" - monkeypatch.setattr(update_cmd, "get_default_hermes_root", lambda: root) - monkeypatch.setattr(update_cmd, "get_hermes_home", lambda: profile_home) + monkeypatch.setattr(hermes_constants, "get_default_hermes_root", lambda: root) + monkeypatch.setattr(hermes_constants, "get_hermes_home", lambda: profile_home) - candidates = update_cmd._portable_git_candidates() + candidates = _portable_git_candidates() assert candidates[0] == ( root / "git" / "mingw64" / "libexec" / "git-core" / "git.exe" ) diff --git a/tests/hermes_cli/test_early_recovery.py b/tests/hermes_cli/test_early_recovery.py index d8bac329be..122c62b627 100644 --- a/tests/hermes_cli/test_early_recovery.py +++ b/tests/hermes_cli/test_early_recovery.py @@ -1,12 +1,7 @@ -"""Tests for hermes_cli._early_recovery — the dependency-light bootstrap -repair that runs BEFORE hermes_cli.main's third-party imports (#57828 / #58004). +"""Startup triggers PM recovery without importing the damaged dependencies. -Covers: -- entry-point lifecycle: a broken core import (dotenv) crashes the import of - hermes_cli.main WITHOUT early recovery, and imports fine when recovery runs - first (proving main.py invokes recovery before its third-party imports) -- recover_if_needed unit behavior: fast path, marker gating, update-argv skip, - lock single-flight, no marker clearing, pinned repair specs +Real generation rebuilds and pre-activation startup live in tests/pm; these +checks keep marker ownership, retry limits and single-flight behavior intact. """ from __future__ import annotations @@ -14,140 +9,117 @@ from __future__ import annotations import os import subprocess import sys -import textwrap from pathlib import Path import pytest from hermes_cli import _early_recovery as er +from pm import recovery REPO_ROOT = Path(__file__).resolve().parents[2] -# --------------------------------------------------------------------------- -# Entry-point lifecycle (subprocess, real imports) -# --------------------------------------------------------------------------- - -def _make_broken_dotenv_shadow(tmp_path: Path) -> Path: - """A sys.path dir shadowing ``dotenv`` with the #57828 failure state: - distribution metadata intact, import files wiped/broken.""" - shadow = tmp_path / "shadow" - shadow.mkdir() - (shadow / "dotenv.py").write_text( - "raise ImportError('import files wiped mid-install (#57828)')\n", - encoding="utf-8", - ) - return shadow - - -def _run_lifecycle_subprocess(tmp_path: Path, *, repair: bool) -> subprocess.CompletedProcess: - shadow = _make_broken_dotenv_shadow(tmp_path) - hermes_home = tmp_path / "hermes_home" - hermes_home.mkdir() - script = tmp_path / "lifecycle.py" - script.write_text( - textwrap.dedent( - f""" - import sys - - shadow = {str(shadow)!r} - sys.path.insert(0, shadow) - - # _early_recovery must be importable on the corrupted venv - # (stdlib-only) — this import itself is part of the contract. - import hermes_cli._early_recovery as er - - REPAIR = {repair!r} - - def recorder(*args, **kwargs): - print("EARLY_RECOVERY_CALLED", flush=True) - if REPAIR: - sys.path.remove(shadow) - sys.modules.pop("dotenv", None) - - er.recover_if_needed = recorder - - import hermes_cli.main # noqa: F401 - print("MAIN_IMPORTED_OK", flush=True) - """ - ), - encoding="utf-8", - ) - env = { - **os.environ, - "PYTHONPATH": str(REPO_ROOT), - "HERMES_HOME": str(hermes_home), - } - return subprocess.run( - [sys.executable, str(script)], - capture_output=True, - text=True, - cwd=REPO_ROOT, - env=env, - timeout=120, - ) - - -def test_broken_dotenv_crashes_main_import_without_repair(tmp_path): - """Negative control: the shadow really breaks importing hermes_cli.main, - and recovery was invoked BEFORE the crash (i.e. before third-party - imports) — so a real repair at that point can save the launch.""" - result = _run_lifecycle_subprocess(tmp_path, repair=False) - assert result.returncode != 0 - assert "EARLY_RECOVERY_CALLED" in result.stdout - assert "MAIN_IMPORTED_OK" not in result.stdout - assert "wiped mid-install" in result.stderr - - - - -def test_early_recovery_module_is_stdlib_only(tmp_path): - """The module must import in a process where every non-stdlib import - fails — that is the whole point of its existence.""" - script = tmp_path / "stdlib_only.py" - script.write_text( - textwrap.dedent( - """ - import builtins - import sys - - STDLIB = set(sys.stdlib_module_names) | {"hermes_cli"} - real_import = builtins.__import__ - - def guard(name, *args, **kwargs): - top = name.split(".")[0] - if top not in STDLIB: - raise ImportError(f"non-stdlib import blocked: {name}") - return real_import(name, *args, **kwargs) - - builtins.__import__ = guard - import hermes_cli._early_recovery # noqa: F401 - print("STDLIB_ONLY_OK") - """ - ), - encoding="utf-8", - ) +@pytest.mark.parametrize("prefix", [[], ["-p", "default"], ["--profile=default"]]) +def test_bootstrap_and_pm_cli_work_without_site_packages(tmp_path, prefix): + env = {**os.environ, "HERMES_HOME": str(tmp_path / "home"), "PYTHONPATH": str(REPO_ROOT)} result = subprocess.run( - [sys.executable, str(script)], - capture_output=True, - text=True, - cwd=REPO_ROOT, - env={**os.environ, "PYTHONPATH": str(REPO_ROOT)}, - timeout=60, + [sys.executable, "-S", "-m", "hermes_cli.main", *prefix, "pm", "repair", "--help"], + cwd=tmp_path, env=env, capture_output=True, text=True, timeout=60, ) - assert "STDLIB_ONLY_OK" in result.stdout, result.stderr + assert result.returncode == 0, result.stderr + assert "hermes pm repair" in result.stdout -# --------------------------------------------------------------------------- -# recover_if_needed unit behavior -# --------------------------------------------------------------------------- +@pytest.mark.parametrize("marker_name", [".update-incomplete", ".lazy-refresh-incomplete"]) +def test_marker_requests_pm_repair_then_clears(tmp_path, monkeypatch, capsys, marker_name): + root = _project(tmp_path) + marker = root / marker_name + marker.write_text("interrupted", encoding="utf-8") + calls = [] + monkeypatch.setattr(recovery, "repair_dependencies", calls.append) + assert er.recover_if_needed(root, argv=[]) is True + assert calls == [root] + assert not marker.exists() + assert capsys.readouterr().out == "" + + +@pytest.mark.parametrize("body", ["", "not json", '{"attempts": 1}', "started=1\npid=0\n"]) +def test_failed_repair_keeps_marker_and_stops_at_retry_limit(tmp_path, monkeypatch, capsys, body): + root = _project(tmp_path) + marker = root / ".update-incomplete" + marker.write_text(body, encoding="utf-8") + attempts = er._read_marker_attempts(marker) + calls = [] + def fail(project): + calls.append(project) + raise RuntimeError("dependency build failed") + monkeypatch.setattr(recovery, "repair_dependencies", fail) + for expected in range(attempts + 1, er._EARLY_CORE_INSTALL_MAX_ATTEMPTS + 1): + assert er.recover_if_needed(root, argv=[]) is False + assert er._read_marker_attempts(marker) == expected + if "pid=" in body: + assert marker.read_text(encoding="utf-8").startswith(body) + before = len(calls) + assert er.recover_if_needed(root, argv=[]) is False + assert len(calls) == before + output = capsys.readouterr() + assert output.out == "" + assert "hermes pm repair" in output.err + + +def test_recovery_obeys_live_owner_and_single_flight(tmp_path, monkeypatch): + root = _project(tmp_path) + marker = root / ".update-incomplete" + marker.write_text(f"started=1\npid={os.getpid()}\n", encoding="utf-8") + calls = [] + monkeypatch.setattr(recovery, "repair_dependencies", calls.append) + assert er.recover_if_needed(root, argv=[]) is False + assert calls == [] + assert marker.exists() + marker.write_text("interrupted", encoding="utf-8") + fd = er._claim_recovery_lock(root) + assert fd is not None + try: + assert er.recover_if_needed(root, argv=[]) is False + assert calls == [] + assert marker.exists() + finally: + os.close(fd) + assert er.recover_if_needed(root, argv=["update"]) is True + assert calls == [root] + + +def test_missing_environment_cannot_write_a_retry_marker_without_lock(tmp_path, monkeypatch): + from hermes_cli.runtime_paths import install_state_dir, runtime_facts_path + from pm.lock import Facts + + root = _project(tmp_path) + state = install_state_dir(root) + Facts(runtime_facts_path(root)).record_state("venv", "old", [], environment=state / "environments" / "old" / "venv") + fd = er._claim_recovery_lock(root) + assert fd is not None + try: + assert er.recover_if_needed(root, argv=[]) is False + assert not (state / ".repair-incomplete").exists() + finally: + os.close(fd) + + +def test_pm_commands_and_healthy_startup_do_not_repair(tmp_path, monkeypatch): + root = _project(tmp_path) + monkeypatch.setattr(recovery, "repair_dependencies", lambda _: pytest.fail("unexpected install")) + assert er.recover_if_needed(root, argv=[]) is False + marker = root / ".update-incomplete" + marker.write_text("interrupted", encoding="utf-8") + assert er.recover_if_needed(root, argv=["pm", "repair"]) is False + assert marker.exists() + def test_pid_liveness_recognizes_current_process(): assert er._pid_is_running(os.getpid()) is True assert er._pid_is_running(0) is False - def test_marker_owner_liveness_uses_recorded_pid(tmp_path, monkeypatch): marker = tmp_path / ".update-incomplete" marker.write_text("started=1\npid=4321\n", encoding="utf-8") @@ -159,7 +131,6 @@ def test_marker_owner_liveness_uses_recorded_pid(tmp_path, monkeypatch): assert er._marker_owner_is_live(marker) is True assert seen == [4321] - def _project(tmp_path: Path, *, pyproject: bool = True) -> Path: root = tmp_path / "proj" root.mkdir(exist_ok=True) @@ -181,367 +152,5 @@ def _project(tmp_path: Path, *, pyproject: bool = True) -> Path: -def test_marker_plus_broken_probe_repairs_with_pinned_specs(tmp_path, monkeypatch): - root = _project(tmp_path) - marker = root / ".lazy-refresh-incomplete" - marker.write_text("x", encoding="utf-8") - - probe_results = iter([["PyYAML", "python-dotenv"], []]) - monkeypatch.setattr(er, "_probe_broken_packages", lambda: next(probe_results)) - installs = [] - monkeypatch.setattr( - er, "_run_repair_install", lambda specs, r: installs.append(specs) or True - ) - - er.recover_if_needed(project_root=root, argv=[]) - - assert installs == [["PyYAML==6.0.2", "python-dotenv==1.2.2"]] - # Marker lifecycle belongs to main.py's full recovery — never cleared here. - assert marker.exists() - # Lock released for the full recovery pass. - assert not (root / ".update-incomplete.lock").exists() - - -# --------------------------------------------------------------------------- -# _run_repair_install: uv-managed base interpreters (#83569) -# --------------------------------------------------------------------------- - -def test_repair_install_prefers_uv_when_base_is_externally_managed( - tmp_path, monkeypatch -): - """uv-managed base Pythons carry EXTERNALLY-MANAGED: plain - ``python -m pip`` aborts, so the repair must go through ``uv pip`` with - VIRTUAL_ENV pointed at the project venv.""" - root = _project(tmp_path) - monkeypatch.setattr(er, "_base_interpreter_is_externally_managed", lambda: True) - monkeypatch.setattr(er, "_find_uv_binary", lambda: "/fake/uv") - - calls = [] - - def fake_run(cmd, **kwargs): - calls.append(cmd) - - class R: - returncode = 0 - stderr = "" - stdout = "" - - return R() - - monkeypatch.setattr(er.subprocess, "run", fake_run) - - assert er._run_repair_install(["cryptography==50.0.0"], root) is True - - assert len(calls) == 1 - cmd = calls[0] - assert cmd[:3] == ["/fake/uv", "pip", "install"] - assert "--force-reinstall" in cmd - assert "cryptography==50.0.0" in cmd - - -def test_repair_install_uv_sets_virtual_env_to_project_venv(tmp_path, monkeypatch): - root = _project(tmp_path) - monkeypatch.setattr(er, "_base_interpreter_is_externally_managed", lambda: True) - monkeypatch.setattr(er, "_find_uv_binary", lambda: "/fake/uv") - - seen_env = {} - - def fake_run(cmd, **kwargs): - seen_env.update(kwargs.get("env") or {}) - - class R: - returncode = 0 - stderr = "" - stdout = "" - - return R() - - monkeypatch.setattr(er.subprocess, "run", fake_run) - - assert er._run_repair_install(["PyYAML==6.0.2"], root) is True - assert seen_env.get("VIRTUAL_ENV") == str(root / "venv") - # A leaked PYTHONHOME/PYTHONPATH from the parent shell must not steer - # uv's venv resolution. - assert "PYTHONHOME" not in seen_env - assert "PYTHONPATH" not in seen_env - - -def test_repair_install_falls_back_to_break_system_packages_without_uv( - tmp_path, monkeypatch -): - """No uv anywhere: still attempt the repair with pip's PEP 668 override - instead of no-oping behind externally-managed-environment.""" - root = _project(tmp_path) - monkeypatch.setattr(er, "_base_interpreter_is_externally_managed", lambda: True) - monkeypatch.setattr(er, "_find_uv_binary", lambda: None) - - calls = [] - - def fake_run(cmd, **kwargs): - calls.append(cmd) - - class R: - returncode = 0 - stderr = "" - stdout = "" - - return R() - - monkeypatch.setattr(er.subprocess, "run", fake_run) - - assert er._run_repair_install(["cryptography==50.0.0"], root) is True - - pip_calls = [c for c in calls if "pip" in c] - assert pip_calls, calls - assert any("--break-system-packages" in c for c in pip_calls) - - -def test_repair_install_uses_plain_pip_when_not_externally_managed( - tmp_path, monkeypatch -): - """Self-contained venvs (no PEP 668 marker) keep the original behaviour: - ensurepip + plain pip, no uv lookup, no override flag.""" - root = _project(tmp_path) - monkeypatch.setattr( - er, "_base_interpreter_is_externally_managed", lambda: False - ) - monkeypatch.setattr( - er, "_find_uv_binary", lambda: pytest.fail("uv must not be consulted") - ) - - calls = [] - - def fake_run(cmd, **kwargs): - calls.append(cmd) - - class R: - returncode = 0 - stderr = "" - stdout = "" - - return R() - - monkeypatch.setattr(er.subprocess, "run", fake_run) - - assert er._run_repair_install(["cryptography==50.0.0"], root) is True - - flat = [part for cmd in calls for part in cmd] - assert "--break-system-packages" not in flat - assert any("ensurepip" in part for part in flat) - - -def test_externally_managed_detection(tmp_path, monkeypatch): - """The probe keys off the EXTERNALLY-MANAGED marker next to the stdlib.""" - import sysconfig - - real_get_path = sysconfig.get_path - monkeypatch.setattr( - sysconfig, - "get_path", - lambda key: str(tmp_path) if key == "stdlib" else real_get_path(key), - ) - assert er._base_interpreter_is_externally_managed() is False - (tmp_path / "EXTERNALLY-MANAGED").write_text("", encoding="utf-8") - assert er._base_interpreter_is_externally_managed() is True - - -# --------------------------------------------------------------------------- -# Pending core install (.update-incomplete) — completed BEFORE native imports -# (#83569 review: a deferred update must not re-lock itself on the next launch) -# --------------------------------------------------------------------------- - -def test_core_marker_triggers_install_before_any_native_import( - tmp_path, monkeypatch -): - """The reviewer's exact case (comment 5254279935): ``.update-incomplete`` - present, venv HEALTHY (import probes would pass). The early pass must - STILL run the core install — crucially while no native extension module - is loaded in this process — because deferring to main()'s post-import - recovery lets a recurring eager import remap the .pyd first.""" - root = _project(tmp_path) - core_marker = root / ".update-incomplete" - core_marker.write_text('{"attempts": 0}', encoding="utf-8") - - from hermes_cli import _install_repair as ir - - calls: list[dict] = [] - - def fake_install(project_root): - calls.append( - { - "root": project_root, - "native_loaded_at_call": sorted( - m for m in sys.modules if m.startswith("cryptography") - ), - } - ) - - monkeypatch.setattr(ir, "run_core_install", fake_install) - # Early recovery imports _install_repair lazily inside the helper; make - # sure the lazy import resolves to the SAME monkeypatched module object. - import hermes_cli._install_repair # noqa: F401 (pre-import for patch) - - er.recover_if_needed(project_root=root, argv=[]) - - assert len(calls) == 1, "core install must run when the marker exists" - assert calls[0]["root"] == root - assert calls[0]["native_loaded_at_call"] == [], ( - "install must run BEFORE any cryptography module is loaded " - "(that is the whole point of the early pass)" - ) - assert not core_marker.exists(), "marker cleared on success" - # And the lazy import-probe repair path must NOT also fire: - # (no probe repair attempted — cryptography is irrelevant to this branch) - - -def test_core_marker_marks_attempts_and_keeps_marker_on_install_failure( - tmp_path, monkeypatch -): - root = _project(tmp_path) - core_marker = root / ".update-incomplete" - core_marker.write_text('{"attempts": 0}', encoding="utf-8") - - from hermes_cli import _install_repair as ir - - def boom(_project_root): - raise RuntimeError("simulated install failure") - - monkeypatch.setattr(ir, "run_core_install", boom) - import hermes_cli._install_repair # noqa: F401 - - er.recover_if_needed(project_root=root, argv=[]) - - assert core_marker.exists(), "failure keeps the marker for the next try" - import json - - body = json.loads(core_marker.read_text(encoding="utf-8")) - assert body["attempts"] == 1 - # Recovery lock released even on failure (next launch may retry). - assert not (root / ".update-incomplete.lock").exists() - - -def test_core_marker_retry_ceiling_hands_off_to_late_recovery( - tmp_path, monkeypatch -): - """A persistently failing install must not reinstall-hammer every launch.""" - root = _project(tmp_path) - core_marker = root / ".update-incomplete" - core_marker.write_text( - f'{{"attempts": {er._EARLY_CORE_INSTALL_MAX_ATTEMPTS}}}', encoding="utf-8" - ) - - from hermes_cli import _install_repair as ir - - monkeypatch.setattr( - ir, - "run_core_install", - lambda _r: (_ for _ in ()).throw( - AssertionError("install must NOT run past the attempts ceiling") - ), - ) - import hermes_cli._install_repair # noqa: F401 - - er.recover_if_needed(project_root=root, argv=[]) - - assert core_marker.exists(), "marker retained for main.py's late recovery" - # Counter not bumped further by the skipped attempt. - - -def test_lazy_marker_alone_does_not_trigger_core_install(tmp_path, monkeypatch): - """Invariant guard: a lone ``.lazy-refresh-incomplete`` must NOT trigger - the core-install branch (lazy repair has its own narrow probe path and - must NEVER clear the core marker per #58004).""" - root = _project(tmp_path) - (root / ".lazy-refresh-incomplete").write_text("x", encoding="utf-8") - - from hermes_cli import _install_repair as ir - - monkeypatch.setattr( - ir, - "run_core_install", - lambda _r: (_ for _ in ()).throw( - AssertionError("core install must not run for the lazy marker") - ), - ) - import hermes_cli._install_repair # noqa: F401 - - # Healthy probes → early pass does nothing (preserves existing behavior). - monkeypatch.setattr(er, "_probe_broken_packages", lambda: []) - - er.recover_if_needed(project_root=root, argv=[]) - - -def test_core_marker_from_dead_updater_is_recovered_on_update_retry( - tmp_path, monkeypatch -): - """Retrying ``hermes update`` must consume a prior deferral marker. - - The self-lock preflight exits after writing this marker. Desktop and CLI - retries both keep ``update`` in argv, so an argv-only skip loops forever. - """ - root = _project(tmp_path) - core_marker = root / ".update-incomplete" - core_marker.write_text("started=1\npid=1234\n", encoding="utf-8") - - from hermes_cli import _install_repair as ir - - calls = [] - monkeypatch.setattr(ir, "run_core_install", lambda project_root: calls.append(project_root)) - monkeypatch.setattr(er, "_marker_owner_is_live", lambda _marker: False, raising=False) - monkeypatch.setattr(er, "_UPDATE_RETRY_RECOVERED", False) - import hermes_cli._install_repair # noqa: F401 - - er.recover_if_needed(project_root=root, argv=["update"]) - - assert calls == [root] - assert not core_marker.exists() - assert er._should_skip_external_secret_sources() is True - - -def test_core_marker_owned_by_live_updater_is_not_recovered( - tmp_path, monkeypatch -): - """A second launch must not reinstall into an active updater's venv.""" - root = _project(tmp_path) - core_marker = root / ".update-incomplete" - core_marker.write_text("started=1\npid=1234\n", encoding="utf-8") - - from hermes_cli import _install_repair as ir - - monkeypatch.setattr( - ir, - "run_core_install", - lambda _r: (_ for _ in ()).throw( - AssertionError("must not race a live updater") - ), - ) - monkeypatch.setattr(er, "_marker_owner_is_live", lambda _marker: True, raising=False) - import hermes_cli._install_repair # noqa: F401 - - er.recover_if_needed(project_root=root, argv=[]) - - assert core_marker.exists() - - -def test_bump_marker_attempts_handles_missing_and_corrupt_bodies(tmp_path): - from hermes_cli import _install_repair as ir - - m = tmp_path / ".update-incomplete" - m.write_text("", encoding="utf-8") - assert ir.bump_marker_attempts(m) == 1 - - m.write_text("not json", encoding="utf-8") - assert ir.bump_marker_attempts(m) == 1 - - m.write_text('{"attempts": 2}', encoding="utf-8") - assert ir.bump_marker_attempts(m) == 3 - - - - - - - - diff --git a/tests/hermes_cli/test_install_cua_driver.py b/tests/hermes_cli/test_install_cua_driver.py index 8e7c54298b..e65fb87081 100644 --- a/tests/hermes_cli/test_install_cua_driver.py +++ b/tests/hermes_cli/test_install_cua_driver.py @@ -37,15 +37,16 @@ def test_pip_install_drives_pm_uv_with_composed_env(tmp_path, monkeypatch): it degrades to the pre-existing pip ladder.""" import importlib import subprocess - from pathlib import Path - + from hermes_constants import venv_python_path from hermes_cli import tools_config_cua as cua pm_ensure = importlib.import_module("pm.ensure") + selected = tmp_path / "selected-venv" + monkeypatch.setattr("hermes_cli.runtime_paths.selected_venv", lambda root: selected) stub_uv = tmp_path / "uv-stub.exe" stub_uv.write_text("") - composed_env = {"VIRTUAL_ENV": str(tmp_path / "venv")} + composed_env = {"VIRTUAL_ENV": str(selected), "UV_PYTHON": "pm-base-python"} seen = {} def fake_run_text(cmd, **kwargs): @@ -64,15 +65,26 @@ def test_pip_install_drives_pm_uv_with_composed_env(tmp_path, monkeypatch): monkeypatch.setattr(pm_ensure, "uv", fake_pm_uv) result = cua._pip_install(["cua-driver"]) assert result.returncode == 0 - assert calls == [{"realize": True, "venv": Path(sys.executable).parent.parent}] - assert seen["cmd"] == [str(stub_uv), "pip", "install", "cua-driver"] + assert calls == [{"realize": True, "venv": selected}] + assert seen["cmd"] == [str(stub_uv), "pip", "install", "--python", str(selected), "cua-driver"] assert seen["env"] == composed_env # uv unavailable: probe --version succeeds via the stub, install goes to pip. monkeypatch.setattr(pm_ensure, "uv", lambda **kw: (None, {})) result = cua._pip_install(["cua-driver"]) assert result.returncode == 0 - assert seen["cmd"] == [sys.executable, "-m", "pip", "install", "cua-driver"] + assert seen["cmd"] == [str(venv_python_path(selected)), "-m", "pip", "install", "cua-driver"] + + # A PM policy/provisioning failure must be reported, not bypassed via pip. + from pm.package import InstallError + def refuse_uv(**kwargs): + raise InstallError("python", "not installed and lazy installs are disabled") + monkeypatch.setattr(pm_ensure, "uv", refuse_uv) + seen.clear() + result = cua._pip_install(["cua-driver"]) + assert result.returncode != 0 + assert "lazy installs are disabled" in result.stderr + assert not seen def _runtime_manifest(version="0.20.0", *, omit=None): diff --git a/tests/hermes_cli/test_install_progress_stream.py b/tests/hermes_cli/test_install_progress_stream.py deleted file mode 100644 index c2a4ee09c2..0000000000 --- a/tests/hermes_cli/test_install_progress_stream.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Installer progress must use the stream drained by the desktop updater.""" - -import subprocess -import sys - -import pytest - -from hermes_cli.main_install_repair import _run_install_with_heartbeat - - -@pytest.mark.parametrize("exit_code", [0, 7]) -def test_installer_stderr_streams_to_stdout(tmp_path, monkeypatch, capfd, exit_code): - import hermes_cli.main as main - - monkeypatch.setattr(main, "PROJECT_ROOT", tmp_path) - # More than a pipe buffer of progress, from a real child on the native host. - size = 256 * 1024 - cmd = [ - sys.executable, - "-c", - f"import sys; sys.stderr.write('x' * {size}); sys.stderr.flush(); sys.exit({exit_code})", - ] - if exit_code: - with pytest.raises(subprocess.CalledProcessError) as error: - _run_install_with_heartbeat(cmd) - assert error.value.returncode == exit_code - else: - _run_install_with_heartbeat(cmd) - - output = capfd.readouterr() - assert output.out == "x" * size - assert output.err == "" diff --git a/tests/hermes_cli/test_lazy_refresh_venv_repair.py b/tests/hermes_cli/test_lazy_refresh_venv_repair.py index 698b3e060c..3458aa4834 100644 --- a/tests/hermes_cli/test_lazy_refresh_venv_repair.py +++ b/tests/hermes_cli/test_lazy_refresh_venv_repair.py @@ -2,94 +2,13 @@ from __future__ import annotations -import os -import textwrap -from pathlib import Path from types import SimpleNamespace -from unittest.mock import MagicMock, patch import hermes_cli.main as m import hermes_cli.main_install_repair as hermes_cli_main_install_repair -from hermes_cli import main_install_repair -from hermes_cli import update_cmd import pytest - - - - -def test_detect_returns_none_when_probe_subprocess_fails(tmp_path, monkeypatch): - python = tmp_path / "python" - python.write_text("", encoding="utf-8") - monkeypatch.setattr( - m, "_resolve_install_target_python", lambda *a, **k: python - ) - monkeypatch.setattr( - hermes_cli_main_install_repair, "_resolve_install_target_python", lambda *a, **k: python - ) - monkeypatch.setattr( - m.subprocess, - "run", - MagicMock(side_effect=OSError("exec failed")), - ) - assert main_install_repair._detect_broken_lazy_refresh_imports(["uv", "pip"]) is None - - - - -def test_repair_runs_force_reinstall_with_pyproject_pins( - tmp_path, monkeypatch -): - pyproject = tmp_path / "pyproject.toml" - pyproject.write_text( - textwrap.dedent( - """\ - [project] - name = "fake" - version = "0.0.0" - dependencies = [ - "pyyaml==6.0.3", - "click==8.2.1", - ] - """ - ) - ) - monkeypatch.setattr(m, "PROJECT_ROOT", tmp_path) - - calls: list[list[str]] = [] - - def fake_install(cmd, **kwargs): - calls.append(cmd) - - detect_calls = {"count": 0} - - def fake_detect(prefix, *, env=None): - detect_calls["count"] += 1 - return [] - - monkeypatch.setattr(main_install_repair, "_run_package_only_install", fake_install) - monkeypatch.setattr(main_install_repair, "_detect_broken_lazy_refresh_imports", fake_detect) - - ok = main_install_repair._repair_broken_lazy_refresh_imports( - ["uv", "pip"], - ["PyYAML", "click"], - env={"VIRTUAL_ENV": str(tmp_path)}, - ) - assert ok is True - assert calls == [ - [ - "uv", - "pip", - "install", - "--force-reinstall", - "pyyaml==6.0.3", - "click==8.2.1", - ] - ] - assert detect_calls["count"] == 1 - - def test_refresh_failure_reports_pm_error(monkeypatch, capsys): import importlib ensure = importlib.import_module("pm.ensure") @@ -121,10 +40,8 @@ def test_capture_active_tool_dependencies_uses_tools_status_probes(monkeypatch): assert m._capture_active_tool_dependencies() == ["ddgs", "langfuse"] -def test_cmd_update_captures_and_propagates_pre_rebuild_snapshot( - tmp_path, monkeypatch -): - """The updater must carry pre-rebuild state into its repair refresh.""" +def test_cmd_update_repairs_before_refreshing_dependency_inputs(tmp_path, monkeypatch): + """The current-checkout repair must bypass PM's matching-stamp shortcut.""" from hermes_cli import update_cmd (tmp_path / ".git").mkdir() @@ -142,11 +59,8 @@ def test_cmd_update_captures_and_propagates_pre_rebuild_snapshot( return SimpleNamespace(returncode=0, stdout="0\n", stderr="") return SimpleNamespace(returncode=0, stdout="", stderr="") - def fake_sync(extras=None, *, explicit=False): - refresh_calls.append((sorted(extras or []), explicit)) - - def fake_sync_raises(extras=None, *, explicit=False): - refresh_calls.append((sorted(extras or []), explicit)) + def fake_sync_raises(extras=None, *, explicit=False, repair=False): + refresh_calls.append((extras, explicit, repair)) raise SyncReached monkeypatch.setattr(m, "PROJECT_ROOT", tmp_path) @@ -168,9 +82,6 @@ def test_cmd_update_captures_and_propagates_pre_rebuild_snapshot( update_cmd, "_venv_core_imports_healthy", lambda: (False, "broken") ) monkeypatch.setattr(update_cmd, "_write_update_incomplete_marker", lambda: None) - monkeypatch.setattr( - m, "_install_python_dependencies_with_optional_fallback", lambda *a, **k: None - ) # _restore_active_tool_dependencies retired (pm-clean-audit-49945b1402 item 9). monkeypatch.setattr(m.subprocess, "run", fake_run) import pm @@ -196,7 +107,5 @@ def test_cmd_update_captures_and_propagates_pre_rebuild_snapshot( with pytest.raises(SyncReached): update_cmd._cmd_update_impl(args, gateway_mode=False) - # The repair phase is one explicit pm sync carrying the pre-rebuild - # extras snapshot. Tool-dep pip restore is retired (pm-clean-audit-49945b1402 - # final-gates item 9): the staged generation owns its dependency set. - assert refresh_calls == [(sorted(["all", *snapshot]), True)] + # Repair must bypass freshness before the normal update can refresh inputs. + assert refresh_calls == [(None, False, True)] diff --git a/tests/hermes_cli/test_quarantine_noop_restore.py b/tests/hermes_cli/test_quarantine_noop_restore.py deleted file mode 100644 index 076ee35741..0000000000 --- a/tests/hermes_cli/test_quarantine_noop_restore.py +++ /dev/null @@ -1,140 +0,0 @@ -"""Regression tests for the quarantine no-op restore gap (#75584). - -On Windows, ``_run_quarantined_install`` / ``_run_install_cmd`` rename live -``hermes*.exe`` shims aside (``hermes.exe.old.``) before invoking the -installer so uv/pip can write fresh replacements. When the install SUCCEEDS -but never rewrites entry points (uv audits an already-satisfied editable -install as a no-op), the old code only restored the shims on FAILURE — the -quarantined shims stayed renamed aside and ``hermes`` vanished from PATH -after a green install. - -These tests exercise both wrapper sites with a fake installer and assert the -shims come back on every path: - - success + installer rewrote shims → fresh shims kept, .old garbage left - - success + installer wrote nothing → original shims renamed back (the bug) - - failure → original shims renamed back (as before) -""" - -from __future__ import annotations - -from pathlib import Path -from unittest.mock import patch - -import pytest - -from hermes_cli import _install_repair as ir -from hermes_cli import main_install_repair - - -def _make_scripts_dir(tmp_path: Path) -> Path: - scripts = tmp_path / "venv" / "Scripts" - scripts.mkdir(parents=True) - for name in ("hermes", "hermes-agent", "hermes-acp", "hermes-gateway"): - (scripts / f"{name}.exe").write_bytes(b"MZ-old-" + name.encode()) - return scripts - - -def _shim_names(scripts: Path) -> set[str]: - return {p.name for p in scripts.iterdir()} - - -# --------------------------------------------------------------------------- -# main_install_repair._run_quarantined_install -# --------------------------------------------------------------------------- - - -def test_main_noop_success_restores_shims(tmp_path): - """A successful install that writes no entry points must restore shims.""" - scripts = _make_scripts_dir(tmp_path) - - with patch.object(main_install_repair, "_is_windows", lambda: True), patch.object(main_install_repair, "_run_install_with_heartbeat", lambda cmd, env=None: None - ): - main_install_repair._run_quarantined_install(["fake"], scripts_dir=scripts) - - names = _shim_names(scripts) - assert "hermes.exe" in names, "hermes.exe must be restored after a no-op install" - assert "hermes-acp.exe" in names - assert "hermes-gateway.exe" in names - assert (scripts / "hermes.exe").read_bytes() == b"MZ-old-hermes" - - -def test_main_rewriting_success_keeps_fresh_shims(tmp_path): - """When the installer writes fresh shims, restore must NOT clobber them.""" - scripts = _make_scripts_dir(tmp_path) - - def fake_install(cmd, env=None): - for name in ("hermes", "hermes-agent", "hermes-acp", "hermes-gateway"): - (scripts / f"{name}.exe").write_bytes(b"MZ-new-" + name.encode()) - - with patch.object(main_install_repair, "_is_windows", lambda: True), patch.object(main_install_repair, "_run_install_with_heartbeat", fake_install - ): - main_install_repair._run_quarantined_install(["fake"], scripts_dir=scripts) - - assert (scripts / "hermes.exe").read_bytes() == b"MZ-new-hermes" - - -def test_main_failure_restores_shims_and_reraises(tmp_path): - scripts = _make_scripts_dir(tmp_path) - - def boom(cmd, env=None): - raise RuntimeError("install died") - - with patch.object(main_install_repair, "_is_windows", lambda: True), patch.object(main_install_repair, "_run_install_with_heartbeat", boom - ): - with pytest.raises(RuntimeError, match="install died"): - main_install_repair._run_quarantined_install(["fake"], scripts_dir=scripts) - - assert (scripts / "hermes.exe").read_bytes() == b"MZ-old-hermes" - - -# --------------------------------------------------------------------------- -# hermes_cli._install_repair._run_install_cmd (the deferred-recovery path) -# --------------------------------------------------------------------------- - - -def _patch_repair_windows(scripts: Path): - """Force the repair module down the Windows quarantine path.""" - return ( - patch.object(ir, "_is_windows", lambda: True), - patch.object(ir, "_venv_scripts_dir", lambda root: scripts), - ) - - -def test_repair_noop_success_restores_shims(tmp_path): - """The early-recovery install path (the #75584 report) must restore too.""" - scripts = _make_scripts_dir(tmp_path) - win, vdir = _patch_repair_windows(scripts) - - with win, vdir, patch.object(ir.subprocess, "run", lambda *a, **k: None): - ir._run_install_cmd(["fake"], env=None, root=tmp_path) - - names = _shim_names(scripts) - assert "hermes.exe" in names, "hermes.exe must be restored after a no-op recovery install" - assert (scripts / "hermes.exe").read_bytes() == b"MZ-old-hermes" - - -def test_repair_rewriting_success_keeps_fresh_shims(tmp_path): - scripts = _make_scripts_dir(tmp_path) - win, vdir = _patch_repair_windows(scripts) - - def fake_run(cmd, cwd=None, check=None, env=None): - (scripts / "hermes.exe").write_bytes(b"MZ-new-hermes") - - with win, vdir, patch.object(ir.subprocess, "run", fake_run): - ir._run_install_cmd(["fake"], env=None, root=tmp_path) - - assert (scripts / "hermes.exe").read_bytes() == b"MZ-new-hermes" - - -def test_repair_failure_restores_shims_and_reraises(tmp_path): - scripts = _make_scripts_dir(tmp_path) - win, vdir = _patch_repair_windows(scripts) - - def fake_run(cmd, cwd=None, check=None, env=None): - raise ir.subprocess.CalledProcessError(1, cmd) - - with win, vdir, patch.object(ir.subprocess, "run", fake_run): - with pytest.raises(ir.subprocess.CalledProcessError): - ir._run_install_cmd(["fake"], env=None, root=tmp_path) - - assert (scripts / "hermes.exe").read_bytes() == b"MZ-old-hermes" diff --git a/tests/hermes_cli/test_quarantine_orphan_rescue.py b/tests/hermes_cli/test_quarantine_orphan_rescue.py index 28c029cc78..28faa63e11 100644 --- a/tests/hermes_cli/test_quarantine_orphan_rescue.py +++ b/tests/hermes_cli/test_quarantine_orphan_rescue.py @@ -28,7 +28,6 @@ from unittest.mock import patch import pytest from hermes_cli import _early_recovery as er -from hermes_cli import _install_repair as ir from hermes_cli import main as cli_main from hermes_cli import main_install_repair @@ -308,12 +307,8 @@ def test_helper_is_a_noop_when_installer_wrote_a_fresh_shim(tmp_path, capsys): assert capsys.readouterr().err == "" -# --------------------------------------------------------------------------- -# both call sites route through the helper -# --------------------------------------------------------------------------- - - -def test_main_restore_reports_on_stderr(tmp_path, capsys): +def test_restore_reports_on_stderr(tmp_path, capsys): + """Restore diagnostics must not pollute a JSON-RPC stdout stream.""" scripts = _make_scripts_dir(tmp_path) quarantined = scripts / "hermes.exe.old.123" quarantined.write_bytes(b"MZ-old-hermes") @@ -323,26 +318,9 @@ def test_main_restore_reports_on_stderr(tmp_path, capsys): raise PermissionError(32, "being used by another process") with patch.object(er.os, "rename", always_locked): - main_install_repair._restore_quarantined_exes([(original, quarantined)]) - - captured = capsys.readouterr() - assert "FAILED to restore hermes.exe" in captured.err - assert captured.out == "" - - -def test_repair_restore_reports_on_stderr(tmp_path, capsys): - """The early-recovery path must warn on stderr (acp speaks JSON-RPC on stdout).""" - scripts = _make_scripts_dir(tmp_path) - quarantined = scripts / "hermes.exe.old.123" - quarantined.write_bytes(b"MZ-old-hermes") - original = scripts / "hermes.exe" - - def always_locked(src, dst): - raise PermissionError(32, "being used by another process") - - with patch.object(er.os, "rename", always_locked): - ir._restore_quarantined_exes([(original, quarantined)]) + failed = er.restore_quarantined_shims([(original, quarantined)], backoff_ms=(0,)) captured = capsys.readouterr() + assert failed == [(original, quarantined)] assert "FAILED to restore hermes.exe" in captured.err assert captured.out == "", "stdout must stay clean for JSON-RPC" diff --git a/tests/hermes_cli/test_setup.py b/tests/hermes_cli/test_setup.py index a2721a2e4d..fef3348d00 100644 --- a/tests/hermes_cli/test_setup.py +++ b/tests/hermes_cli/test_setup.py @@ -159,6 +159,7 @@ def test_modal_setup_persists_direct_mode_when_user_chooses_their_own_account(tm ), ) monkeypatch.setitem(sys.modules, "swe_rex", object()) + monkeypatch.setitem(sys.modules, "modal", types.ModuleType("modal")) from hermes_cli.setup import setup_terminal_backend diff --git a/tests/hermes_cli/test_shim_fail_closed_windows_live.py b/tests/hermes_cli/test_shim_fail_closed_windows_live.py deleted file mode 100644 index 73d9446a99..0000000000 --- a/tests/hermes_cli/test_shim_fail_closed_windows_live.py +++ /dev/null @@ -1,153 +0,0 @@ -"""LIVE Windows E2E for the fail-closed shim quarantine (#87331). - -Runs ONLY on a real Windows host (the on-demand ``windows-venv-e2e.yml`` -lane). Reproduces the REAL lock shape from the field report: a process -holding ``hermes.exe`` open WITHOUT FILE_SHARE_DELETE, exactly like a -running launcher — then proves the strict quarantine refuses before any -installer runs, and that the non-contended path still installs. - -No mocks: real files, a real child process holding a real Windows handle, -the real rename attempt hitting the real sharing violation. -""" - -from __future__ import annotations - -import os -import subprocess -import sys -import time -from pathlib import Path - -import pytest -from hermes_cli import main_install_repair - -pytestmark = pytest.mark.skipif( - sys.platform != "win32", reason="live Windows shim-lock E2E" -) - -PROJECT_ROOT = Path(__file__).resolve().parents[2] - -# Child that opens a file with GENERIC_READ and NO FILE_SHARE_DELETE — -# the exact sharing mode a running .exe image / desktop backend exhibits. -_HOLDER_CODE = r""" -import ctypes, sys, time -GENERIC_READ = 0x80000000 -FILE_SHARE_READ = 0x1 # note: NO FILE_SHARE_DELETE -OPEN_EXISTING = 3 -h = ctypes.windll.kernel32.CreateFileW( - sys.argv[1], GENERIC_READ, FILE_SHARE_READ, None, OPEN_EXISTING, 0, None -) -if h == -1 or h == 0xFFFFFFFF: - print("OPEN_FAILED", flush=True) - sys.exit(1) -print("HOLDING", flush=True) -time.sleep(120) -""" - - -@pytest.fixture() -def held_shim(tmp_path: Path): - scripts = tmp_path / "venv" / "Scripts" - scripts.mkdir(parents=True) - shim = scripts / "hermes.exe" - shim.write_bytes(b"MZ fake shim") - (scripts / "hermes-gateway.exe").write_bytes(b"MZ fake shim") - holder = subprocess.Popen( - [sys.executable, "-c", _HOLDER_CODE, str(shim)], - stdout=subprocess.PIPE, - text=True, - ) - line = holder.stdout.readline().strip() - if line != "HOLDING": - holder.kill() - pytest.fail(f"lock-holder child failed: {line!r}") - yield scripts, shim - holder.kill() - holder.wait() - - -def test_locked_shim_really_cannot_be_renamed(held_shim): - """Premise check: the no-FILE_SHARE_DELETE handle blocks rename.""" - _scripts, shim = held_shim - with pytest.raises(OSError): - os.rename(shim, shim.with_name("hermes.exe.old.premise")) - - -def test_strict_quarantine_refuses_against_real_lock(held_shim, monkeypatch): - import hermes_cli.main as cli_main - import hermes_cli.main_install_repair as hermes_cli_main_install_repair - - scripts, _shim = held_shim - install_ran: list = [] - monkeypatch.setattr( - cli_main, - "_run_install_with_heartbeat", - lambda cmd, env=None: install_ran.append(cmd), - ) - monkeypatch.setattr( - hermes_cli_main_install_repair, - "_run_install_with_heartbeat", - lambda cmd, env=None: install_ran.append(cmd), - ) - - with pytest.raises(main_install_repair.ShimQuarantineError) as exc_info: - main_install_repair._run_quarantined_install( - ["would-be", "uv", "pip", "install"], - scripts_dir=scripts, - strict_quarantine=True, - ) - - assert install_ran == [], "installer ran against a contended venv" - assert "hermes.exe" in exc_info.value.failed_shims - # The unlocked sibling's rename was rolled back — venv untouched. - assert (scripts / "hermes-gateway.exe").exists() - assert not list(scripts.glob("*.old.*")) - - -def test_recovery_installer_refuses_against_real_lock(held_shim, monkeypatch): - import hermes_cli._install_repair as ir - - scripts, _shim = held_shim - monkeypatch.setattr(ir, "_venv_scripts_dir", lambda root: scripts) - run_calls: list = [] - monkeypatch.setattr(ir.subprocess, "run", lambda *a, **k: run_calls.append(a)) - - with pytest.raises(ir.ShimQuarantineError): - ir._run_install_cmd(["fake"], env=None, root=scripts.parent.parent) - assert run_calls == [] - - -def test_release_then_strict_quarantine_succeeds(tmp_path, monkeypatch): - """After the holder exits, the same strict path proceeds normally.""" - import hermes_cli.main as cli_main - import hermes_cli.main_install_repair as hermes_cli_main_install_repair - - scripts = tmp_path / "venv" / "Scripts" - scripts.mkdir(parents=True) - (scripts / "hermes.exe").write_bytes(b"MZ fake shim") - - holder = subprocess.Popen( - [sys.executable, "-c", _HOLDER_CODE, str(scripts / "hermes.exe")], - stdout=subprocess.PIPE, - text=True, - ) - assert holder.stdout.readline().strip() == "HOLDING" - holder.kill() - holder.wait() - time.sleep(0.3) # handle teardown - - install_ran: list = [] - monkeypatch.setattr( - cli_main, - "_run_install_with_heartbeat", - lambda cmd, env=None: install_ran.append(cmd), - ) - monkeypatch.setattr( - hermes_cli_main_install_repair, - "_run_install_with_heartbeat", - lambda cmd, env=None: install_ran.append(cmd), - ) - main_install_repair._run_quarantined_install( - ["fake"], scripts_dir=scripts, strict_quarantine=True - ) - assert install_ran == [["fake"]] diff --git a/tests/hermes_cli/test_update_concurrent_quarantine.py b/tests/hermes_cli/test_update_concurrent_quarantine.py index 3e93f27dbc..b8169ab07b 100644 --- a/tests/hermes_cli/test_update_concurrent_quarantine.py +++ b/tests/hermes_cli/test_update_concurrent_quarantine.py @@ -141,52 +141,6 @@ def test_detect_concurrent_parents_call_robust_to_one_bad_hop(_winp, tmp_path): -# --------------------------------------------------------------------------- -# _quarantine_running_hermes_exe — retry, then report -# --------------------------------------------------------------------------- - - -@patch.object(main_install_repair, "_is_windows", return_value=True) -def test_quarantine_succeeds_first_attempt(_winp, tmp_path): - """When the rename works immediately, no warning, single rename pair returned.""" - shim = tmp_path / "hermes.exe" - shim.write_bytes(b"old") - - pairs = main_install_repair._quarantine_running_hermes_exe(tmp_path) - - assert len(pairs) == 1 - orig, quarantine = pairs[0] - assert orig == shim - assert quarantine.name.startswith("hermes.exe.old.") - assert quarantine.exists() - assert not shim.exists() - - -@patch.object(main_install_repair, "_is_windows", return_value=True) -def test_quarantine_reports_a_lock_it_cannot_break(_winp, tmp_path, capsys, monkeypatch): - """Every retry failed: name the likely culprits, queue nothing for reboot.""" - shim = tmp_path / "hermes.exe" - shim.write_bytes(b"locked") - - def always_fails(self, target): - raise OSError(32, "The process cannot access the file (simulated lock)") - - monkeypatch.setattr(main_install_repair, "_hermes_exe_shims", lambda d: [shim]) - with patch.object(Path, "rename", always_fails), patch( - "time.sleep", lambda *_a, **_k: None - ): - pairs = main_install_repair._quarantine_running_hermes_exe(tmp_path) - - captured = capsys.readouterr().out.lower() - - assert pairs == [] - # A clear message, not raw [WinError 32], and no reboot promise we can't keep. - assert "could not quarantine" in captured - assert "reboot" not in captured - - - - # --------------------------------------------------------------------------- # Windows gateway pause/resume before update mutation # --------------------------------------------------------------------------- diff --git a/tests/hermes_cli/test_update_interrupted_recovery.py b/tests/hermes_cli/test_update_interrupted_recovery.py index c46c84cfbd..dc937a9d34 100644 --- a/tests/hermes_cli/test_update_interrupted_recovery.py +++ b/tests/hermes_cli/test_update_interrupted_recovery.py @@ -1,18 +1,14 @@ """Tests for interrupted-install self-heal (the ``.update-incomplete`` marker). -Covers the breadcrumb lifecycle and the launch-time recovery guard added so a -``hermes update`` killed mid-install (Ctrl-C, terminal close, WSL OOM) gets -finished automatically on the next launch instead of leaving a half-built venv. +Covers the breadcrumb lifecycle. The launch-time recovery itself is now owned +by PM: ``hermes_cli/_early_recovery.recover_if_needed`` asks ``pm.recovery`` +to restore dependencies and clears markers only on success — see +tests/hermes_cli/test_early_recovery.py and tests/pm/test_recovery.py. """ from __future__ import annotations -from pathlib import Path - import hermes_cli.main as m -import hermes_cli.main_install_repair as hermes_cli_main_install_repair -from hermes_cli import main_install_repair -from hermes_cli import update_cmd def test_marker_round_trip(tmp_path, monkeypatch): @@ -29,117 +25,3 @@ def test_marker_round_trip(tmp_path, monkeypatch): m._clear_update_incomplete_marker() assert not marker.exists() - - - - - - -def _stub_install_env(monkeypatch, m, seen): - """Common stubs so recovery's install path is inert and observable.""" - import hermes_cli.main_install_repair as hermes_cli_main_install_repair - - class R: - returncode = 0 - - monkeypatch.setattr(m.subprocess, "run", lambda *a, **k: R()) - # Recovery resolves uv through the pm.ensure.uv bridge (the defining seam); - # patching the pm package re-export would not intercept it. - import importlib - - pm_ensure = importlib.import_module("pm.ensure") - monkeypatch.setattr(pm_ensure, "uv", lambda **kw: (None, {})) - # The install executor moved to hermes_cli._install_repair (shared between - # the pre-import early pass and this late recovery path) — stub WHERE it - # is executed, not the legacy main.py wrapper it replaced. - import hermes_cli._install_repair as ir - - monkeypatch.setattr( - ir, "run_core_install", lambda _root: seen.__setitem__("install", True) - ) - - -def test_recovery_self_lock_does_not_clear_core_marker_via_import_probes( - tmp_path, monkeypatch -): - # ``.update-incomplete`` is the generic core-install marker: recovery - # must run the full reinstall (#58004 review blocker). - monkeypatch.setattr(m, "PROJECT_ROOT", tmp_path) - (tmp_path / "pyproject.toml").write_text("[project]\nname='x'\n") - m._write_update_incomplete_marker() - - scripts_dir = tmp_path / "venv" / "Scripts" - scripts_dir.mkdir(parents=True) - shim = scripts_dir / "hermes.exe" - shim.write_text("") - - monkeypatch.setattr(m, "_is_windows", lambda: True) - monkeypatch.setattr(hermes_cli_main_install_repair, "_is_windows", lambda: True) - monkeypatch.setattr(m, "_venv_scripts_dir", lambda: scripts_dir) - monkeypatch.setattr(hermes_cli_main_install_repair, "_venv_scripts_dir", lambda: scripts_dir) - monkeypatch.setattr(main_install_repair, "_hermes_exe_shims", lambda d: [shim]) - monkeypatch.setattr(main_install_repair, "_default_venv_install_target", - lambda: (["uv", "pip"], {"VIRTUAL_ENV": str(tmp_path / "venv")}), - ) - monkeypatch.setattr( - m, "_repair_venv_via_import_probes", lambda *a, **k: "healthy" - ) - monkeypatch.setattr( - hermes_cli_main_install_repair, "_repair_venv_via_import_probes", lambda *a, **k: "healthy" - ) - - class FakeProc: - def __init__(self, exe_path): - self._exe = exe_path - - def exe(self): - return self._exe - - def parents(self): - return [FakeProc(str(shim))] - - monkeypatch.setattr("psutil.Process", lambda: FakeProc(sys_executable_path())) - - seen = {"install": False} - _stub_install_env(monkeypatch, m, seen) - - m._recover_from_interrupted_install() - - assert seen["install"] is True, "core marker still requires full reinstall" - assert not m._update_marker_path().exists(), "cleared only after full reinstall" - - - - -def sys_executable_path(): - import sys - - return sys.executable - - -def test_default_venv_install_target_follows_pm_uv_seam(tmp_path, monkeypatch): - """``_default_venv_install_target`` resolves uv through ``pm.ensure.uv`` (the - defining seam, not the ``pm.uv`` re-export) and keeps the pre-existing pip - fallback — ``[sys.executable, -m pip]`` with no env — when pm cannot supply - one. Contract, not source shape: both arms run the real function.""" - import importlib - import sys - - monkeypatch.setattr(m, "PROJECT_ROOT", tmp_path) - pm_ensure = importlib.import_module("pm.ensure") - - stub_uv = tmp_path / "uv-stub.exe" - stub_uv.write_text("") - monkeypatch.setattr(pm_ensure, "uv", lambda **kw: (str(stub_uv), {})) - prefix, env = main_install_repair._default_venv_install_target() - assert prefix == [str(stub_uv), "pip"] - # project_venv_dir(tmp_path) is None (no venv/ or .venv/), so the pre-existing - # fallback layout names tmp_path/venv; VIRTUAL_ENV stays the seam's own env job. - assert env["VIRTUAL_ENV"] == str(tmp_path / "venv") - - monkeypatch.setattr(pm_ensure, "uv", lambda **kw: (None, {})) - prefix, env = main_install_repair._default_venv_install_target() - assert prefix == [sys.executable, "-m", "pip"] - assert env is None - - diff --git a/tests/hermes_cli/test_update_shim_fail_closed.py b/tests/hermes_cli/test_update_shim_fail_closed.py deleted file mode 100644 index 5f376820d5..0000000000 --- a/tests/hermes_cli/test_update_shim_fail_closed.py +++ /dev/null @@ -1,210 +0,0 @@ -"""Fail-closed shim quarantine (#87331): a contended venv is never mutated. - -The bug class: on Windows, when `hermes.exe`/sibling shims could not be -renamed aside (another process holds them without FILE_SHARE_DELETE), the -updater printed a warning and ran the installer anyway — which then died -partway on the same locks and stranded the venv between versions (3x field -reports on one machine, #87331). - -Contract pinned here: -- `_run_quarantined_install(strict_quarantine=True)` raises - ShimQuarantineError BEFORE running any install command, and rolls back the - renames that did succeed. -- Non-strict callers keep the old warn-and-try behavior. -- The recovery installer's `_run_install_cmd` is strict unconditionally. -- The update boundary turns the error into a refusal (exit 2) + marker, - never a ZIP fallback. -""" - -import os -import sys -from pathlib import Path -from unittest import mock - -import pytest - -from hermes_cli import main_install_repair -import hermes_cli._install_repair as ir -import hermes_cli.update_cmd as update_cmd - - -def _make_shims(scripts_dir: Path, names=("hermes", "hermes-gateway")) -> list[Path]: - scripts_dir.mkdir(parents=True, exist_ok=True) - shims = [] - for name in names: - p = scripts_dir / f"{name}.exe" - p.write_bytes(b"MZ fake") - shims.append(p) - return shims - - -@pytest.fixture() -def windows(monkeypatch): - monkeypatch.setattr(main_install_repair, "_is_windows", lambda: True) - monkeypatch.setattr(ir, "_is_windows", lambda: True) - - -# --------------------------------------------------------------------------- -# main.py: _run_quarantined_install strict mode -# --------------------------------------------------------------------------- - -def test_strict_quarantine_refuses_before_install(windows, tmp_path, monkeypatch): - scripts = tmp_path / "venv" / "Scripts" - shims = _make_shims(scripts) - # hermes.exe cannot be renamed; hermes-gateway.exe can - real_rename = Path.rename - - def deny_hermes(self, target): - if self.name == "hermes.exe": - raise PermissionError(13, "held open") - return real_rename(self, target) - - monkeypatch.setattr(Path, "rename", deny_hermes) - monkeypatch.setattr(main_install_repair, "_hermes_exe_shims", lambda d: shims) - - install_ran = [] - monkeypatch.setattr( - main_install_repair, "_run_install_with_heartbeat", - lambda cmd, env=None: install_ran.append(cmd), - ) - - with pytest.raises(main_install_repair.ShimQuarantineError) as exc_info: - main_install_repair._run_quarantined_install( - ["uv", "pip", "install", "-e", "."], - scripts_dir=scripts, - strict_quarantine=True, - ) - - # The installer NEVER ran — that is the whole fix. - assert install_ran == [] - assert "hermes.exe" in exc_info.value.failed_shims - # The successful rename (hermes-gateway.exe) was rolled back. - assert (scripts / "hermes-gateway.exe").exists() - assert not list(scripts.glob("hermes-gateway.exe.old.*")) - - -def test_non_strict_keeps_warn_and_try(windows, tmp_path, monkeypatch): - scripts = tmp_path / "venv" / "Scripts" - shims = _make_shims(scripts, names=("hermes",)) - monkeypatch.setattr( - Path, "rename", - mock.Mock(side_effect=PermissionError(13, "held open")), - ) - monkeypatch.setattr(main_install_repair, "_hermes_exe_shims", lambda d: shims) - - install_ran = [] - monkeypatch.setattr( - main_install_repair, "_run_install_with_heartbeat", - lambda cmd, env=None: install_ran.append(cmd), - ) - - # Default (non-strict): installer still runs — old behavior for repair - # paths whose venv is already mutated. - main_install_repair._run_quarantined_install(["fake"], scripts_dir=scripts) - assert install_ran == [["fake"]] - - -def test_strict_all_renames_ok_runs_install(windows, tmp_path, monkeypatch): - scripts = tmp_path / "venv" / "Scripts" - shims = _make_shims(scripts) - monkeypatch.setattr(main_install_repair, "_hermes_exe_shims", lambda d: shims) - - install_ran = [] - monkeypatch.setattr( - main_install_repair, "_run_install_with_heartbeat", - lambda cmd, env=None: install_ran.append(cmd), - ) - main_install_repair._run_quarantined_install( - ["fake"], scripts_dir=scripts, strict_quarantine=True - ) - assert install_ran == [["fake"]] - - -def test_update_sync_installs_are_strict(windows, tmp_path, monkeypatch): - """_install_python_dependencies_with_optional_fallback must pass - strict_quarantine=True — the #87331 site.""" - seen = {} - - def spy(cmd, *, env=None, scripts_dir=None, strict_quarantine=False): - seen["strict"] = strict_quarantine - - monkeypatch.setattr(main_install_repair, "_run_quarantined_install", spy) - monkeypatch.setattr(main_install_repair, "_venv_scripts_dir", lambda: tmp_path) - monkeypatch.setattr( - main_install_repair, "_verify_console_scripts_installed", - lambda prefix, env=None: None, - ) - monkeypatch.setattr( - main_install_repair, "_verify_core_dependencies_installed", - lambda prefix, env=None, group="all": None, - ) - main_install_repair._install_python_dependencies_with_optional_fallback(["uv", "pip"]) - assert seen["strict"] is True - - -# --------------------------------------------------------------------------- -# _install_repair.py: recovery installer is strict unconditionally -# --------------------------------------------------------------------------- - -def test_recovery_install_cmd_fail_closed(windows, tmp_path, monkeypatch): - root = tmp_path - scripts = root / "venv" / "Scripts" - _make_shims(scripts, names=("hermes",)) - - monkeypatch.setattr(ir, "_venv_scripts_dir", lambda r: scripts) - monkeypatch.setattr( - Path.__module__ and os, "rename", - mock.Mock(side_effect=PermissionError(13, "held open")), - ) - - run_calls = [] - monkeypatch.setattr( - ir.subprocess, "run", lambda *a, **k: run_calls.append(a) - ) - - with pytest.raises(ir.ShimQuarantineError): - ir._run_install_cmd(["fake"], env=None, root=root) - assert run_calls == [] # contended venv never mutated - - -def test_recovery_install_cmd_ok_when_uncontended(windows, tmp_path, monkeypatch): - root = tmp_path - scripts = root / "venv" / "Scripts" - _make_shims(scripts, names=("hermes",)) - monkeypatch.setattr(ir, "_venv_scripts_dir", lambda r: scripts) - - run_calls = [] - monkeypatch.setattr( - ir.subprocess, "run", - lambda *a, **k: run_calls.append(a) or mock.Mock(returncode=0), - ) - ir._run_install_cmd(["fake"], env=None, root=root) - assert len(run_calls) == 1 - - -# --------------------------------------------------------------------------- -# update_cmd.py: boundary refusal — marker + exit 2, no ZIP fallback -# --------------------------------------------------------------------------- - -def test_refusal_writes_marker_and_exits_2(monkeypatch, capsys): - wrote = [] - monkeypatch.setattr( - update_cmd, "_write_update_incomplete_marker", lambda: wrote.append(1) - ) - exc = main_install_repair.ShimQuarantineError(["hermes.exe"]) - with pytest.raises(SystemExit) as exit_info: - update_cmd._refuse_update_for_contended_shims(exc) - assert exit_info.value.code == 2 - assert wrote == [1] - out = capsys.readouterr().out - assert "hermes.exe" in out - assert "deferred" in out - - -def test_shim_error_type_resolves_real_class(): - assert update_cmd._shim_quarantine_error_type() is main_install_repair.ShimQuarantineError - - -def test_shim_error_is_not_a_zip_fallback_trigger(): - exc = main_install_repair.ShimQuarantineError(["hermes.exe"]) - assert update_cmd._should_zip_fallback_on_update_error(exc) is False diff --git a/tests/hermes_cli/test_update_stale_virtualenv.py b/tests/hermes_cli/test_update_stale_virtualenv.py deleted file mode 100644 index d333c9afa6..0000000000 --- a/tests/hermes_cli/test_update_stale_virtualenv.py +++ /dev/null @@ -1,114 +0,0 @@ -"""Test: _install_python_dependencies_with_optional_fallback with stale VIRTUAL_ENV. - -Simulates the real crash: a pip/system-Python install where PROJECT_ROOT is -site-packages and VIRTUAL_ENV=PROJECT_ROOT/venv does not exist. -""" -import os -import sys -import unittest -from pathlib import Path -from unittest import mock - -import hermes_cli.main as main_mod -from hermes_cli import main_install_repair - - -class StaleVirtualEnvTest(unittest.TestCase): - def _call(self, uv_cmd, venv_path, fake_executable, is_windows=False): - """Run the function with a mocked uv/env and capture the subprocess call.""" - captured = [] - - def fake_quarantine(cmd, *, env=None, scripts_dir=None, strict_quarantine=False): - captured.append((list(cmd), dict(env or {}), scripts_dir)) - return None - - def fake_verify(prefix, *, env=None): - return None - - with mock.patch.object(main_install_repair, "_run_quarantined_install", fake_quarantine), \ - mock.patch.object(main_install_repair, "_verify_console_scripts_installed", fake_verify), \ - mock.patch.object(main_install_repair, "_venv_scripts_dir", return_value=None), \ - mock.patch.object(main_install_repair, "_is_windows", return_value=is_windows), \ - mock.patch.object(main_install_repair.sys, "executable", fake_executable), \ - mock.patch.object(main_mod, "PROJECT_ROOT", Path("/fake/project")): - main_install_repair._install_python_dependencies_with_optional_fallback( - list(uv_cmd), - env={"VIRTUAL_ENV": str(venv_path)}, - group="all", - ) - return captured - - def test_stale_virtualenv_pins_python(self): - """VIRTUAL_ENV points at a nonexistent venv -> --python sys.executable.""" - captured = self._call( - uv_cmd=[Path("/fake/uv"), "pip"], - venv_path=Path("/fake/project/venv"), # does not exist - fake_executable="/fake/python311/python.exe", - ) - self.assertTrue(captured, "no subprocess call captured") - cmd, env, _ = captured[0] - # --python must come after 'install': uv pip install --python ... - self.assertIn("install", cmd) - self.assertIn("--python", cmd) - self.assertEqual(cmd[cmd.index("--python") + 1], "/fake/python311/python.exe") - # VIRTUAL_ENV removed from env - self.assertNotIn("VIRTUAL_ENV", env) - - def test_existing_virtualenv_keeps_env(self): - """VIRTUAL_ENV points at an existing venv -> unchanged, no --python.""" - real_venv = Path(sys.executable).resolve().parent.parent - if not real_venv.is_dir(): - self.skipTest("no real venv available in this test run") - captured = self._call( - uv_cmd=[Path("/fake/uv"), "pip"], - venv_path=real_venv, - fake_executable=sys.executable, - ) - cmd, env, _ = captured[0] - self.assertNotIn("--python", cmd) - self.assertEqual(env.get("VIRTUAL_ENV"), str(real_venv)) - - def test_python_dash_m_uv_is_detected(self): - """python -m uv must also trigger the pin (naive basename check misses it).""" - captured = self._call( - uv_cmd=[Path("/fake/python"), "-m", "uv", "pip"], - venv_path=Path("/fake/project/venv"), # does not exist - fake_executable="/fake/python311/python.exe", - ) - self.assertTrue(captured, "no subprocess call captured") - cmd, _, _ = captured[0] - self.assertIn("--python", cmd) - self.assertEqual(cmd[cmd.index("--python") + 1], "/fake/python311/python.exe") - - def test_existing_python_flag_wins(self): - """A caller-supplied --python is not duplicated by the pin.""" - captured = self._call( - uv_cmd=[Path("/fake/uv"), "pip"], - venv_path=Path("/fake/project/venv"), - fake_executable="/fake/python311/python.exe", - ) - # Force the caller path through a manual pin with a pre-existing flag. - args = ["install", "--python", "/caller/choice/python.exe", "hermes"] - pinned = main_install_repair._insert_python_pin(args) - self.assertEqual(pinned, args, "existing --python must win") - self.assertEqual(pinned.count("--python"), 1) - - def test_windows_pins_quarantine_to_interpreter_scripts_dir(self): - """On Windows with a missing project venv, quarantine must target the - interpreter's Scripts dir (where the shims actually live), not None.""" - fake_scripts = Path("/fake/python311/Scripts") - with mock.patch.object(main_install_repair, "_interpreter_scripts_dir", return_value=fake_scripts - ): - captured = self._call( - uv_cmd=[Path("/fake/uv"), "pip"], - venv_path=Path("/fake/project/venv"), - fake_executable="/fake/python311/python.exe", - is_windows=True, - ) - self.assertTrue(captured, "no subprocess call captured") - _, _, scripts_dir = captured[0] - self.assertEqual(scripts_dir, fake_scripts) - - -if __name__ == "__main__": - unittest.main(verbosity=2) diff --git a/tests/hermes_cli/test_update_zip_two_phase.py b/tests/hermes_cli/test_update_zip_two_phase.py index 79a1fcb857..e2c40f2970 100644 --- a/tests/hermes_cli/test_update_zip_two_phase.py +++ b/tests/hermes_cli/test_update_zip_two_phase.py @@ -286,23 +286,6 @@ def test_venv_helpers_honour_an_explicit_platform_verdict(): ) -def test_patched_is_windows_reaches_the_venv_path_derivation(): - """End-to-end: patching the module predicate must change the derived path.""" - from unittest.mock import patch - - from hermes_cli import main as hermes_main - - with patch.object(hermes_main, "_is_windows", return_value=True): - got = hermes_main._resolve_install_target_python( - ["uv", "pip"], env={"VIRTUAL_ENV": "/nope/venv"} - ) - # The path doesn't exist so we get None, but the *derivation* must have - # used the Windows layout -- assert that directly. - assert got is None - assert ( - venv_python_path("/nope/venv", windows=True).as_posix() - == "/nope/venv/Scripts/python.exe" - ) # --------------------------------------------------------------------------- diff --git a/tests/hermes_cli/test_verify_console_scripts.py b/tests/hermes_cli/test_verify_console_scripts.py index f7106d10c5..74346d3c4a 100644 --- a/tests/hermes_cli/test_verify_console_scripts.py +++ b/tests/hermes_cli/test_verify_console_scripts.py @@ -1,10 +1,8 @@ -"""Tests for _verify_console_scripts_installed (issue #52931).""" +"""Orphan launcher discovery follows the declared console script names.""" from __future__ import annotations import textwrap -from pathlib import Path -from unittest.mock import patch import pytest from hermes_cli import main_install_repair @@ -40,30 +38,13 @@ def fake_scripts_dir(tmp_path): return scripts -class TestVerifyConsoleScriptsInstalled: - def test_no_action_when_all_shims_present(self, temp_pyproject, fake_scripts_dir): - for name in ("hermes", "hermes-agent", "hermes-acp"): - (fake_scripts_dir / f"{name}.exe").write_bytes(b"fake") +class TestHermesExeShims: + """The orphan sweep includes declared scripts and the legacy gateway shim.""" - with patch("hermes_cli.main_install_repair._is_windows", return_value=True), \ - patch("hermes_cli.main_install_repair._venv_scripts_dir", return_value=fake_scripts_dir), \ - patch("hermes_cli.main_install_repair._run_quarantined_install") as mock_install: - from hermes_cli.main_install_repair import _verify_console_scripts_installed - - _verify_console_scripts_installed(["uv", "pip"], env={}) - - mock_install.assert_not_called() - - - - - def test_quarantine_shims_include_declared_console_scripts( + def test_shims_include_declared_console_scripts( self, temp_pyproject, fake_scripts_dir ): - import hermes_cli.main as main_mod - - with patch("hermes_cli.main_install_repair._is_windows", return_value=True): - names = {path.name for path in main_install_repair._hermes_exe_shims(fake_scripts_dir)} + names = {path.name for path in main_install_repair._hermes_exe_shims(fake_scripts_dir)} assert {"hermes.exe", "hermes-agent.exe", "hermes-acp.exe"} <= names assert "hermes-gateway.exe" in names diff --git a/tests/hermes_cli/test_verify_core_dependencies.py b/tests/hermes_cli/test_verify_core_dependencies.py deleted file mode 100644 index 4e413e7e8f..0000000000 --- a/tests/hermes_cli/test_verify_core_dependencies.py +++ /dev/null @@ -1,156 +0,0 @@ -"""Tests for _verify_core_dependencies_installed. - -Regression coverage for the partial-install bug where uv's incremental -resolver silently failed to land ``pathspec`` (and similar newly-added -base deps) during ``hermes update``, leaving the venv in a broken state -that only surfaced hours later when a downstream subprocess imported the -missing module. - -The verification step: - 1. Reads pyproject.toml's [project.dependencies] directly. - 2. Filters by environment markers so cross-platform exclusions don't - false-positive (e.g. ``ptyprocess ; sys_platform != 'win32'`` on Windows). - 3. Probes ``importlib.metadata.version()`` in the venv interpreter. - 4. Reinstalls with --reinstall, then per-package, if anything's missing. -""" - -from __future__ import annotations - -import subprocess -import sys -import textwrap -from pathlib import Path -from unittest.mock import MagicMock, patch - -import pytest - - -@pytest.fixture -def temp_pyproject(tmp_path, monkeypatch): - """Point hermes_cli.main.PROJECT_ROOT at a tmp dir with a minimal pyproject. - - The verification helper opens ``PROJECT_ROOT / 'pyproject.toml'`` directly; - redirecting PROJECT_ROOT keeps the test hermetic. - """ - pyproject = tmp_path / "pyproject.toml" - pyproject.write_text(textwrap.dedent("""\ - [project] - name = "fake" - version = "0.0.0" - dependencies = [ - "pathspec==1.1.1", - "pydantic==2.13.4", - "ptyprocess>=0.7.0,<1; sys_platform != 'win32'", - "tzdata>=2024.1; sys_platform == 'win32'", - ] - """)) - import hermes_cli.main as main_mod - monkeypatch.setattr(main_mod, "PROJECT_ROOT", tmp_path) - return tmp_path - - -@pytest.fixture -def fake_venv_python(tmp_path): - """Create a fake venv python shim path that exists on disk.""" - venv_root = tmp_path / "venv" - scripts = venv_root / "Scripts" - scripts.mkdir(parents=True) - py = scripts / "python.exe" - py.write_text("#!/bin/sh\necho fake python") - return py, venv_root - - -class TestVerifyCoreDependencies: - - - - def test_skips_deps_excluded_by_environment_markers(self, temp_pyproject, fake_venv_python): - """A dep whose ``sys_platform`` marker excludes THIS host must not be - probed (and so never reported missing). Without marker evaluation the - verification step would false-positive on every cross-platform - exclusion and chase its tail installing something inapplicable here. - - Deliberately host-invariant rather than ``platforms("windows")``: the subject - is ``packaging``'s marker *evaluation*, not any OS facility. The - pyproject fixture declares one dep gated to non-Windows and one gated - to Windows, so exactly one of the pair is filtered on any host — the - old ``patch("sys.platform", "win32")`` bought nothing but a fake host. - """ - py, venv_root = fake_venv_python - env = {"VIRTUAL_ENV": str(venv_root)} - captured_argv: list[list[str]] = [] - - def fake_subprocess_run(cmd, **kwargs): - captured_argv.append(list(cmd)) - return MagicMock(returncode=0, stdout="", stderr="") - - with patch("hermes_cli.main_install_repair._resolve_install_target_python", return_value=py), \ - patch("hermes_cli.main_install_repair.subprocess.run", side_effect=fake_subprocess_run), \ - patch("hermes_cli.main_install_repair._run_install_with_heartbeat"): - - from hermes_cli.main_install_repair import _verify_core_dependencies_installed - _verify_core_dependencies_installed(["uv", "pip"], env=env) - - # Find the probe argv — it's the call that passed the dep names. - probe = next( - (argv for argv in captured_argv if any("importlib.metadata" in str(a) for a in argv)), - None, - ) - assert probe is not None, "verification probe should have run" - # The dep names are tacked on after the -c script. Exactly one of the - # marker-gated pair applies to this host; the other must be filtered. - on_windows = sys.platform == "win32" - assert ("ptyprocess" in probe) is not on_windows, ( - "ptyprocess is gated by sys_platform != 'win32', so it must be " - f"probed off Windows and filtered on it; probe argv was: {probe}" - ) - assert ("tzdata" in probe) is on_windows, ( - "tzdata is gated by sys_platform == 'win32', so it must be probed " - f"on Windows and filtered elsewhere; probe argv was: {probe}" - ) - assert "pathspec" in probe, "core deps without markers must be checked" - - def test_no_pyproject_is_noop(self, tmp_path, monkeypatch): - """If pyproject.toml is missing (unusual but possible in some test - envs), the verification step must short-circuit, not crash.""" - import hermes_cli.main as main_mod - monkeypatch.setattr(main_mod, "PROJECT_ROOT", tmp_path) - # No pyproject.toml in tmp_path. - with patch("hermes_cli.main_install_repair._resolve_install_target_python") as mock_resolve, \ - patch("hermes_cli.main_install_repair._run_install_with_heartbeat") as mock_install: - from hermes_cli.main_install_repair import _verify_core_dependencies_installed - _verify_core_dependencies_installed(["uv", "pip"], env={}) - assert not mock_resolve.called - assert not mock_install.called - - - -class TestResolveInstallTargetPython: - def test_uses_virtual_env_from_environment(self, tmp_path): - """When VIRTUAL_ENV is set, the verification step must probe THAT - venv's interpreter — not the outer Python that drove `hermes update`. - If we probed sys.executable instead, we'd false-positive every dep - the outer interpreter happens to lack.""" - venv_root = tmp_path / "newvenv" - scripts = venv_root / "Scripts" - scripts.mkdir(parents=True) - py = scripts / "python.exe" - py.write_text("fake") - - with patch("hermes_cli.main_install_repair._is_windows", return_value=True): - from hermes_cli.main_install_repair import _resolve_install_target_python - result = _resolve_install_target_python( - ["uv", "pip"], env={"VIRTUAL_ENV": str(venv_root)} - ) - assert result == py - - def test_returns_none_when_venv_python_missing(self, tmp_path): - """If the path we'd point at doesn't exist (uv install failed before - the python shim landed), return None so the verification step - cleanly short-circuits instead of crashing on FileNotFoundError.""" - with patch("hermes_cli.main_install_repair._is_windows", return_value=True): - from hermes_cli.main_install_repair import _resolve_install_target_python - result = _resolve_install_target_python( - ["uv", "pip"], env={"VIRTUAL_ENV": str(tmp_path / "does_not_exist")} - ) - assert result is None diff --git a/tests/plugins/memory/test_hindsight_embedded_runtime.py b/tests/plugins/memory/test_hindsight_embedded_runtime.py index d8513b9920..e58115347c 100644 --- a/tests/plugins/memory/test_hindsight_embedded_runtime.py +++ b/tests/plugins/memory/test_hindsight_embedded_runtime.py @@ -48,14 +48,14 @@ def test_ensure_sideenv_builds_and_publishes_generation(side_root, monkeypatch): def fake_bridge(venv): calls.append(("bridge", venv)) - return "uv-bin", {"VIRTUAL_ENV": str(venv)} + return "uv-bin", {"VIRTUAL_ENV": str(venv), "UV_PYTHON": "pm-python"} - def fake_run(uv_bin, env, args, timeout): - calls.append(("run", [uv_bin, *args], env)) + def fake_run(cmd, **kwargs): + calls.append(("run", cmd, kwargs["env"])) return _fake_completed() monkeypatch.setattr(rt, "_uv_bridge", fake_bridge) - monkeypatch.setattr(rt, "_run_uv", fake_run) + monkeypatch.setattr(rt.subprocess, "run", fake_run) gen = rt.ensure_sideenv() @@ -70,6 +70,7 @@ def test_ensure_sideenv_builds_and_publishes_generation(side_root, monkeypatch): runs = [c for c in calls if c[0] == "run"] assert [c[1][1] for c in runs] == ["lock", "sync"] assert all(c[2]["VIRTUAL_ENV"].endswith(".venv") for c in runs) + assert all(c[2]["UV_PYTHON"] == "pm-python" for c in runs) assert calls[0][1] == gen / ".venv" diff --git a/tests/pm/test_plugin_survival_contract.py b/tests/pm/test_plugin_survival_contract.py index e5a541abc4..2a1a334103 100644 --- a/tests/pm/test_plugin_survival_contract.py +++ b/tests/pm/test_plugin_survival_contract.py @@ -168,10 +168,11 @@ def admission_env(tmp_path, monkeypatch): monkeypatch.setattr(ws.paths, "repo_root", lambda: core) monkeypatch.setattr(ensure, "lazy_installs_allowed", lambda: True) monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "tools")) - # pm's store uv realization is a package-manager concern; the store - # is empty under tmp, so pin the resolution to the PATH uv (same - # precedence _uv_binary applies on dev machines). - monkeypatch.setattr(ensure, "uv", lambda *a, **k: (shutil.which("uv"), os.environ.copy())) + # Keep the real dependency transaction. Substitute only tool provisioning. + from pm.packages import uv_env + monkeypatch.setattr(ensure, "uv", lambda **kwargs: ( + shutil.which("uv"), {**uv_env(kwargs.get("base_env")), "UV_PYTHON": sys.executable}, + )) return tmp_path, home diff --git a/tests/pm/test_plugins_state.py b/tests/pm/test_plugins_state.py index a2d4e02c98..f541891cd0 100644 --- a/tests/pm/test_plugins_state.py +++ b/tests/pm/test_plugins_state.py @@ -50,6 +50,18 @@ def test_enabled_plugins_ordered_reads_all_homes(homes): assert by_root.get(profile_home / "plugins") == ["c-plug"] +def test_missing_config_parser_is_not_an_empty_plugin_selection(homes, monkeypatch): + import sys + + default_home, _ = homes + _write_config(default_home, ["keep-plug"]) + before = (default_home / "config.yaml").read_bytes() + monkeypatch.setitem(sys.modules, "utils", None) + with pytest.raises(ImportError): + pstate.enabled_plugins_ordered() + assert (default_home / "config.yaml").read_bytes() == before + + def test_enabled_list_preserves_config_order(homes): default_home, _ = homes # NOT alphabetical: recency order must survive the read diff --git a/tests/pm/test_recovery.py b/tests/pm/test_recovery.py new file mode 100644 index 0000000000..3426c752a2 --- /dev/null +++ b/tests/pm/test_recovery.py @@ -0,0 +1,116 @@ +"""PM repair replays the selected dependency graph without broken app imports.""" +from __future__ import annotations + +import importlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys + +import pytest + +from pm.lock import Facts +from pm.packages import uv_env +from tests.pm.test_workspace_build_inputs import _wheel + + +@pytest.mark.parametrize("failure", [None, "missing_lock", "corrupt_facts", "empty_environment", "missing_extras", "validation", "publication"]) +def test_repair_restores_recorded_plugin_dependencies_without_config(tmp_path, monkeypatch, failure): + import pm.paths as paths + import pm.workspace as workspace + from hermes_cli.runtime_paths import selected_venv, site_packages + + engine = importlib.import_module("pm.ensure") + uv = shutil.which("uv") + assert uv, "recovery integration requires real uv" + core = tmp_path / "core" + core.mkdir() + wheels = tmp_path / "wheels" + wheels.mkdir() + _wheel(wheels, "core_dep", "1.0") + _wheel(wheels, "plugin_dep", "1.0") + (core / "pyproject.toml").write_text( + '[project]\nname="repair-core"\nversion="1"\nrequires-python=">=3.11"\n' + 'dependencies=["core-dep==1.0"]\n[project.optional-dependencies]\nall=[]\n' + '[tool.uv]\npackage=false\nno-index=true\n' + f'find-links=[{json.dumps(wheels.as_posix())}]\n', encoding="utf-8", + ) + plugin = tmp_path / "plugin" + plugin.mkdir() + (plugin / "pyproject.toml").write_text( + '[project]\nname="repair-plugin"\nversion="1"\nrequires-python=">=3.11"\n' + 'dependencies=["plugin-dep==1.0"]\n[tool.uv]\npackage=false\n', encoding="utf-8", + ) + monkeypatch.setattr(paths, "repo_root", lambda: core) + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home")) + monkeypatch.setattr(engine, "uv", lambda **kwargs: ( + uv, {**uv_env(kwargs.get("base_env")), "UV_PYTHON": sys.executable, "UV_OFFLINE": "1"}, + )) + monkeypatch.setattr(engine, "lazy_installs_allowed", lambda: True) + monkeypatch.setattr(workspace, "enabled_member_dirs", lambda: [plugin]) + # The committed core lock is independent of the plugin union. + env = {**uv_env(), "UV_PYTHON": sys.executable, "UV_OFFLINE": "1"} + env.pop("UV_NO_CONFIG") + subprocess.run([uv, "lock"], cwd=core, env=env, capture_output=True, check=True, timeout=60) + engine.sync_venv([], explicit=True) + old = selected_venv(core) + old_fact = Facts(paths.runtime_facts_path()).get("venv") + old_lock = Path(old_fact["resolved_lock"]).read_bytes() + config = tmp_path / "home" / "config.yaml" + config.write_bytes(b"plugins:\n enabled: [repair-plugin]\n") + config_before = config.read_bytes() + shutil.rmtree(site_packages(old) / "core_dep") + shutil.rmtree(site_packages(old) / "plugin_dep") + + def broken_config(*args, **kwargs): + raise AssertionError("repair must use the recorded graph, not parse config") + monkeypatch.setattr(engine, "lazy_installs_allowed", broken_config) + monkeypatch.setattr(workspace, "enabled_member_dirs", broken_config) + if failure: + from pm import recovery + from pm.package import InstallError + + if failure == "corrupt_facts": + paths.runtime_facts_path().write_text("invalid recorded state", encoding="utf-8") + elif failure in {"empty_environment", "missing_extras"}: + data = json.loads(paths.runtime_facts_path().read_text(encoding="utf-8")) + recorded = data["packages"]["venv"] + if failure == "empty_environment": + recorded["environment"] = "" + else: + recorded.pop("extras") + paths.runtime_facts_path().write_text(json.dumps(data), encoding="utf-8") + old_facts = paths.runtime_facts_path().read_bytes() + def fail(*args, **kwargs): + raise InstallError("venv", "injected validation or publication failure") + if failure == "missing_lock": + Path(old_fact["resolved_lock"]).unlink() + elif failure == "validation": + monkeypatch.setattr(recovery, "validate_environment", fail) + elif failure == "publication": + monkeypatch.setattr(Facts, "record_state", fail) + with pytest.raises((InstallError, ValueError)): + engine.sync_venv(repair=True) + assert paths.runtime_facts_path().read_bytes() == old_facts + if failure not in {"corrupt_facts", "empty_environment"}: + assert selected_venv(core) == old + assert config.read_bytes() == config_before + assert old.is_dir() + return + + engine.sync_venv(repair=True) + restored = selected_venv(core) + assert restored != old + python = restored / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + result = subprocess.run( + [str(python), "-I", "-c", "import core_dep, plugin_dep; print(core_dep.__version__, plugin_dep.__version__)"], + cwd=tmp_path, capture_output=True, text=True, check=True, timeout=30, + ) + assert result.stdout.strip() == "1.0 1.0" + new_fact = Facts(paths.runtime_facts_path()).get("venv") + assert new_fact["stamp"] == old_fact["stamp"] + assert Path(new_fact["resolved_lock"]).read_bytes() == old_lock + assert old.is_dir() and not (site_packages(old) / "plugin_dep").exists() + assert config.read_bytes() == config_before diff --git a/tests/pm/test_recovery_validation.py b/tests/pm/test_recovery_validation.py new file mode 100644 index 0000000000..f56e47d524 --- /dev/null +++ b/tests/pm/test_recovery_validation.py @@ -0,0 +1,48 @@ +"""PM validates real imports before it publishes a recovered dependency tree.""" +from __future__ import annotations + +import importlib +import importlib.metadata +import os +import shutil +import sys + +import pytest + +from pm.package import InstallError +from pm.packages import uv_env +from pm.recovery import validate_environment + + +@pytest.mark.parametrize("damage", ["module", "distribution"]) +def test_validation_rejects_a_missing_required_import(tmp_path, monkeypatch, damage): + import pm.paths as paths + from hermes_cli.runtime_paths import site_packages + + core = tmp_path / "core" + core.mkdir() + version = importlib.metadata.version("python-dotenv") + (core / "pyproject.toml").write_text( + '[project]\nname="validation-proof"\nversion="1"\nrequires-python=">=3.11"\n' + f'dependencies=["python-dotenv=={version}"]\n[tool.uv]\npackage=false\n', + encoding="utf-8", + ) + monkeypatch.setattr(paths, "repo_root", lambda: core) + uv = shutil.which("uv") + assert uv, "validation integration requires real uv" + env = {**uv_env(), "UV_PYTHON": sys.executable} + env.pop("UV_NO_CONFIG") + workspace = tmp_path / "workspace" + candidate = tmp_path / "venv" + monkeypatch.setattr(importlib.import_module("pm.ensure"), "uv", lambda **kwargs: (uv, env.copy())) + from pm.workspace import lock_and_sync + + lock_and_sync([], [], root=workspace, venv_dir=candidate) + python = candidate / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + validate_environment(python, env=env, cwd=workspace) + shutil.rmtree(site_packages(candidate) / "dotenv") + if damage == "distribution": + for metadata in site_packages(candidate).glob("python_dotenv-*.dist-info"): + shutil.rmtree(metadata) + with pytest.raises(InstallError, match="startup validation failed"): + validate_environment(python, env=env, cwd=workspace) diff --git a/tests/pm/test_runtime_selection.py b/tests/pm/test_runtime_selection.py index 99605f35d0..557dc07ca9 100644 --- a/tests/pm/test_runtime_selection.py +++ b/tests/pm/test_runtime_selection.py @@ -64,7 +64,8 @@ def test_boot_uses_one_selected_dependency_tree_in_fresh_process(tmp_path, monke assert process.stdout.splitlines() == ["new", "True"] -@pytest.mark.parametrize("command,allowed", [(["pm", "install", "--help"], True), (["pm", "doctor"], True), (["chat"], False), (["chat", "pm", "install"], False)]) +@pytest.mark.parametrize("command,allowed", [(["pm", "install", "--help"], True), (["pm", "doctor"], True), + (["-p", "default", "pm", "repair"], True), (["chat"], False), (["chat", "pm", "install"], False)]) def test_broken_environment_keeps_explicit_repair_entry_reachable(tmp_path, monkeypatch, command, allowed): import os import subprocess @@ -81,10 +82,34 @@ def test_broken_environment_keeps_explicit_repair_entry_reachable(tmp_path, monk capture_output=True, text=True, timeout=30) assert (result.returncode == 0) is allowed, result.stderr if not allowed: - assert "hermes pm install" in result.stderr + assert "hermes pm repair" in result.stderr assert "Traceback" not in result.stderr +def test_manual_repair_bypasses_damaged_generation_activation(tmp_path, monkeypatch): + import os + import subprocess + import sys + from hermes_cli.runtime_paths import install_state_dir, runtime_facts_path, site_packages + + repo = Path(__file__).resolve().parents[2] + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home")) + generation = install_state_dir(repo) / "environments" / "damaged" + environment = generation / "venv" + site_packages(environment).mkdir(parents=True) + (environment / "pyvenv.cfg").write_text("home = test", encoding="utf-8") + (generation / ".lease-managed").touch() + (generation / ".leases").write_text("not a directory", encoding="utf-8") + runtime_facts_path(repo).write_text(json.dumps({"schema": 1, "packages": {"venv": { + "environment": str(environment), "extras": [], "stamp": "old", + }}}), encoding="utf-8") + env = {**os.environ, "PYTHONPATH": str(repo)} + result = subprocess.run([sys.executable, "-S", "-m", "hermes_cli.main", "pm", "repair", "--help"], + cwd=tmp_path, env=env, capture_output=True, text=True, timeout=30) + assert result.returncode == 0, result.stderr + assert "hermes pm repair" in result.stdout + + @pytest.mark.parametrize("data", [[], {"packages": []}, {"packages": {"venv": []}}]) def test_malformed_selection_has_actionable_error(tmp_path, monkeypatch, data): from hermes_cli import runtime_paths diff --git a/tests/pm/test_runtime_transaction.py b/tests/pm/test_runtime_transaction.py index 21d9e7b2f5..d07931a773 100644 --- a/tests/pm/test_runtime_transaction.py +++ b/tests/pm/test_runtime_transaction.py @@ -97,7 +97,7 @@ def test_real_uv_builds_separate_environment_before_selection(tmp_path, monkeypa monkeypatch.setattr("pm.workspace.enabled_member_dirs", lambda: []) ensure = importlib.import_module("pm.ensure") from pm.packages import uv_env - monkeypatch.setattr(ensure, "uv", lambda **kw: (uv, uv_env())) + monkeypatch.setattr(ensure, "uv", lambda **kw: (uv, {**uv_env(kw.get("base_env")), "UV_PYTHON": sys.executable})) prepared = Venv().apply([]) assert selected_venv(core) == base candidate = prepared["environment"] diff --git a/tests/pm/test_startup_recovery.py b/tests/pm/test_startup_recovery.py new file mode 100644 index 0000000000..db6e5852df --- /dev/null +++ b/tests/pm/test_startup_recovery.py @@ -0,0 +1,112 @@ +"""Startup restores recorded dependencies before importing application packages.""" +from __future__ import annotations + +import importlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys + +import pytest + +from pm.lock import Facts, Lockfile +from pm.packages import uv_env +from pm.store import current_target, tree_digest +from tests.pm.test_workspace_build_inputs import _wheel + + +@pytest.mark.parametrize("marker_name", [".update-incomplete", ".lazy-refresh-incomplete", None, "manual", "baseline"]) +def test_bootstrap_repairs_before_dependency_activation(tmp_path, monkeypatch, marker_name): + import pm.paths as paths + from hermes_cli.runtime_paths import selected_venv, site_packages + + engine = importlib.import_module("pm.ensure") + repo = Path(__file__).resolve().parents[2] + core = tmp_path / "app" + core.mkdir() + home = tmp_path / "home" + home.mkdir() + for name in ("hermes_bootstrap.py", "hermes_constants.py"): + shutil.copy2(repo / name, core / name) + shutil.copytree(repo / "pm", core / "pm", ignore=shutil.ignore_patterns("__pycache__")) + cli = core / "hermes_cli" + cli.mkdir() + for name in ("__init__.py", "runtime_paths.py", "runtime_state.py", "_early_recovery.py", "_parser.py"): + shutil.copy2(repo / "hermes_cli" / name, cli / name) + wheels = tmp_path / "wheels" + wheels.mkdir() + _wheel(wheels, "startup_dep", "1.0") + (core / "pyproject.toml").write_text( + '[project]\nname="startup-proof"\nversion="1"\nrequires-python=">=3.11"\n' + 'dependencies=["startup-dep==1.0"]\n[tool.uv]\npackage=false\nno-index=true\n' + f'find-links=[{json.dumps(wheels.as_posix())}]\n', encoding="utf-8", + ) + uv = shutil.which("uv") + assert uv, "startup recovery requires real uv" + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "tools")) + monkeypatch.setattr(paths, "repo_root", lambda: core) + monkeypatch.setattr(engine, "uv", lambda **kwargs: ( + uv, {**uv_env(kwargs.get("base_env")), "UV_PYTHON": sys.executable, "UV_OFFLINE": "1"}, + )) + monkeypatch.setattr(engine, "lazy_installs_allowed", lambda: True) + clean = {**uv_env(), "UV_PYTHON": sys.executable, "UV_OFFLINE": "1"} + clean.pop("UV_NO_CONFIG") + subprocess.run([uv, "lock"], cwd=core, env=clean, capture_output=True, check=True, timeout=60) + engine.sync_venv([], explicit=True, plugin_dirs=[]) + old = selected_venv(core) + shutil.rmtree(site_packages(old)) + if marker_name == "baseline": + from pm.features import write_features + + # A shipped baseline has its feature declaration but no mutable selection. + paths.runtime_facts_path().unlink() + write_features([], tmp_path) + marker = core / (".update-incomplete" if marker_name in {"manual", "baseline"} else marker_name) if marker_name else None + if marker: + marker.write_text('{"attempts":3}' if marker_name == "manual" else '{"attempts":0}', encoding="utf-8") + + # Select real executables through isolated fixture facts, without downloads. + lock = Lockfile(core / "pm" / "lock.json") + tools = tmp_path / "tools" + facts = Facts(tools / "facts.json") + uv_entry = tools / "uv" + uv_entry.mkdir(parents=True) + shutil.copy2(uv, uv_entry / Path(uv).name) + python_entry = Path(sys.base_prefix) + if os.name != "nt": + # A system Python's prefix can be /usr: never hash that entire tree. + python_entry = tools / "python" + (python_entry / "bin").mkdir(parents=True) + (python_entry / "bin/python3").symlink_to(Path(sys._base_executable).resolve()) + for name, directory in (("uv", uv_entry), ("python", python_entry)): + lock.set_pin(name, "fixture", {}) + facts.record(name, "fixture", str(directory), {}, tools, + target=current_target(), digest=tree_digest(directory)) + lock.save() + launcher = core / "launch.py" + launcher.write_text( + 'import hermes_bootstrap\nimport startup_dep\nprint("APP_STARTED", startup_dep.__version__)\n', + encoding="utf-8", + ) + env = {**os.environ, "PYTHONPATH": str(core)} + env.pop("PYTEST_CURRENT_TEST", None) # this child owns an isolated copied installation + if marker_name == "manual": + repaired = subprocess.run([sys.executable, "-S", "-m", "pm.cli", "repair"], cwd=tmp_path, + env=env, capture_output=True, text=True, timeout=90) + assert repaired.returncode == 0, repaired.stdout + repaired.stderr + assert not marker.exists() + result = subprocess.run( + [sys.executable, "-S", str(launcher)], cwd=tmp_path, + env=env, capture_output=True, text=True, + encoding="utf-8", timeout=90, + ) + assert result.returncode == 0, result.stdout + result.stderr + assert result.stdout.strip() == "APP_STARTED 1.0" + assert marker is None or not marker.exists() + assert selected_venv(core) != old + assert old.is_dir() + if marker_name != "manual": + assert "repaired" in result.stderr.lower() diff --git a/tests/pm/test_union_installs_members.py b/tests/pm/test_union_installs_members.py index f5bf8b097f..155c416629 100644 --- a/tests/pm/test_union_installs_members.py +++ b/tests/pm/test_union_installs_members.py @@ -20,9 +20,7 @@ import pm.workspace as ws def _site_packages(venv: Path) -> Path: - """site-packages of the venv uv actually created — the running - interpreter's layout (pm pins UV_PYTHON to sys.executable), not a - per-OS hardcoded one.""" + """The fixture supplies this interpreter as the PM toolchain.""" import sysconfig return Path(sysconfig.get_paths(vars={"base": str(venv)})["purelib"]) @@ -62,8 +60,13 @@ def mini_workspace(tmp_path, monkeypatch): store.mkdir() venv = tmp_path / "venv" + import importlib + import shutil import pm.paths + from pm.packages import uv_env + monkeypatch.setattr(importlib.import_module("pm.ensure"), "uv", + lambda **kwargs: (shutil.which("uv"), {**uv_env(kwargs.get("base_env")), "UV_PYTHON": sys.executable})) monkeypatch.setattr(pm.paths, "repo_root", lambda: core) monkeypatch.setattr(pm.paths, "store_root", lambda: store) monkeypatch.setattr(ws.paths, "repo_root", lambda: core) diff --git a/tests/pm/test_uv_python.py b/tests/pm/test_uv_python.py new file mode 100644 index 0000000000..d3dbcf209c --- /dev/null +++ b/tests/pm/test_uv_python.py @@ -0,0 +1,125 @@ +"""PM-owned uv commands use the installed interpreter, never host discovery.""" +from __future__ import annotations + +import importlib +import os +from pathlib import Path +import shutil +import subprocess +import sys + +import pytest + +from pm.lock import Facts, Lockfile +from pm.package import InstallError +from pm.packages import Python, Uv +from pm.store import current_target + + +@pytest.fixture +def installed_uv(tmp_path, monkeypatch): + import pm.paths as paths + import pm.registry as registry + + uv = shutil.which("uv") + assert uv, "the interpreter selection contract requires real uv" + store = tmp_path / "store" + lock = Lockfile(tmp_path / "lock.json") + target = current_target() + digest = "1" * 64 + entry = store / "uv" + entry.mkdir(parents=True) + binary = entry / ("uv.exe" if os.name == "nt" else "uv") + shutil.copy2(uv, binary) + uvx = Path(uv).with_name("uvx" + binary.suffix) + assert uvx.is_file(), "the real uv distribution must include uvx" + shutil.copy2(uvx, entry / uvx.name) + lock.set_pin("uv", "test", {target: {"url": "https://test.invalid/uv", "sha256": digest}}) + lock.set_pin("python", "test", {target: {"url": "https://test.invalid/python", "sha256": digest}}) + lock.save() + facts = Facts(store / "facts.json") + facts.record("uv", "test", entry.name, {}, store, target=target, artifacts=[digest]) + monkeypatch.setattr(paths, "lockfile_path", lambda: lock.path) + monkeypatch.setattr(paths, "store_root", lambda: store) + monkeypatch.setattr(paths, "writable_store_root", lambda: store) + monkeypatch.setitem(registry._packages, "uv", Uv()) + return tmp_path, binary, facts, target, digest + + +def test_all_uv_commands_keep_the_pm_interpreter(installed_uv, monkeypatch): + import pm.registry as registry + + root, uv, facts, target, digest = installed_uv + selected = root / "store" / "selected-python" + clean = {key: value for key, value in os.environ.items() if not key.startswith("UV_")} + clean.update({"UV_NO_CONFIG": "1", "UV_OFFLINE": "1", "UV_PYTHON_DOWNLOADS": "never"}) + subprocess.run([str(uv), "venv", "--python", sys.executable, str(selected)], + cwd=root, env=clean, check=True, capture_output=True, timeout=60) + python = selected / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + + class FixturePython(Python): + binary_rel = {"win32": "Scripts/python.exe", "posix": "bin/python"} + + monkeypatch.setitem(registry._packages, "python", FixturePython()) + facts.record("python", "test", selected.name, {}, selected.parent, + target=target, artifacts=[digest]) + monkeypatch.setenv("UV_PYTHON", str(root / "ambient-python")) + monkeypatch.setenv("UV_PROJECT_ENVIRONMENT", str(root / "ambient-venv")) + before = dict(os.environ) + managed_uv, env = importlib.import_module("pm.ensure").uv(realize=False) + assert managed_uv == str(uv) + assert env.get("UV_PYTHON") == str(python) + assert "UV_PROJECT_ENVIRONMENT" not in env + assert dict(os.environ) == before + uvx, uvx_env = importlib.import_module("pm.ensure").uv("uvx", realize=False) + assert Path(uvx) == uv.with_name("uvx" + uv.suffix) + assert uvx_env["UV_PYTHON"] == str(python) + subprocess.run([uvx, "--version"], cwd=root, env=uvx_env, check=True, capture_output=True, timeout=30) + + project = root / "project" + project.mkdir() + (project / "pyproject.toml").write_text( + '[project]\nname="pm-python-proof"\nversion="1"\nrequires-python=">=3.11"\n' + '[tool.uv]\npackage=false\n', encoding="utf-8", + ) + (project / ".python-version").write_text(str(root / "host-only-python"), encoding="utf-8") + env.pop("UV_NO_CONFIG") + env.update({"UV_OFFLINE": "1", "UV_PYTHON_DOWNLOADS": "never"}) + environment = root / "candidate" + env["VIRTUAL_ENV"] = str(environment) + for args in (["venv", str(environment)], ["lock"], ["sync", "--frozen", "--active"], + ["run", "--active", "--no-sync", "python", "-c", "import sys; print(sys.prefix)"]): + result = subprocess.run([managed_uv, *args], cwd=project, env=env, + capture_output=True, text=True, check=True, timeout=60) + assert Path(result.stdout.strip()).resolve() == environment.resolve() + + incomplete = root / "store" / "uv-without-uvx" + incomplete.mkdir() + shutil.copy2(uv, incomplete / uv.name) + facts.record("uv", "test", incomplete.name, {}, incomplete.parent, + target=target, artifacts=[digest]) + ensure = importlib.import_module("pm.ensure") + assert ensure.uv("uvx", realize=False)[0] is None + with pytest.raises(InstallError, match="binary is missing"): + ensure.uv("uvx") + + +def test_uv_refuses_discovery_when_pm_python_is_missing(installed_uv, monkeypatch): + root, _, facts, target, digest = installed_uv + ensure = importlib.import_module("pm.ensure") + monkeypatch.setattr(ensure, "lazy_installs_allowed", lambda: False) + monkeypatch.setenv("UV_PYTHON", str(root / "ambient-python")) + managed_uv, env = ensure.uv(realize=False) + assert managed_uv is None + assert "UV_PYTHON" not in env + assert not (root / "store" / "python-test").exists() + with pytest.raises(InstallError, match="python"): + ensure.uv() + + entry = root / "store" / "missing-binary" + entry.mkdir() + facts.record("python", "test", entry.name, {}, entry.parent, + target=target, artifacts=[digest]) + assert ensure.uv(realize=False)[0] is None + with pytest.raises(InstallError, match="binary is missing"): + ensure.uv() diff --git a/tests/pm/test_venv_stamp.py b/tests/pm/test_venv_stamp.py new file mode 100644 index 0000000000..f3cc3e15df --- /dev/null +++ b/tests/pm/test_venv_stamp.py @@ -0,0 +1,36 @@ +"""Dependency freshness follows PM's interpreter identity, not unrelated tools.""" +from pm.lock import Lockfile +from pm.packages import Venv +from pm.store import current_target + + +def test_venv_stamp_tracks_the_python_pin(tmp_path, monkeypatch): + from pm import paths + + core = tmp_path / "core" + core.mkdir() + (core / "uv.lock").write_bytes(b"unchanged dependency lock") + lock = Lockfile(tmp_path / "pm-lock.json") + target = current_target() + artifact = {"url": "https://example.invalid/python", "sha256": "1" * 64} + lock.set_pin("python", "test.1", {target: artifact}) + lock.save() + monkeypatch.setattr(paths, "repo_root", lambda: core) + monkeypatch.setattr(paths, "lockfile_path", lambda: lock.path) + venv = Venv() + original = venv.expected_stamp([], plugin_dirs=[]) + + lock.set_pin("uv", "unrelated", {target: artifact}) + lock.save() + assert venv.expected_stamp([], plugin_dirs=[]) == original + + # Repacked interpreter bytes are a new input even at the same version. + artifact = {**artifact, "sha256": "2" * 64} + lock.set_pin("python", "test.1", {target: artifact}) + lock.save() + repinned = venv.expected_stamp([], plugin_dirs=[]) + assert repinned != original + + lock.set_pin("python", "test.2", {target: artifact}) + lock.save() + assert venv.expected_stamp([], plugin_dirs=[]) != repinned diff --git a/tests/pm/test_workspace.py b/tests/pm/test_workspace.py index 2621e7212e..a2b2c6cb66 100644 --- a/tests/pm/test_workspace.py +++ b/tests/pm/test_workspace.py @@ -10,6 +10,7 @@ core + plugin deps into ONE lock; conflict = loud refusal. from __future__ import annotations +import importlib import os import subprocess from pathlib import Path @@ -267,8 +268,8 @@ def test_sync_failure_is_never_a_conflict(tmp_path, monkeypatch): stdout = "" monkeypatch.setattr(ws, "_generate_pyproject", lambda *a, **k: (Path("/x/ws"), False)) - monkeypatch.setattr(ws, "_uv_binary", lambda: "uv") - monkeypatch.setattr("pm.packages.uv_env", lambda env=None: {"UV_CACHE_DIR": "/x/cache"}) + monkeypatch.setattr(importlib.import_module("pm.ensure"), "uv", + lambda **kwargs: ("uv", {"UV_CACHE_DIR": "/x/cache", "UV_PYTHON": "pm-python"})) captured = {} @@ -302,10 +303,10 @@ def test_staging_root_and_env_are_honored_without_live_mutation(monkeypatch, tmp return FakeProc() monkeypatch.setattr(ws, "_generate_pyproject", lambda *a, **k: (staging, False)) - monkeypatch.setattr(ws, "_uv_binary", lambda: "uv") monkeypatch.setattr( - "pm.packages.uv_env", - lambda env=None: {**(env or {}), "UV_CACHE_DIR": "/x/cache"}, + importlib.import_module("pm.ensure"), "uv", + lambda **kwargs: ("uv", {**(kwargs.get("base_env") or {}), + "UV_CACHE_DIR": "/x/cache", "UV_PYTHON": "pm-python"}), ) monkeypatch.setattr(ws.subprocess, "run", fake_run) @@ -321,6 +322,7 @@ def test_staging_root_and_env_are_honored_without_live_mutation(monkeypatch, tmp assert seen["env"][live_key] == "staged" # staged env wins assert seen["env"]["UV_CACHE_DIR"] == "/x/cache" # uv_env layered on top assert seen["env"]["UV_PROJECT_ENVIRONMENT"] == str(tmp_path / "staging-venv") + assert seen["env"]["UV_PYTHON"] == "pm-python" assert os.environ[live_key] == "live" # live env untouched finally: del os.environ[live_key] @@ -340,7 +342,8 @@ def test_changed_root_seeds_from_committed_lock_unchanged_keeps_extended( stderr = "" stdout = "" - monkeypatch.setattr(ws, "_uv_binary", lambda: "uv") + monkeypatch.setattr(importlib.import_module("pm.ensure"), "uv", + lambda **kwargs: ("uv", {"UV_PYTHON": "pm-python"})) monkeypatch.setattr(ws.subprocess, "run", lambda cmd, **k: FakeProc()) root = ws.workspace_root() diff --git a/tests/pm/test_workspace_build_inputs.py b/tests/pm/test_workspace_build_inputs.py index d2dee5010a..e89304024c 100644 --- a/tests/pm/test_workspace_build_inputs.py +++ b/tests/pm/test_workspace_build_inputs.py @@ -9,6 +9,7 @@ import sys import pytest from pm import workspace +from pm.packages import uv_env def test_real_build_inputs_stay_in_generated_root(tmp_path, monkeypatch): @@ -31,7 +32,8 @@ def test_real_build_inputs_stay_in_generated_root(tmp_path, monkeypatch): root, venv = tmp_path / "staging", tmp_path / "venv" uv = shutil.which("uv") assert uv is not None - monkeypatch.setattr(workspace, "_uv_binary", lambda: uv) + monkeypatch.setattr(importlib.import_module("pm.ensure"), "uv", + lambda **kwargs: (uv, {**uv_env(kwargs.get("base_env")), "UV_PYTHON": sys.executable})) workspace.lock_and_sync([], [], root=root, venv_dir=venv) python = venv / ("Scripts/python.exe" if sys.platform == "win32" else "bin/python") probe = subprocess.run([str(python), "-c", "import buildable_core; print(buildable_core.VALUE)"], @@ -114,7 +116,8 @@ def test_plugin_can_move_compatible_transitive_but_not_exact_requirement(tmp_pat uv = shutil.which("uv") assert uv monkeypatch.setattr(workspace.paths, "repo_root", lambda: core) - monkeypatch.setattr(workspace, "_uv_binary", lambda: uv) + monkeypatch.setattr(importlib.import_module("pm.ensure"), "uv", + lambda **kwargs: (uv, {**uv_env(kwargs.get("base_env")), "UV_PYTHON": sys.executable})) baseline, first_env = tmp_path / "baseline", tmp_path / "first-env" workspace.lock_and_sync([], [], root=baseline, venv_dir=first_env) first_lock = (baseline / "uv.lock").read_bytes() diff --git a/tests/test_managed_runtime_resolution.py b/tests/test_managed_runtime_resolution.py index 9e5c15682d..99e0ac5f9c 100644 --- a/tests/test_managed_runtime_resolution.py +++ b/tests/test_managed_runtime_resolution.py @@ -1,21 +1,35 @@ -"""Managed workspace tooling wins; PATH is only a pre-install fallback.""" +"""Workspace commands preserve PM's toolchain instead of consulting PATH.""" import importlib import shutil +import subprocess -from pm.workspace import _uv_binary +import pytest + +from pm import workspace +from pm.package import InstallError -def test_workspace_uv_prefers_managed_tool_without_path_probe(monkeypatch): +def test_workspace_uv_preserves_managed_tool_and_interpreter(monkeypatch, tmp_path): ensure = importlib.import_module("pm.ensure") - monkeypatch.setattr(ensure, "uv", lambda **kwargs: ("managed/uv", {})) + monkeypatch.setattr(ensure, "uv", lambda **kwargs: ("managed/uv", {"UV_PYTHON": "managed/python"})) + monkeypatch.setattr(workspace, "_generate_pyproject", lambda *args: (tmp_path, False)) def reject_path(*args, **kwargs): - raise AssertionError("managed uv must win before PATH") + raise AssertionError("workspace commands must not resolve tools from PATH") monkeypatch.setattr(shutil, "which", reject_path) - assert _uv_binary() == "managed/uv" + calls = [] + def run(cmd, **kwargs): + calls.append((cmd, kwargs["env"])) + return subprocess.CompletedProcess(cmd, 0, "", "") + monkeypatch.setattr(workspace.subprocess, "run", run) + workspace.lock_and_sync([], venv_dir=tmp_path / "venv", root=tmp_path) + assert [cmd[1] for cmd, _ in calls] == ["lock", "sync"] + assert all(cmd[0] == "managed/uv" and env["UV_PYTHON"] == "managed/python" for cmd, env in calls) -def test_workspace_uv_accepts_developer_path_only_when_store_absent(monkeypatch): +def test_workspace_uv_missing_toolchain_never_falls_back(monkeypatch, tmp_path): ensure = importlib.import_module("pm.ensure") monkeypatch.setattr(ensure, "uv", lambda **kwargs: (None, {})) - monkeypatch.setattr(shutil, "which", lambda name: "developer/uv" if name == "uv" else None) - assert _uv_binary() == "developer/uv" + monkeypatch.setattr(workspace, "_generate_pyproject", lambda *args: (tmp_path, False)) + monkeypatch.setattr(shutil, "which", lambda name: "developer/uv") + with pytest.raises(InstallError, match="PM's uv and Python"): + workspace.lock_and_sync([], venv_dir=tmp_path / "venv", root=tmp_path) diff --git a/tests/test_packaging_metadata.py b/tests/test_packaging_metadata.py index 85abd2f203..d603f5b693 100644 --- a/tests/test_packaging_metadata.py +++ b/tests/test_packaging_metadata.py @@ -33,8 +33,7 @@ def test_packaging_declared_as_core_dependency(): hermes_cli/main.py) yet was undeclared, so it only reached users transitively. The slim Docker image shipped without it, silently disabling Hindsight append-mode and version-constraint checks. It must - be a declared core dependency so it installs everywhere and the - update-repair step (``_verify_core_dependencies_installed``) guards it. + be a declared core dependency so PM includes it in dependency generations. """ data = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8")) core = data["project"]["dependencies"] diff --git a/tests/tools/test_browser_use_cli.py b/tests/tools/test_browser_use_cli.py index 80e4d75a63..ee4051d854 100644 --- a/tests/tools/test_browser_use_cli.py +++ b/tests/tools/test_browser_use_cli.py @@ -265,16 +265,35 @@ class TestFindCli: ) assert bu_cli._find_cli_unpatched() == ["/usr/local/bin/browser-use"] - def test_falls_back_to_uvx(self, monkeypatch): - """The zero-install fallback resolves pm's PINNED uvx — never a - bare PATH probe (pm names the binary; a PATH uvx is an unknown - version).""" - monkeypatch.setattr(bu_cli, "_pinned_uvx", lambda: "/store/uvx") - monkeypatch.setattr( - bu_cli.shutil, "which", - lambda name, path=None: None, - ) - assert bu_cli._find_cli_unpatched() == ["/store/uvx", "browser-use"] + def test_falls_back_to_uvx(self, monkeypatch, tmp_path): + """PM owns the executable spelling and the interpreter environment.""" + import pm + import subprocess + + executable = str(tmp_path / "chosen-by-pm.bin") + calls = [] + def managed(command="uv", *, realize=True, base_env=None): + calls.append((command, realize)) + return executable, {**(base_env or {}), "UV_PYTHON": "pm-python"} + monkeypatch.setattr(pm, "uv", managed) + monkeypatch.setattr(bu_cli.shutil, "which", lambda name, path=None: None) + assert bu_cli._find_cli_unpatched() == [executable, "browser-use"] + assert calls == [("uvx", False)] + monkeypatch.setattr(bu_cli, "_find_cli", bu_cli._find_cli_unpatched) + monkeypatch.setattr(bu_cli, "_base_subprocess_env", lambda: {"BROWSER_SETTING": "preserved"}) + monkeypatch.setattr(bu_cli, "_route_backend", lambda *args: None) + seen = {} + def run(cmd, **kwargs): + seen.update(cmd=cmd, env=kwargs["env"], input=kwargs["input"]) + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + monkeypatch.setattr(bu_cli.subprocess, "run", run) + result = json.loads(bu_cli.browser_exec("print(1)")) + assert result["success"] is True + assert calls[-1] == ("uvx", True) + assert seen["cmd"] == [executable, "browser-use"] + assert seen["env"]["UV_PYTHON"] == "pm-python" + assert seen["env"]["BROWSER_SETTING"] == "preserved" + assert seen["input"] == "print(1)" def test_bare_path_uvx_not_consulted(self, monkeypatch): """Kill the PATH probe: a uvx reachable only via bare PATH is not diff --git a/tools/browser_use_cli.py b/tools/browser_use_cli.py index 5fd2772c9f..4da8f7aa0f 100644 --- a/tools/browser_use_cli.py +++ b/tools/browser_use_cli.py @@ -239,25 +239,12 @@ def _managed_bin_dir() -> str: def _pinned_uvx() -> Optional[str]: - """The pinned uvx from pm's store, or None when pm can't provide it. - - uvx ships inside uv's own store entry, beside the uv binary — the pin - that governs uv governs it. Resolved from pm's uv fact rather than by - probing directories: pm names the binary, so nobody goes fishing with - ``shutil.which`` on a dir (a PATH probe could resolve a system uvx of - unknown version). Pure lookup (``realize=False``) — this is a probe, - not the converging ``install_cli()`` path. - """ + """Read-only lookup of PM's uvx executable.""" try: import pm - uv_bin, _env = pm.uv(realize=False) - if not uv_bin: - return None - uvx = str(Path(uv_bin).with_name("uvx.exe" if os.name == "nt" else "uvx")) - if os.path.isfile(uvx) and os.access(uvx, os.X_OK): - return uvx - return None + uvx, _env = pm.uv("uvx", realize=False) + return uvx except Exception as e: # pragma: no cover — defensive logger.debug("Could not resolve pinned uvx: %s", e) return None @@ -303,8 +290,7 @@ def _find_cli() -> Optional[List[str]]: def install_cli(timeout_s: int = 600) -> Tuple[bool, str]: """Install the browser-use CLI persistently via ``uv tool install``. - Resolution order for uv: Hermes' managed uv (realized on demand via - ``pm.uv``) → uv on PATH. The binary is linked + PM supplies both uv and its base Python. The binary is linked into ``$HERMES_HOME/bin`` (``UV_TOOL_BIN_DIR``) so ``_find_cli()`` resolves it for every profile without touching the user's PATH. @@ -320,23 +306,14 @@ def install_cli(timeout_s: int = 600) -> Tuple[bool, str]: if managed: return True, f"browser-use CLI already installed ({managed})" - uv_bin: Optional[str] = None - env = dict(os.environ) try: import pm - uv_bin, pm_env = pm.uv() - if uv_bin: - env = pm_env + uv_bin, env = pm.uv() except Exception as e: - logger.debug("Managed uv unavailable: %s", e) + return False, f"PM's uv/Python toolchain is unavailable: {e}" if not uv_bin: - uv_bin = shutil.which("uv") - if not uv_bin: - return False, ( - "uv is not available and could not be bootstrapped. Install uv " - "(https://docs.astral.sh/uv/) and run `uv tool install browser-use`." - ) + return False, "PM's uv/Python toolchain is unavailable; run `hermes pm install`." env["UV_NO_CONFIG"] = "1" if bin_dir: @@ -601,6 +578,16 @@ def browser_exec(code: str, session: str = "", timeout_s: int = _DEFAULT_TIMEOUT "then run `browser-use --doctor` to verify the setup.") env = _base_subprocess_env() + if len(cmd) > 1: + try: + import pm + + uvx, env = pm.uv("uvx", base_env=env) + if not uvx: + return tool_error("PM's uv/Python toolchain is unavailable; run `hermes pm install`.") + cmd = [uvx, *cmd[1:]] + except Exception as e: + return tool_error(f"PM's uv/Python toolchain is unavailable: {e}") if session: if not _SESSION_RE.match(session): return tool_error(f"Invalid session name {session!r}: use 1-64 letters, digits, " diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index b7ae6cfbd2..4f40a8f665 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -1787,6 +1787,7 @@ This command does not update the Hermes application itself. hermes pm --help hermes pm doctor hermes pm status +hermes pm repair hermes pm install hermes pm install chromium ``` diff --git a/website/docs/reference/package-management.md b/website/docs/reference/package-management.md index 94deab7eaf..275788ffb1 100644 --- a/website/docs/reference/package-management.md +++ b/website/docs/reference/package-management.md @@ -294,6 +294,7 @@ hermes pm install chromium | `pm install [names...]` | Install named packages. With no names, provision required tools plus Python and sync the `all` extra. | | `pm env [names...]` | Print the composed environment of installed packages as JSON. It does not install missing packages. | | `pm doctor` | Check installed tool identities, files, and digests against the lock. | +| `pm repair` | Rebuild the recorded Python dependency set in a new generation, validate it, then select it. Does not update pins, features, or plugin configuration. | | `pm status` | Print the latest sync/update receipt as JSON, or report that no receipt exists. | | `pm gc` | Remove unreferenced tool-store entries, eligible download partials, and unused lease-managed Python generations. | @@ -324,5 +325,7 @@ launchers, and invokes native packaging. Maintainers can read - **Missing or outdated tool:** read `hermes pm doctor`, then use an explicit PM install on a writable installation. - **New environment requires restart:** restart the affected Hermes process. Do not add a second site-packages tree to its live imports. - **Dependency conflict:** read `hermes pm status`. Correct the plugin requirements before retrying admission. -- **Damaged packaged base:** repair or reinstall through the package owner. Do not alter signed files to suppress the diagnostic. +- **Damaged Python dependencies:** run `hermes pm repair`, then restart Hermes. Repair replays the selected generation's saved workspace and lock without parsing plugin configuration. An unreadable record or missing saved lock fails without selecting a reduced dependency set. Before a generation exists, repair uses the shipped or committed lock and recorded feature set. +- **Interrupted dependency install:** startup requests the same PM repair before dependency activation. Automatic attempts are bounded; `pm repair` retries explicitly. A failed repair preserves the previous selection and its retry marker. +- **Damaged Python executable or application source:** repair or reinstall through the package owner. PM cannot run without those files. Signed payload files are never modified by dependency repair. - **Unknown package or extra:** use the declared name. `pm install` takes package names, not Python extra names or pip specifications.