From 8b7eae99ef7c8b9dd998da323a906ee0fa84b770 Mon Sep 17 00:00:00 2001 From: ethernet Date: Tue, 8 Sep 2026 23:39:55 -0400 Subject: [PATCH] fix(pm): own interpreter selection and dependency recovery Pin uv and uvx to the PM interpreter instead of ambient Python discovery. A matching dependency stamp cannot prove that installed files still exist. Repair now rebuilds the recorded workspace and lock in a fresh generation, checks startup imports, and publishes the selection only after success. Run startup recovery before dependency activation. Keep manual PM repair reachable when the selected environment is damaged. Preserve plugin selection, retry ownership, and the previous generation on failure. Remove the separate pip, ensurepip, per-extra, and install-time quarantine ladders. Keep orphan launcher restoration. Verification: 717 targeted tests passed on native Windows ARM64, with 56 skipped. Ruff, diff checks, and the source-scoped compat check passed. A disposable real Hermes install recovered deleted YAML and dotenv files, then printed CLI help with exit 0. Its lock and stamp stayed unchanged. The full suite and a release build were not run for this change. --- hermes_bootstrap.py | 22 +- hermes_cli/_early_recovery.py | 433 ++-------- hermes_cli/_install_repair.py | 325 +------ hermes_cli/_parser.py | 15 + hermes_cli/main.py | 76 +- hermes_cli/main_install_repair.py | 808 +----------------- hermes_cli/setup_terminal.py | 17 +- hermes_cli/tools_config_cua.py | 19 +- hermes_cli/update_cmd.py | 51 +- hermes_cli/update_cmd_git.py | 2 +- plugins/memory/hindsight/embedded_runtime.py | 6 - pm/cli.py | 15 +- pm/ensure.py | 85 +- pm/lock.py | 13 +- pm/packages.py | 47 +- pm/plugins_state.py | 13 +- pm/recovery.py | 58 ++ pm/workspace.py | 53 +- scripts/ci/setup_toolchain.py | 1 - .../test_checkout_mutation_guards.py | 52 +- tests/hermes_cli/test_cmd_update.py | 12 +- tests/hermes_cli/test_early_recovery.py | 581 ++----------- tests/hermes_cli/test_install_cua_driver.py | 24 +- .../test_install_progress_stream.py | 32 - .../test_lazy_refresh_venv_repair.py | 103 +-- .../test_quarantine_noop_restore.py | 140 --- .../test_quarantine_orphan_rescue.py | 30 +- tests/hermes_cli/test_setup.py | 1 + .../test_shim_fail_closed_windows_live.py | 153 ---- .../test_update_concurrent_quarantine.py | 46 - .../test_update_interrupted_recovery.py | 126 +-- .../test_update_shim_fail_closed.py | 210 ----- .../test_update_stale_virtualenv.py | 114 --- tests/hermes_cli/test_update_zip_two_phase.py | 17 - .../hermes_cli/test_verify_console_scripts.py | 29 +- .../test_verify_core_dependencies.py | 156 ---- .../memory/test_hindsight_embedded_runtime.py | 9 +- tests/pm/test_plugin_survival_contract.py | 9 +- tests/pm/test_plugins_state.py | 12 + tests/pm/test_recovery.py | 116 +++ tests/pm/test_recovery_validation.py | 48 ++ tests/pm/test_runtime_selection.py | 29 +- tests/pm/test_runtime_transaction.py | 2 +- tests/pm/test_startup_recovery.py | 112 +++ tests/pm/test_union_installs_members.py | 9 +- tests/pm/test_uv_python.py | 125 +++ tests/pm/test_venv_stamp.py | 36 + tests/pm/test_workspace.py | 15 +- tests/pm/test_workspace_build_inputs.py | 7 +- tests/test_managed_runtime_resolution.py | 32 +- tests/test_packaging_metadata.py | 3 +- tests/tools/test_browser_use_cli.py | 39 +- tools/browser_use_cli.py | 47 +- website/docs/reference/cli-commands.md | 1 + website/docs/reference/package-management.md | 5 +- 55 files changed, 1100 insertions(+), 3441 deletions(-) create mode 100644 pm/recovery.py delete mode 100644 tests/hermes_cli/test_install_progress_stream.py delete mode 100644 tests/hermes_cli/test_quarantine_noop_restore.py delete mode 100644 tests/hermes_cli/test_shim_fail_closed_windows_live.py delete mode 100644 tests/hermes_cli/test_update_shim_fail_closed.py delete mode 100644 tests/hermes_cli/test_update_stale_virtualenv.py delete mode 100644 tests/hermes_cli/test_verify_core_dependencies.py create mode 100644 tests/pm/test_recovery.py create mode 100644 tests/pm/test_recovery_validation.py create mode 100644 tests/pm/test_startup_recovery.py create mode 100644 tests/pm/test_uv_python.py create mode 100644 tests/pm/test_venv_stamp.py 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.