From 53e6f001c7a409c9f61094f9a744dbb7815cd7b5 Mon Sep 17 00:00:00 2001 From: ethernet Date: Sat, 12 Sep 2026 16:30:35 -0400 Subject: [PATCH] refactor(pm): consolidate runtime ownership and updater completion Run historical updater completion in a fresh interpreter so cached imports cannot revive retired dependency installers. Share Git and ZIP completion, carry receipt and recovery state, and preserve child exit status. Route plugin admission, binary acquisition, desktop launch and build paths through PM. Replace redundant helpers and tests with real worker, package, publication and launch checks. Keep the shipped compatibility surface fixed. Targeted Python and desktop checks pass. Native update journeys and fresh production image qualification remain pending. This is a checkpoint before those acceptance runs. --- .github/workflows/bootstrap-installer.yml | 31 +- .github/workflows/installer-tests.yml | 13 + .github/workflows/pm-bundle.yml | 13 +- agent/proxy_sources/iron_proxy.py | 161 ++---- agent/secret_sources/bitwarden.py | 157 +----- .../electron/backend-python-coherence.test.ts | 114 ----- .../electron/bootstrap-platform.test.ts | 7 - apps/desktop/electron/bootstrap-platform.ts | 5 - .../electron/fixtures/source-backend.py | 93 ++++ apps/desktop/electron/main.ts | 158 +----- apps/desktop/electron/source-backend.test.ts | 234 +++++++++ apps/desktop/electron/source-backend.ts | 105 ++++ gateway/run.py | 4 +- hermes_cli/_launchers.py | 1 + hermes_cli/_old_updater.py | 150 +++++- hermes_cli/_scan_venv_blockers.py | 321 +----------- hermes_cli/_update_takeover.py | 108 ++++ hermes_cli/boot_bootstrap.py | 124 +---- hermes_cli/doctor_tools.py | 25 +- hermes_cli/main.py | 4 +- hermes_cli/old_updater_deps.py | 5 +- hermes_cli/plugin_packs.py | 17 +- hermes_cli/plugins_admission.py | 27 +- hermes_cli/plugins_cmd.py | 124 ++--- hermes_cli/plugins_transaction.py | 9 +- hermes_cli/proxy_cli.py | 4 +- hermes_cli/secrets_cli.py | 11 +- hermes_cli/update_cmd.py | 43 +- hermes_cli/update_cmd_git.py | 2 +- hermes_cli/update_cmd_zip.py | 63 +-- hermes_cli/update_finish.py | 137 +++++ hermes_cli/update_lock.py | 14 +- hermes_cli/update_receipt.py | 15 +- hermes_cli/update_serve_resume.py | 59 +++ pm/__init__.py | 1 + pm/build_operations.py | 13 +- pm/cli.py | 36 +- pm/ensure.py | 144 +++--- pm/environment.py | 21 +- pm/lock.json | 183 +++++++ pm/operations.py | 12 +- pm/packages.py | 19 +- pm/plugin_declarations.py | 107 ++++ pm/security_packages.py | 129 +++++ pm/update.py | 4 - pm/workspace.py | 70 +-- scripts/audit-old-updater-imports.py | 138 ++--- scripts/build/node-deps.mjs | 17 +- scripts/bundles/desktop.py | 15 +- scripts/bundles/native.py | 17 +- scripts/ci/test_install_ps1_cli_launchers.ps1 | 188 ------- .../ci/test_install_ps1_path_migration.ps1 | 125 ----- scripts/install.sh | 8 +- scripts/smoke-payload.sh | 97 ++-- .../tests/test-install-ps1-stage-protocol.ps1 | 134 ----- tests-js/node-deps.test.mjs | 21 +- tests-js/source-update-observer.test.mjs | 42 ++ .../agent/lsp/test_install_and_lint_fixes.py | 53 +- tests/compat/README.md | 49 +- tests/compat/old_updater_support.py | 123 +++++ tests/docker/test_tui_prebuilt_bundle.py | 20 +- tests/gateway/test_pm_activation.py | 14 +- tests/gateway/test_update_cron_drain.py | 26 +- tests/gateway/test_update_streaming.py | 130 +++-- tests/hermes_cli/plugin_worker_support.py | 154 ++++++ tests/hermes_cli/test_boot_bootstrap.py | 106 ---- tests/hermes_cli/test_cmd_update.py | 2 +- .../hermes_cli/test_doctor_command_install.py | 1 + .../hermes_cli/test_doctor_pm_store_probe.py | 179 ++++--- .../test_install_bucket_separation.py | 67 ++- .../test_memory_dependency_admission.py | 2 +- tests/hermes_cli/test_npm_engine.py | 27 +- tests/hermes_cli/test_old_updater_takeover.py | 262 ++++++++++ .../test_plugin_admission_transaction.py | 21 - tests/hermes_cli/test_plugin_install_ref.py | 15 +- tests/hermes_cli/test_plugin_packs.py | 144 ------ .../test_plugin_worker_lifecycle.py | 268 ++++++++++ .../test_plugins_admission_setter.py | 338 ++++--------- tests/hermes_cli/test_plugins_cmd.py | 89 +--- .../hermes_cli/test_plugins_cmd_deps_flow.py | 154 ------ .../test_plugins_cmd_enable_disable_nested.py | 312 +++--------- tests/hermes_cli/test_relay_plugin_cutover.py | 4 +- tests/hermes_cli/test_scan_venv_blockers.py | 476 ++---------------- tests/hermes_cli/test_tui_npm_install.py | 31 +- .../test_update_apply_shallow_count.py | 139 ++--- .../test_update_fetch_failure_classifier.py | 59 ++- tests/hermes_cli/test_update_finish.py | 383 ++++++++++++++ .../test_update_fleet_check_fail_closed.py | 77 +-- .../test_update_interrupted_recovery.py | 27 - .../hermes_cli/test_update_modified_notice.py | 82 +-- .../test_update_post_pull_syntax_guard.py | 127 ++--- tests/hermes_cli/test_update_receipt.py | 4 +- .../hermes_cli/test_update_zip_completion.py | 220 ++++++++ .../test_update_zip_sync_failure.py | 14 +- .../test_venv_holder_windows_live.py | 178 +------ .../test_web_memory_provider_setup_install.py | 2 +- tests/install/README.md | 7 +- tests/install/e2e-assets/launch-from-spec.mjs | 164 +----- tests/install/e2e-assets/source-driver.ps1 | 19 + tests/install/e2e-assets/source-driver.sh | 21 + .../e2e-assets/source-update-observer.mjs | 34 ++ tests/install/e2e-assets/source_driver.py | 105 ++++ tests/install/installer-script-e2e.sh | 119 +---- tests/install/macos-desktop-e2e.sh | 35 +- tests/install/windows-e2e.ps1 | 41 +- tests/pm/_fixtures.py | 123 +++++ tests/pm/test_activation_setup.py | 6 +- tests/pm/test_admission_members_locked.py | 48 -- tests/pm/test_build_operations.py | 21 +- tests/pm/test_download_partial_integrity.py | 40 -- tests/pm/test_download_state.py | 6 +- tests/pm/test_downloader.py | 50 +- tests/pm/test_environment_build.py | 137 +++-- tests/pm/test_network_retries.py | 11 +- tests/pm/test_plugins_state.py | 3 +- tests/pm/test_pm_authority.py | 32 +- tests/pm/test_pm_core.py | 110 +--- tests/pm/test_recovery.py | 2 +- tests/pm/test_security_consumers.py | 338 +++++++++++++ tests/pm/test_setup_lock_format.py | 2 +- tests/pm/test_source_update_launch.py | 2 +- tests/pm/test_stage_only.py | 2 +- tests/pm/test_startup_activation.py | 114 +++++ tests/pm/test_startup_recovery.py | 5 +- tests/pm/test_takeover_preparation.py | 119 +++++ tests/pm/test_union_installs_members.py | 113 ----- tests/pm/test_update_auth_scope.py | 24 +- tests/pm/test_worker.py | 98 ++-- tests/pm/test_workspace.py | 4 +- tests/pm/test_workspace_build_inputs.py | 25 +- tests/pm/test_workspace_output_encoding.py | 10 +- tests/scripts/test_agent_build.py | 50 ++ tests/scripts/test_bundle_native.py | 2 +- tests/scripts/test_bundle_npm.py | 33 ++ tests/scripts/test_bundle_store_cleanup.py | 13 +- tests/scripts/test_pm_build_consumers.py | 4 +- tests/scripts/test_setup_toolchain.py | 2 +- tests/scripts/test_source_driver.py | 275 ++++++++++ tests/test_audit_old_updater_imports.py | 46 +- tests/test_bitwarden_secrets.py | 136 +---- .../test_desktop_update_windows_timestamp.py | 62 +-- tests/test_fresh_source_install.py | 146 ++++++ tests/test_install_sh_python_pin_indent.py | 17 +- tests/test_iron_proxy.py | 29 +- tests/test_node_resolution.py | 4 + tests/test_old_updater_additional_shims.py | 119 +++-- tests/test_old_updater_compat_surface.py | 80 ++- tests/test_old_updater_main_shims.py | 155 +++--- tests/test_old_updater_shims.py | 196 +++----- tests/test_source_launcher_stages.py | 126 +---- .../test_dockerfile_node_modules_perms.py | 22 - .../tools/test_multiplex_tool_cache_scope.py | 26 +- tests/tools/test_tirith_security.py | 432 +--------------- tools/tirith_security.py | 442 ++++------------ .../docs/developer-guide/egress-internals.md | 22 +- website/docs/developer-guide/plugins/index.md | 28 +- website/docs/reference/cli-commands.md | 4 +- website/docs/reference/package-management.md | 51 +- website/docs/user-guide/desktop.md | 15 +- website/docs/user-guide/egress/iron-proxy.md | 13 +- website/docs/user-guide/secrets/bitwarden.md | 19 +- website/docs/user-guide/security.md | 16 +- 162 files changed, 6357 insertions(+), 6496 deletions(-) delete mode 100644 apps/desktop/electron/backend-python-coherence.test.ts create mode 100644 apps/desktop/electron/fixtures/source-backend.py create mode 100644 apps/desktop/electron/source-backend.test.ts create mode 100644 apps/desktop/electron/source-backend.ts create mode 100644 hermes_cli/_update_takeover.py create mode 100644 hermes_cli/update_finish.py create mode 100644 hermes_cli/update_serve_resume.py create mode 100644 pm/plugin_declarations.py create mode 100644 pm/security_packages.py delete mode 100644 scripts/ci/test_install_ps1_cli_launchers.ps1 delete mode 100644 scripts/ci/test_install_ps1_path_migration.ps1 delete mode 100644 scripts/tests/test-install-ps1-stage-protocol.ps1 create mode 100644 tests-js/source-update-observer.test.mjs create mode 100644 tests/compat/old_updater_support.py create mode 100644 tests/hermes_cli/plugin_worker_support.py create mode 100644 tests/hermes_cli/test_old_updater_takeover.py delete mode 100644 tests/hermes_cli/test_plugin_admission_transaction.py create mode 100644 tests/hermes_cli/test_plugin_worker_lifecycle.py delete mode 100644 tests/hermes_cli/test_plugins_cmd_deps_flow.py create mode 100644 tests/hermes_cli/test_update_finish.py delete mode 100644 tests/hermes_cli/test_update_interrupted_recovery.py create mode 100644 tests/hermes_cli/test_update_zip_completion.py create mode 100644 tests/install/e2e-assets/source-driver.ps1 create mode 100644 tests/install/e2e-assets/source-driver.sh create mode 100644 tests/install/e2e-assets/source-update-observer.mjs create mode 100644 tests/install/e2e-assets/source_driver.py create mode 100644 tests/pm/_fixtures.py delete mode 100644 tests/pm/test_admission_members_locked.py delete mode 100644 tests/pm/test_download_partial_integrity.py create mode 100644 tests/pm/test_security_consumers.py create mode 100644 tests/pm/test_startup_activation.py create mode 100644 tests/pm/test_takeover_preparation.py delete mode 100644 tests/pm/test_union_installs_members.py create mode 100644 tests/scripts/test_bundle_npm.py create mode 100644 tests/scripts/test_source_driver.py create mode 100644 tests/test_fresh_source_install.py delete mode 100644 tests/tools/test_dockerfile_node_modules_perms.py diff --git a/.github/workflows/bootstrap-installer.yml b/.github/workflows/bootstrap-installer.yml index a99565f501..1cc5343058 100644 --- a/.github/workflows/bootstrap-installer.yml +++ b/.github/workflows/bootstrap-installer.yml @@ -4,12 +4,9 @@ name: Bootstrap installer # POSIX shell installer (scripts/install.sh), its generated pin fragments, # and the version stamp the `complete` stage ships. The PowerShell installer # keeps its own Windows-only lane (installer-tests.yml); this lane runs the -# cheap cross-checks plus a real sandboxed install against the PR checkout. -# -# Deliberately minimal: the heavy `python-deps` stage (uv sync of every -# extra) is skipped — the pm/uv machinery under it is covered by the Python -# lanes, and pulling the whole dependency graph per installer PR would make -# this lane slower than everything it protects. +# protocol/stamp cross-checks plus a whole current installer against a tiny +# application graph. The latter runs real PM/uv and generated launchers, not +# every production extra. Tool download/hash tests remain separate. on: workflow_call: @@ -23,7 +20,7 @@ concurrency: jobs: posix: - name: install.sh sandbox install + stamp verification + name: install.sh protocol + small fresh-install E2E runs-on: ubuntu-24.04 timeout-minutes: 15 # NOTE: `runner.temp` is only available in step-level env, not job-level @@ -68,7 +65,7 @@ jobs: echo "mirror=$mirror" >> "$GITHUB_OUTPUT" echo "sha=$sha" >> "$GITHUB_OUTPUT" - - name: Run the installer stages against the PR checkout + - name: Check repository/config/completion protocol (not a working install) shell: bash env: # install.sh reads HERMES_INSTALL_DIR, not INSTALL_DIR (its default @@ -89,11 +86,6 @@ jobs: } run_stage prerequisites run_stage repository - run_stage venv - # python-deps (uv sync --extra all) is deliberately skipped — the - # comment at the top of this file explains why. - run_stage node-deps - run_stage path run_stage config run_stage complete test -f "$HERMES_INSTALL_DIR/.hermes-bootstrap-complete" @@ -120,6 +112,19 @@ jobs: test "$head" = "${{ steps.source.outputs.sha }}" \ || { echo "::error::installed HEAD $head != pinned ${{ steps.source.outputs.sha }}"; exit 1; } + - name: Prepare tools for the real small-graph installer test + uses: ./.github/actions/setup-pm + + - name: Prepare isolated acceptance-test dependencies + shell: bash + run: python -m scripts.ci.python_packages pytest==9.1.1 pytest-asyncio==1.3.0 ruamel.yaml==0.18.17 packaging==26.0 + + - name: Current installer to PM worker to published command + shell: bash + env: + HERMES_TEST_FILE_RETRIES: '0' + run: bash scripts/run_tests.sh tests/test_fresh_source_install.py -q -j 1 + windows: name: install.ps1 protocol surface runs-on: windows-latest diff --git a/.github/workflows/installer-tests.yml b/.github/workflows/installer-tests.yml index 75b5668b19..f785ff2e8c 100644 --- a/.github/workflows/installer-tests.yml +++ b/.github/workflows/installer-tests.yml @@ -44,3 +44,16 @@ jobs: - name: System Node and npm compatibility (Windows PowerShell 5.1) shell: powershell run: powershell -NoProfile -ExecutionPolicy Bypass -File scripts/tests/test-install-ps1-node-compatibility.ps1 + + - name: Prepare native publication-test tools + uses: ./.github/actions/setup-pm + + - name: Prepare isolated acceptance-test dependencies + shell: bash + run: python -m scripts.ci.python_packages pytest==9.1.1 pytest-asyncio==1.3.0 ruamel.yaml==0.18.17 packaging==26.0 distlib==0.4.3 + + - name: Whole-script launcher publication (PowerShell 5.1 and 7) + shell: bash + env: + HERMES_TEST_FILE_RETRIES: '0' + run: bash scripts/run_tests.sh tests/test_source_launcher_stages.py -k powershell_stage -q -j 1 diff --git a/.github/workflows/pm-bundle.yml b/.github/workflows/pm-bundle.yml index 05e4aee21e..7e3d071c9b 100644 --- a/.github/workflows/pm-bundle.yml +++ b/.github/workflows/pm-bundle.yml @@ -136,10 +136,13 @@ jobs: path: ${{ steps.pm.outputs.uv-cache-path }} key: ${{ steps.pm.outputs.python-cache-key }} - # The payload must boot with nothing from this checkout: the staged - # venv's interpreter, cwd at the staged REPO (the desktop spawn - # convention — sys.path[0] from cwd survives relocation where the - # editable install's absolute path would not), no PYTHONPATH. - - name: Smoke test the payload + # The generated command must survive relocation without checkout env/cwd. + - name: Smoke test the relocated payload (network disabled) + if: startsWith(matrix.target.label, 'linux-') + shell: bash + run: sudo --preserve-env=PATH unshare --net bash scripts/smoke-payload.sh build/agent-payload + + - name: Smoke test the relocated payload (native launchers) + if: ${{ !startsWith(matrix.target.label, 'linux-') }} shell: bash run: bash scripts/smoke-payload.sh build/agent-payload diff --git a/agent/proxy_sources/iron_proxy.py b/agent/proxy_sources/iron_proxy.py index 19d86d606b..e8f10cac08 100644 --- a/agent/proxy_sources/iron_proxy.py +++ b/agent/proxy_sources/iron_proxy.py @@ -3,7 +3,7 @@ Sandboxes (Docker/Modal/SSH) hold only opaque proxy tokens; iron-proxy — a TLS-intercepting, default-deny egress firewall — swaps them for real credentials on the way out, so a leaked token is useless outside the trusted proxy boundary. The pinned binary is auto-installed -into ``/bin``; CA, ``proxy.yaml``, ``mappings.json``, pidfile and logs live in +into the PM tool store; CA, ``proxy.yaml``, ``mappings.json``, pidfile and logs live in ``/proxy``. Failures warn and never block agent startup. """ @@ -18,7 +18,6 @@ import platform import shutil import signal import subprocess -import tarfile import tempfile import threading import time @@ -31,15 +30,6 @@ from typing import Dict, List, Optional, Tuple logger = logging.getLogger(__name__) -# Pinned: never auto-resolve "latest" — the YAML schema may change between releases. -_IRON_PROXY_VERSION = "0.39.0" -_IRON_PROXY_RELEASE_BASE = f"https://github.com/ironsh/iron-proxy/releases/download/v{_IRON_PROXY_VERSION}" -_IRON_PROXY_CHECKSUM_NAME = "checksums.txt" -# Optional GPG verification of checksums.txt (SHA-256 alone trusts the release channel). -_IRON_PROXY_CHECKSUM_SIG_NAME = "checksums.txt.asc" -_IRON_PROXY_PUBKEY_NAME = "public-key.asc" - -_DOWNLOAD_TIMEOUT = 120 # binary is ~16MB _RUN_TIMEOUT = 30 _STARTUP_GRACE_SECONDS = 5 @@ -154,11 +144,6 @@ class TokenMapping: alias_env_names: Tuple[str, ...] = () -def _hermes_bin_dir() -> Path: - from hermes_constants import get_hermes_home - return get_hermes_home() / "bin" - - def _proxy_state_dir_ro() -> Path: # without creating it (status probes, pidfile reads) from hermes_constants import get_hermes_home return get_hermes_home() / "proxy" @@ -172,135 +157,55 @@ def _proxy_state_dir() -> Path: return d -def _platform_binary_name() -> str: - return "iron-proxy.exe" if platform.system() == "Windows" else "iron-proxy" - - -def _platform_asset_name() -> str: - """Map (uname, arch) -> ``iron-proxy___.tar.gz``; no Windows builds upstream.""" - system, machine = platform.system(), platform.machine().lower() - if os_name := {"Linux": "linux", "Darwin": "darwin"}.get(system): - arch = "arm64" if machine in ("arm64", "aarch64") else "amd64" - return f"iron-proxy_{_IRON_PROXY_VERSION}_{os_name}_{arch}.tar.gz" - if system == "Windows": - raise RuntimeError(f"iron-proxy does not ship native Windows binaries as of v{_IRON_PROXY_VERSION}. Run the proxy on a Linux/macOS host, or inside WSL.") - raise RuntimeError(f"Unsupported platform for iron-proxy auto-install: {system} {machine}") - - def find_iron_proxy(*, install_if_missing: bool = False) -> Optional[Path]: - """Managed ``/bin`` copy first, then PATH; optionally auto-install.""" - managed = _hermes_bin_dir() / _platform_binary_name() - if managed.exists() and os.access(managed, os.X_OK): - return managed + """External tools do not require PM platform support; acquire only on a miss.""" if system := shutil.which("iron-proxy"): return Path(system) - if not install_if_missing: - return None - try: - return install_iron_proxy() - except Exception as exc: # noqa: BLE001 — never block startup - logger.warning("iron-proxy auto-install failed: %s", exc) - return None + import pm + + selected = pm.installed_package("iron-proxy") + if selected is not None: + return selected.binary + if install_if_missing: + try: + pm.ensure("iron-proxy") + return pm.installed_package("iron-proxy").binary + except Exception as exc: # noqa: BLE001 — never block startup + logger.warning("iron-proxy auto-install failed: %s", exc) + return None def install_iron_proxy(*, force: bool = False) -> Path: - """Download, verify, and install the pinned binary; raises on any failure.""" - (bin_dir := _hermes_bin_dir()).mkdir(parents=True, exist_ok=True) - target = bin_dir / _platform_binary_name() - if target.exists() and not force: - return target - asset_name = _platform_asset_name() - with tempfile.TemporaryDirectory(prefix="hermes-iron-proxy-") as tmpdir: - archive_path, checksum_path = (tmp := Path(tmpdir)) / asset_name, tmp / _IRON_PROXY_CHECKSUM_NAME - logger.info("Downloading %s", f"{_IRON_PROXY_RELEASE_BASE}/{asset_name}") - _release_asset(asset_name, archive_path) - _release_asset(_IRON_PROXY_CHECKSUM_NAME, checksum_path) - # Best-effort GPG check of checksums.txt closes the release-channel tamper gap. - _verify_checksums_signature(tmp, checksum_path) - expected, actual = _expected_sha256(checksum_path, asset_name), _sha256_file(archive_path) - if expected.lower() != actual.lower(): - raise RuntimeError(f"Checksum mismatch for {asset_name}: expected {expected}, got {actual}") - with tarfile.open(archive_path, "r:gz") as tf: - member = _pick_tar_member(tf, _platform_binary_name()) - # PEP 706 data filter rejects escaping links; < 3.12 relies on _pick_tar_member's sanitization. - try: - tf.extract(member, tmp, filter="data") # noqa: S202 - except TypeError: - tf.extract(member, tmp) # noqa: S202 - extracted = tmp / member.name - # Stage then atomically rename so the binary is never visible half-written. - fd, staged = tempfile.mkstemp(dir=str(bin_dir), prefix=".iron-proxy_") - os.close(fd) - shutil.copy2(extracted, staged) - os.chmod(staged, 0o755) - os.replace(staged, target) - # A freshly-installed binary must re-probe --version on the next get_status(). + """Explicit setup/repair; PM verifies and repairs existing entries too.""" + import pm + + pm.ensure("iron-proxy", explicit=True) + target = pm.installed_package("iron-proxy").binary _VERSION_CACHE.pop(str(target), None) - logger.info("Installed iron-proxy %s at %s", _IRON_PROXY_VERSION, target) return target -def _release_asset(name: str, dest: Path) -> None: - """Download one pinned-release asset to ``dest``; RuntimeError on any URL error.""" - url = f"{_IRON_PROXY_RELEASE_BASE}/{name}" - try: - req = urllib.request.Request(url, headers={"User-Agent": "hermes-agent"}) - with urllib.request.urlopen(req, timeout=_DOWNLOAD_TIMEOUT) as resp, open(dest, "wb") as f: # noqa: S310 - shutil.copyfileobj(resp, f) - except urllib.error.URLError as exc: - raise RuntimeError(f"Failed to download {url}: {exc}") from exc - - def _verify_checksums_signature(tmp: Path, checksum_path: Path) -> bool: """Best-effort GPG check of checksums.txt in an ephemeral keyring. False (with a warning) when gpg or the signature assets are unavailable — SHA-256 stays enforced, gpg is never a hard dependency. Raises ONLY on a present-but-bad signature.""" if not (gpg := shutil.which("gpg")): logger.warning("gpg not found on PATH — skipping iron-proxy release-signature verification (SHA-256 checksum check still enforced).") return False - sig_path, pubkey_path = tmp / _IRON_PROXY_CHECKSUM_SIG_NAME, tmp / _IRON_PROXY_PUBKEY_NAME - try: - _release_asset(_IRON_PROXY_CHECKSUM_SIG_NAME, sig_path) - _release_asset(_IRON_PROXY_PUBKEY_NAME, pubkey_path) - except RuntimeError as exc: - logger.warning("iron-proxy release signature assets unavailable (%s) — skipping GPG verification (SHA-256 checksum check still enforced).", exc) + sig_path, pubkey_path = tmp / "checksums.txt.asc", tmp / "public-key.asc" + if not sig_path.is_file() or not pubkey_path.is_file(): + logger.warning("iron-proxy release signature assets unavailable — skipping GPG verification (SHA-256 checksum check still enforced).") return False - (gnupg_home := tmp / "gnupg").mkdir(mode=0o700, exist_ok=True) - gpg_base = [gpg, "--homedir", str(gnupg_home), "--batch", "--no-tty"] - if (imp := _run([*gpg_base, "--import", str(pubkey_path)], timeout=60)).returncode != 0: - logger.warning("Could not import iron-proxy signing key — skipping GPG verification (SHA-256 still enforced): %s", imp.stderr.decode("utf-8", "replace")[:200]) - return False - if (verify := _run([*gpg_base, "--verify", str(sig_path), str(checksum_path)], timeout=60)).returncode != 0: - raise RuntimeError( - f"iron-proxy checksums.txt failed GPG signature verification — refusing to install (possible release-channel tampering). gpg: {verify.stderr.decode('utf-8', 'replace')[:300]}" - ) - logger.info("Verified iron-proxy checksums.txt GPG signature.") - return True - - -def _expected_sha256(checksum_file: Path, asset_name: str) -> str: - """Parse the standard ``sha256sum`` output: `` ``.""" - - text = checksum_file.read_text(encoding="utf-8-sig", errors="replace") - for line in text.splitlines(): - parts = line.strip().split() - if len(parts) >= 2 and parts[-1] == asset_name: - return parts[0] - raise RuntimeError(f"No checksum entry for {asset_name} in {checksum_file.name}") - - -def _sha256_file(path: Path) -> str: - with open(path, "rb") as f: - return hashlib.file_digest(f, "sha256").hexdigest() - - -def _pick_tar_member(tf: tarfile.TarFile, binary_name: str) -> tarfile.TarInfo: - """Find the binary in the archive (flat or one dir deep); reject abs paths and ``..``.""" - candidates = [ - m for m in tf.getmembers() if m.isfile() and not m.name.startswith("/") and ".." not in Path(m.name).parts and Path(m.name).name == binary_name - ] - if not candidates: - raise RuntimeError(f"Could not find {binary_name} inside downloaded archive (members: {[m.name for m in tf.getmembers()[:5]]}...)") - return min(candidates, key=lambda m: len(m.name)) + with tempfile.TemporaryDirectory(prefix="hermes-iron-signature-") as gnupg_home: + gpg_base = [gpg, "--homedir", gnupg_home, "--batch", "--no-tty"] + if (imp := _run([*gpg_base, "--import", str(pubkey_path)], timeout=60)).returncode != 0: + logger.warning("Could not import iron-proxy signing key — skipping GPG verification (SHA-256 still enforced): %s", imp.stderr.decode("utf-8", "replace")[:200]) + return False + if (verify := _run([*gpg_base, "--verify", str(sig_path), str(checksum_path)], timeout=60)).returncode != 0: + raise RuntimeError( + f"iron-proxy checksums.txt failed GPG signature verification — refusing to install (possible release-channel tampering). gpg: {verify.stderr.decode('utf-8', 'replace')[:300]}" + ) + logger.info("Verified iron-proxy checksums.txt GPG signature.") + return True def _allowlisted_env() -> Dict[str, str]: diff --git a/agent/secret_sources/bitwarden.py b/agent/secret_sources/bitwarden.py index 149912d900..5e8e8dd848 100644 --- a/agent/secret_sources/bitwarden.py +++ b/agent/secret_sources/bitwarden.py @@ -1,7 +1,7 @@ """Bitwarden Secrets Manager (`bws` CLI) integration. Pulls API keys from BSM at startup so they need not live in ``~/.hermes/.env``. -``bws`` is auto-installed into ``/bin/bws`` (one pinned version, +``bws`` is auto-installed into the PM tool store (one pinned version, SHA-256-verified against the published checksum). The one bootstrap secret is the access token in ``.env``; every other key can live in BSM. One ``bws secret list `` call per fetch, cached in-process and on disk @@ -12,19 +12,12 @@ purpose: one cross-platform binary beats the ``bitwarden-sdk-secrets`` Rust whee from __future__ import annotations import base64 -import hashlib import json import logging import os -import platform import re import shutil -import subprocess -import tempfile import time -import urllib.error -import urllib.request -import zipfile from pathlib import Path from typing import Dict, List, Optional, Tuple @@ -39,12 +32,6 @@ from agent.secret_sources.base import ( logger = logging.getLogger(__name__) -# Pinned upstream version — never auto-resolve "latest": release shape (asset -# names, CLI flags) may change between majors and updates must be deliberate. -_BWS_VERSION = "2.0.0" -_BWS_RELEASE_BASE = f"https://github.com/bitwarden/sdk-sm/releases/download/bws-v{_BWS_VERSION}" -_BWS_CHECKSUM_NAME = f"bws-sha256-checksums-{_BWS_VERSION}.txt" -_BWS_DOWNLOAD_TIMEOUT = 60 _BWS_RUN_TIMEOUT = 30 # /cache/bws_cache.json holds only secret VALUES (never the access @@ -90,146 +77,30 @@ def _classify_bws_error(message: str) -> ErrorKind: # --- Binary discovery + lazy install ---------------------------------------- -def _hermes_bin_dir() -> Path: - """Where Hermes stores its managed binaries. Profile-aware.""" - from hermes_constants import get_hermes_home - - return get_hermes_home() / "bin" - - def find_bws(*, install_if_missing: bool = False) -> Optional[Path]: - """Managed ``/bin/bws`` first, then PATH, then optional auto-install.""" - managed = _hermes_bin_dir() / _platform_binary_name() - if managed.exists() and os.access(managed, os.X_OK): - return managed - system = shutil.which("bws") - if system: + """External tools do not require PM platform support; acquire only on a miss.""" + if system := shutil.which("bws"): return Path(system) + import pm + + selected = pm.installed_package("bws") + if selected is not None: + return selected.binary if install_if_missing: try: - return install_bws() + pm.ensure("bws") + return pm.installed_package("bws").binary except Exception as exc: # noqa: BLE001 — never block startup logger.warning("bws auto-install failed: %s", exc) return None -def _platform_binary_name() -> str: - return "bws.exe" if platform.system() == "Windows" else "bws" - - -def _platform_asset_name() -> str: - """Map (uname, arch, libc) → upstream asset filename (Rust target-triple style).""" - system = platform.system() - machine = platform.machine().lower() - arch = "aarch64" if machine in ("arm64", "aarch64") else "x86_64" - - if system == "Darwin": # universal binary covers Intel + Apple Silicon - return f"bws-macos-universal-{_BWS_VERSION}.zip" - if system == "Windows": - return f"bws-{arch}-pc-windows-msvc-{_BWS_VERSION}.zip" - if system == "Linux": - # glibc default; musl only if ldd says so (a wrong guess surfaces as a loader error). - libc = "gnu" - try: - res = subprocess.run(["ldd", "--version"], capture_output=True, text=True, encoding='utf-8', - errors='replace', timeout=2, stdin=subprocess.DEVNULL) - if "musl" in (res.stdout + res.stderr).lower(): - libc = "musl" - except (OSError, subprocess.TimeoutExpired): - pass - return f"bws-{arch}-unknown-linux-{libc}-{_BWS_VERSION}.zip" - - raise RuntimeError(f"Unsupported platform for bws auto-install: {system} {machine}") - - def install_bws(*, force: bool = False) -> Path: - """Download, verify, and install the pinned ``bws`` binary; raises on any failure - (the auto-install path catches; the setup wizard shows the error).""" - bin_dir = _hermes_bin_dir() - bin_dir.mkdir(parents=True, exist_ok=True) - target = bin_dir / _platform_binary_name() - if target.exists() and not force: - return target + """Explicit setup/repair; PM re-verifies even a warm entry (including force).""" + import pm - asset_name = _platform_asset_name() - with tempfile.TemporaryDirectory(prefix="hermes-bws-") as tmpdir: - tmp = Path(tmpdir) - zip_path = tmp / asset_name - checksum_path = tmp / _BWS_CHECKSUM_NAME - - logger.info("Downloading %s", f"{_BWS_RELEASE_BASE}/{asset_name}") - _http_download(f"{_BWS_RELEASE_BASE}/{asset_name}", zip_path) - _http_download(f"{_BWS_RELEASE_BASE}/{_BWS_CHECKSUM_NAME}", checksum_path) - - expected = _expected_sha256(checksum_path, asset_name) - actual = _sha256_file(zip_path) - if expected.lower() != actual.lower(): - raise RuntimeError(f"Checksum mismatch for {asset_name}: expected {expected}, got {actual}") - - with zipfile.ZipFile(zip_path) as zf: - member = _pick_zip_member(zf, _platform_binary_name()) - extracted = _safe_extract_member(zf, member, tmp) - - # Stage in the final directory so the rename can't cross filesystems. - fd, staged = tempfile.mkstemp(dir=str(bin_dir), prefix=".bws_") - os.close(fd) - shutil.copy2(extracted, staged) - os.chmod(staged, 0o755) - os.replace(staged, target) - - logger.info("Installed bws %s at %s", _BWS_VERSION, target) - return target - - -def _http_download(url: str, dest: Path) -> None: - req = urllib.request.Request(url, headers={"User-Agent": "hermes-agent"}) - try: - with urllib.request.urlopen(req, timeout=_BWS_DOWNLOAD_TIMEOUT) as resp, open(dest, "wb") as f: # noqa: S310 - shutil.copyfileobj(resp, f) - except urllib.error.URLError as exc: - raise RuntimeError(f"Failed to download {url}: {exc}") from exc - - -def _expected_sha256(checksum_file: Path, asset_name: str) -> str: - """Parse standard ``sha256sum`` output (`` `` per line).""" - for line in checksum_file.read_text(encoding="utf-8-sig", errors="replace").splitlines(): - parts = line.strip().split() - if len(parts) >= 2 and parts[-1] == asset_name: - return parts[0] - raise RuntimeError(f"No checksum entry for {asset_name} in {checksum_file.name}") - - -def _sha256_file(path: Path) -> str: - h = hashlib.sha256() - with open(path, "rb") as f: - for chunk in iter(lambda: f.read(65536), b""): - h.update(chunk) - return h.hexdigest() - - -def _pick_zip_member(zf: zipfile.ZipFile, binary_name: str) -> str: - """Find the binary in the zip; tolerate a top-level dir, prefer the shortest path.""" - candidates = [n for n in zf.namelist() if n.split("/")[-1] == binary_name] - if not candidates: - raise RuntimeError(f"Could not find {binary_name} inside downloaded archive " - f"(members: {zf.namelist()[:5]}...)") - return min(candidates, key=len) - - -def _safe_extract_member(zf: zipfile.ZipFile, member: str, dest_dir: Path) -> Path: - """Extract one member, refusing zip-slip: ``ZipFile.extract`` never verifies the - joined path stays inside ``dest_dir``, so containment is checked here first.""" - dest_root = os.path.realpath(dest_dir) - target = os.path.realpath(os.path.join(dest_root, member)) - try: # commonpath raises for e.g. different Windows drives — treat as escape - contained = os.path.commonpath([dest_root, target]) == dest_root and target != dest_root - except ValueError: - contained = False - if not contained: - raise RuntimeError(f"Refusing to extract unsafe archive member {member!r}: " - f"it escapes the extraction directory") - zf.extract(member, dest_root) - return Path(target) + pm.ensure("bws", explicit=True) + return pm.installed_package("bws").binary # --- Encrypted last-good cache (opt-in) ------------------------------------- diff --git a/apps/desktop/electron/backend-python-coherence.test.ts b/apps/desktop/electron/backend-python-coherence.test.ts deleted file mode 100644 index a40734ee2a..0000000000 --- a/apps/desktop/electron/backend-python-coherence.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import assert from 'node:assert/strict' -import fs from 'node:fs' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -import { test } from 'vitest' - -// ── Regression guard: backend interpreter / site-packages coherence ───────── -// -// Live repro (2026-08-23, macOS dev run): the checkout had BOTH venvs — -// .venv/ → Python 3.12 (dev tooling) -// venv/ → Python 3.11 (the CLI install venv, owns the real deps) -// -// findPythonForRoot() prefers `.venv/bin/python` (3.12), but -// createPythonBackend() hardcodes `venvRoot = path.join(root, 'venv')` and -// puts venv/lib/python3.11/site-packages on PYTHONPATH. The 3.12 interpreter -// then imports 3.11-compiled native wheels and dies on the FIRST import: -// ImportError: No module named 'pydantic_core._pydantic_core' -// → backend exits(1) before ready → "Gateway offline" → renderer falls back -// to a dead 127.0.0.1:9119 and every profile fails to activate. -// -// The invariant these tests pin down: the venv whose interpreter is selected -// and the venv whose site-packages go on PYTHONPATH must be THE SAME venv. -// One resolver must own that decision (AGENTS.md "observable ladder" rule 6). - -const here = path.dirname(fileURLToPath(import.meta.url)) -const mainTsSource = fs.readFileSync(path.join(here, 'main.ts'), 'utf8') - -function extractFunction(source: string, name: string): string { - const start = source.indexOf(`function ${name}(`) - assert.notEqual(start, -1, `function ${name} not found in main.ts`) - - // Slice to the next top-level `function ` declaration — crude but stable - // for the flat function layout main.ts uses. - const rest = source.slice(start) - const next = rest.slice(1).search(/\nfunction /) - - return next === -1 ? rest : rest.slice(0, next + 1) -} - -test('findPythonForRoot preference order includes .venv before venv (context for the coherence tests)', () => { - const fn = extractFunction(mainTsSource, 'findPythonForRoot') - const venvIdx = fn.indexOf("'.venv'") - const plainIdx = fn.indexOf("'venv'") - - assert.notEqual(venvIdx, -1, 'expected findPythonForRoot to probe .venv') - assert.notEqual(plainIdx, -1, 'expected findPythonForRoot to probe venv') - assert.ok( - venvIdx < plainIdx, - '.venv is probed before venv — this ordering is what the hardcoded venvRoot below disagrees with' - ) -}) - -// Fixed: createPythonBackend derives venvRoot from the selected interpreter -// via venvRootForPython(python, root), falling back to root/venv only for a -// system python. This test guards against re-hardcoding the venv path. -test('createPythonBackend derives venvRoot from the selected interpreter, not a hardcoded venv path', () => { - const fn = extractFunction(mainTsSource, 'createPythonBackend') - - // The buggy shape: interpreter picked by findPythonForRoot (may be .venv), - // while venvRoot/PYTHONPATH is unconditionally root/venv. - const hardcodesVenv = /venvRoot\s*=\s*path\.join\(root,\s*'venv'\)/.test(fn) - const derivesFromPython = /findPythonForRoot|python/.test(fn) && !hardcodesVenv - - assert.ok( - derivesFromPython, - 'createPythonBackend hardcodes venvRoot=path.join(root, "venv") while findPythonForRoot may select .venv/bin/python — ' + - 'a root with both venvs gets a 3.12 interpreter with 3.11 site-packages on PYTHONPATH and crashes on the first native import' - ) -}) - -// Pure-logic mirror of the same invariant, testable without main.ts exports: -// given a root where BOTH .venv and venv exist, whatever venv the interpreter -// came from must be the venv used for site-packages. This encodes the fix's -// contract so the implementation can be extracted against it later. -export function coherentVenvRootForPython(pythonPath: string, root: string): string | null { - // The venv root is the directory two levels up from /bin/python - // (Scripts/python.exe on Windows). A system interpreter (outside `root`) - // is NOT a venv — pairing it with any venv's site-packages needs the same - // version check, so it returns null here. - const posix = pythonPath.match(/^(.*)\/bin\/python[0-9.]*$/) - const win = pythonPath.match(/^(.*)[\\/]Scripts[\\/]python\.exe$/i) - const candidate = posix?.[1] ?? win?.[1] ?? null - - if (!candidate) { - return null - } - - const normalizedRoot = root.replace(/\\/g, '/').replace(/\/+$/, '') - const normalizedCandidate = candidate.replace(/\\/g, '/') - - return normalizedCandidate.startsWith(`${normalizedRoot}/`) ? candidate : null -} - -test('coherentVenvRootForPython maps a selected interpreter back to ITS venv root', () => { - assert.equal(coherentVenvRootForPython('/repo/.venv/bin/python', '/repo'), '/repo/.venv') - assert.equal(coherentVenvRootForPython('/repo/venv/bin/python', '/repo'), '/repo/venv') - assert.equal(coherentVenvRootForPython('C:\\repo\\venv\\Scripts\\python.exe', 'C:\\repo'), 'C:\\repo\\venv') - assert.equal(coherentVenvRootForPython('/usr/bin/python3', '/repo'), null) -}) - -test('dual-venv root: site-packages must come from the venv that owns the selected interpreter', () => { - // Simulates the live repro: interpreter resolved to .venv (3.12), so the - // ONLY coherent venvRoot for PYTHONPATH is .venv — never the sibling venv. - const selected = '/repo/.venv/bin/python' - const venvRoot = coherentVenvRootForPython(selected, '/repo') - - assert.equal(venvRoot, '/repo/.venv') - assert.notEqual( - venvRoot, - '/repo/venv', - 'pairing a .venv interpreter with venv/ site-packages is the crash from the live repro' - ) -}) diff --git a/apps/desktop/electron/bootstrap-platform.test.ts b/apps/desktop/electron/bootstrap-platform.test.ts index 3c736e7017..2ca517fb51 100644 --- a/apps/desktop/electron/bootstrap-platform.test.ts +++ b/apps/desktop/electron/bootstrap-platform.test.ts @@ -3,7 +3,6 @@ import assert from 'node:assert/strict' import { test } from 'vitest' import { - bundledRuntimeImportCheck, detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment, @@ -27,12 +26,6 @@ test('isWindowsBinaryPathInWsl blocks Windows binary types on WSL', () => { assert.equal(isWindowsBinaryPathInWsl('/mnt/c/Tools/hermes.exe', { isWsl: false }), false) }) -test('bundledRuntimeImportCheck selects platform-specific import checks', () => { - assert.equal(bundledRuntimeImportCheck('win32'), 'import fastapi, uvicorn, winpty') - assert.equal(bundledRuntimeImportCheck('darwin'), 'import fastapi, uvicorn, ptyprocess') - assert.equal(bundledRuntimeImportCheck('linux'), 'import fastapi, uvicorn, ptyprocess') -}) - test('detectRemoteDisplay keeps GPU on for local sessions', () => { // Plain local X11, Wayland, native Windows, native macOS — no remote signal. assert.equal(detectRemoteDisplay({ env: { DISPLAY: ':0' }, platform: 'linux' }), null) diff --git a/apps/desktop/electron/bootstrap-platform.ts b/apps/desktop/electron/bootstrap-platform.ts index 34ba0e76b7..9966c39518 100644 --- a/apps/desktop/electron/bootstrap-platform.ts +++ b/apps/desktop/electron/bootstrap-platform.ts @@ -40,10 +40,6 @@ function isWindowsBinaryPathInWsl( ) } -function bundledRuntimeImportCheck(platform = process.platform) { - return platform === 'win32' ? 'import fastapi, uvicorn, winpty' : 'import fastapi, uvicorn, ptyprocess' -} - const GPU_OVERRIDE_ON = new Set(['1', 'true', 'yes', 'on']) const GPU_OVERRIDE_OFF = new Set(['0', 'false', 'no', 'off']) @@ -142,7 +138,6 @@ function resolveLinuxPasswordStore(options: { env?: NodeJS.ProcessEnv; platform? } export { - bundledRuntimeImportCheck, detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment, diff --git a/apps/desktop/electron/fixtures/source-backend.py b/apps/desktop/electron/fixtures/source-backend.py new file mode 100644 index 0000000000..28e9b2fe49 --- /dev/null +++ b/apps/desktop/electron/fixtures/source-backend.py @@ -0,0 +1,93 @@ +"""PM publication plus the real backend, using prepared dependencies offline. + +Only acquisition is substituted: real uv/Python come from the test host. +A local wheel exposes its prepared dependency closure through a load-bearing +.pth. PM's worker creates and selects the generation and the real publisher +writes the launcher. No production dependency install or fake server is used. +""" +from __future__ import annotations + +import importlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import zipfile + + +def main() -> None: + repository = Path(__file__).resolve().parents[4] + temp = Path(sys.argv[1]) + root = Path(os.environ["HERMES_HOME"]) / "hermes-agent" + root.mkdir(parents=True) + for name in ("hermes_cli", "pm", "agent", "tools", "gateway", "tui_gateway", "cron", "plugins"): + shutil.copytree(repository / name, root / name, + ignore=shutil.ignore_patterns("__pycache__", "*.pyc")) + for source in repository.glob("*.py"): + shutil.copy2(source, root / source.name) + sys.path.insert(0, str(root)) + uv = shutil.which("uv") + assert uv, "real uv must be prepared on PATH" + sites = [p for p in sys.path if p.endswith("site-packages")] + assert sites, "run with the prepared Hermes Python dependency environment" + wheels = temp / "wheels" + wheels.mkdir() + dist = "desktop_backend_deps-1.dist-info" + entries = { + "desktop_backend_deps.pth": "\n".join(sites) + "\n", + "desktop_backend_proof.py": "VALUE = 'selected by PM'\n", + f"{dist}/METADATA": "Metadata-Version: 2.1\nName: desktop-backend-deps\nVersion: 1\n", + f"{dist}/WHEEL": "Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n", + } + entries[f"{dist}/RECORD"] = "".join(f"{name},,\n" for name in entries) + with zipfile.ZipFile(wheels / "desktop_backend_deps-1-py3-none-any.whl", "w") as wheel: + for name, content in entries.items(): + wheel.writestr(name, content) + (root / "pyproject.toml").write_text( + '[project]\nname="desktop-startup-proof"\nversion="1"\nrequires-python=">=3.11"\n' + 'dependencies=["desktop-backend-deps==1"]\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") + subprocess.run([uv, "lock", "--project", str(root), "--python", sys.executable], + check=True, stdout=sys.stderr, timeout=30) + import pm + from pm import paths + from pm.lock import Facts + from pm.store import current_target, tree_digest + from hermes_cli.runtime_paths import selected_venv + from hermes_cli._launchers import ensure_install_launchers + + worker = root / "pm" / "worker.py" + worker_code = ( + "import runpy, sys; from pathlib import Path; " + f"sys.path.insert(0, {str(root)!r}); import pm._uv; " + f"pm._uv._toolchain = lambda **kwargs: (Path({uv!r}), Path({sys.executable!r})); " + f"runpy.run_path({str(worker)!r}, run_name='__main__')" + ) + client = importlib.import_module("pm.client") + setattr(client, "runtime_command", lambda *args, **kwargs: [sys.executable, "-I", "-c", worker_code]) + pm.sync_venv(explicit=True, project_root=root) + selected = selected_venv(root) + assert selected and selected.is_relative_to(Path(os.environ["HERMES_HOME"])) + store = paths.store_root() + entry = store / "python-fixture" + python = entry / "bin" / "python3" + python.parent.mkdir(parents=True) + python.symlink_to(getattr(sys, "_base_executable", sys.executable)) + Facts(paths.facts_path()).record("python", "fixture", entry.name, {"PATH": [str(python.parent)]}, + store, target=current_target(), digest=tree_digest(entry)) + launchers = ensure_install_launchers(root, root / ".hermes" / "bin") + assert launchers + (root / "desktop_launch_probe.py").write_text( + "import desktop_backend_proof, json, sys\n" + "print(json.dumps({'python': sys.executable, 'module': desktop_backend_proof.__file__, " + "'value': desktop_backend_proof.VALUE}))\n", encoding="utf-8") + print(json.dumps({"root": str(root), "launcher": str(root / ".hermes/bin/hermes"), + "python": str(python), "selected": str(selected)})) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 69554ade9e..ce5656aae6 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -31,7 +31,7 @@ import { systemPreferences } from 'electron' -import { classifyActiveRuntime } from './active-runtime-state' +import { type ActiveRuntimeState, classifyActiveRuntime } from './active-runtime-state' import { destroyKeepaliveAgents, downloadAgentFor, jsonAgentFor, withRetry } from './api-transport' import { appIconCandidates, resolveAppIcon } from './app-icon' import { stageAppInstallerFile } from './app-installer-file' @@ -389,6 +389,7 @@ import { SESSION_WINDOW_MIN_WIDTH } from './session-windows' import { ensureLoginShellPath } from './shell-path' +import { createSourcePythonBackend, resolveSourceInstallationBackend, type SourceBackend } from './source-backend' import { createBootstrapCoordinator, sshConfigFingerprint } from './ssh-bootstrap-coordinator' import { collectSshConfigHosts, parseSshGOutput } from './ssh-config' import { createSshProbeConnection, pickLocalPort, redactSecrets, SshConnection } from './ssh-connection' @@ -420,7 +421,6 @@ import { } from './updater' import { observeUpdaterHandoff, - resolveInstallationLauncher, resolveStagedUpdaterBinary, spawnUpdaterProcess, stagedUpdaterSupportsPrewrittenMarker @@ -2718,35 +2718,6 @@ function getVenvPython(venvRoot) { return path.join(venvRoot, IS_WINDOWS ? path.join('Scripts', 'python.exe') : path.join('bin', 'python')) } -// Map a selected interpreter back to the venv that OWNS it (the directory -// above bin/ or Scripts/), but only when that venv lives inside `root`. -// Returns null for system pythons — they own no site-packages we should mount. -// -// This exists because findPythonForRoot() probes `.venv` before `venv`, and a -// checkout can legitimately have BOTH (dev tooling venv + the CLI install -// venv, possibly on different Python versions). The interpreter and the -// site-packages placed on PYTHONPATH must come from the SAME venv: pairing a -// .venv 3.12 python with venv/lib/python3.11/site-packages makes the backend -// die on its first native import (pydantic_core) before the gateway binds — -// the renderer then reports "Gateway offline" on every profile. -function venvRootForPython(python: string, root: string) { - const parent = path.dirname(python) - const binName = path.basename(parent).toLowerCase() - - if (binName !== 'bin' && binName !== 'scripts') { - return null - } - - const candidate = path.dirname(parent) - const relative = path.relative(root, candidate) - - if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) { - return null - } - - return candidate -} - // Windows console-window flashes are governed by the *parent's* console, not by // each child spawn. A GUI-subsystem parent (pythonw.exe) has no console, so every // console-subsystem child it spawns (git, gh, cmd, ...) must allocate its own — @@ -4115,25 +4086,22 @@ function readBootstrapMarker() { // ever having written the bootstrap marker -- so we must be able to recognise // "already installed" off the filesystem alone, not just the marker. function isSourceRuntimeUsable(root: string): boolean { - const launcher: string | null = resolveInstallationLauncher(root, IS_WINDOWS, HERMES_HOME) - - return isHermesSourceRoot(root) && launcher !== null && verifyHermesCli( - isCommandScript(launcher) ? `"${launcher}"` : launcher, - { shell: isCommandScript(launcher) } - ) + return resolveSourceInstallationBackend(root, [], { hermesHome: HERMES_HOME }) !== null } function isActiveRuntimeUsable(): boolean { return isSourceRuntimeUsable(ACTIVE_HERMES_ROOT) } -function activeRuntimeState() { +function activeRuntimeState( + backend: SourceBackend | null = resolveSourceInstallationBackend(ACTIVE_HERMES_ROOT, [], { hermesHome: HERMES_HOME }) +): ActiveRuntimeState { // We DELIBERATELY do NOT verify that the checkout is currently at the // pinned commit -- users update via the in-app update path or `hermes // update`, which moves HEAD legitimately. The marker only attests "a // desktop-managed bootstrap ran here at least once"; runtime usability is // what decides whether we can actually launch. - const state = classifyActiveRuntime(readBootstrapMarker(), BOOTSTRAP_MARKER_SCHEMA_VERSION, isActiveRuntimeUsable()) + const state: ActiveRuntimeState = classifyActiveRuntime(readBootstrapMarker(), BOOTSTRAP_MARKER_SCHEMA_VERSION, backend !== null) // The canonical install stamp (written next to the runtime by the bootstrap) // tells the UI where this runtime came from. Prefer it over the marker so @@ -4414,57 +4382,7 @@ function writeDefaultProjectDir(dir) { } } -function createPythonBackend(root, label, backendArgs, options: any = {}) { - const python = findPythonForRoot(root) - - if (!python) { - return null - } - - // The venv interpreter self-locates (pyvenv.cfg) and `cwd: root` puts the - // checkout first on sys.path for `-m hermes_cli.main`. No PYTHONPATH. - const venvRoot = venvRootForPython(python, root) ?? path.join(root, 'venv') - const venvPython = getVenvPython(venvRoot) - const command = IS_WINDOWS && fileExists(venvPython) ? venvPython : python - - return { - kind: 'python', - label, - command, - args: ['-m', 'hermes_cli.main', ...backendArgs], - env: buildDesktopBackendEnv(), - root, - bootstrap: Boolean(options.bootstrap), - shell: false, - // A resolved local runtime — already on this machine (checkout or - // installed). The setup choice's local card reads this to offer the - // "use existing" variant instead of an install. - local: 'installed' - } -} - -// createActiveBackend — build a backend pointing at ACTIVE_HERMES_ROOT, the -// canonical install location shared with the CLI installer. The venv at -// VENV_ROOT may not exist yet on first run; bootstrap=true tells -// ensureRuntime() to create / refresh it before launch. -function createActiveBackend(backendArgs) { - const venvPython = getVenvPython(VENV_ROOT) - const command = fileExists(venvPython) ? venvPython : findSystemPython() - - return { - kind: 'python', - label: `Hermes at ${ACTIVE_HERMES_ROOT}`, - command, - args: ['-m', 'hermes_cli.main', ...backendArgs], - env: buildDesktopBackendEnv(), - root: ACTIVE_HERMES_ROOT, - bootstrap: true, - shell: false, - local: 'installed' - } -} - -function resolveHermesBackend(backendArgs) { +function resolveHermesBackend(backendArgs: string[]): ResolvedHermesBackend { const payload = bundledPayload(process.resourcesPath) if (payload) { @@ -4487,10 +4405,10 @@ function resolveHermesBackend(backendArgs) { // 1. Explicit override -- HERMES_DESKTOP_HERMES_ROOT points at a developer // checkout. Honour it as-is (no bootstrap; the user is driving). - const overrideRoot = process.env.HERMES_DESKTOP_HERMES_ROOT && path.resolve(process.env.HERMES_DESKTOP_HERMES_ROOT) + const overrideRoot: string | undefined = process.env.HERMES_DESKTOP_HERMES_ROOT && path.resolve(process.env.HERMES_DESKTOP_HERMES_ROOT) if (overrideRoot && isHermesSourceRoot(overrideRoot)) { - const backend = createPythonBackend(overrideRoot, `Hermes source at ${overrideRoot}`, backendArgs) + const backend: SourceBackend | null = createSourcePythonBackend(overrideRoot, findPythonForRoot(overrideRoot), backendArgs) if (backend) { return backend @@ -4502,7 +4420,7 @@ function resolveHermesBackend(backendArgs) { // installed `hermes` on PATH so local Python edits are actually exercised. // (In dev with no checkout, SOURCE_REPO_ROOT won't pass isHermesSourceRoot.) if (!IS_PACKAGED && isHermesSourceRoot(SOURCE_REPO_ROOT)) { - const backend = createPythonBackend(SOURCE_REPO_ROOT, `Hermes source at ${SOURCE_REPO_ROOT}`, backendArgs) + const backend: SourceBackend | null = createSourcePythonBackend(SOURCE_REPO_ROOT, findPythonForRoot(SOURCE_REPO_ROOT), backendArgs) if (backend) { return backend @@ -4513,20 +4431,21 @@ function resolveHermesBackend(backendArgs) { // %LOCALAPPDATA%\\hermes\\hermes-agent (Windows) or ~/.hermes/hermes-agent. // A valid bootstrap marker proves Desktop finished the first-run install // flow, but marker provenance is NOT the same thing as runtime usability: - // the CLI can create the exact same repo+venv layout, and older desktop + // the CLI can publish the same installation launcher, and older desktop // builds could leave a healthy install behind without the marker. If the // active runtime is usable, launch it directly; only fall through to // bootstrap when the runtime itself is unusable. - const activeRuntime = activeRuntimeState() + const activeBackend: SourceBackend | null = resolveSourceInstallationBackend(ACTIVE_HERMES_ROOT, backendArgs, { hermesHome: HERMES_HOME }) + const activeRuntime: ActiveRuntimeState = activeRuntimeState(activeBackend) - if (activeRuntime.shouldUseActiveRuntime && !bootstrapRepairRequested) { + if (activeBackend && !bootstrapRepairRequested) { if (!activeRuntime.hasValidMarker) { rememberLog( `[bootstrap] Active Hermes runtime at ${ACTIVE_HERMES_ROOT} is usable but the bootstrap marker is missing or stale; skipping first-run bootstrap.` ) } - return createActiveBackend(backendArgs) + return activeBackend } if (bootstrapRepairRequested) { @@ -4622,6 +4541,8 @@ interface ResolvedHermesBackend { root?: string installStamp?: Readonly | null activeRoot?: string + isPackaged?: boolean + platform?: NodeJS.Platform readyFile?: boolean } @@ -4750,50 +4671,11 @@ async function ensureRuntime(backend: ResolvedHermesBackend): Promise { + if (child.exitCode !== null || child.signalCode !== null) { + return + } + + await new Promise((resolve: () => void): void => { + const timer: NodeJS.Timeout = setTimeout((): void => { + child.kill('SIGKILL') + }, 5_000) + + child.once('exit', (): void => { + clearTimeout(timer) + resolve() + }) + child.kill() + }) +} + +async function ping(port: number, token: string): Promise { + const socket: WebSocket = new WebSocket(`ws://127.0.0.1:${port}/api/ws?token=${token}`) + + try { + return await new Promise((resolve: (value: unknown) => void, reject: (error: Error) => void): void => { + const timer: NodeJS.Timeout = setTimeout((): void => { + reject(new Error('RPC timed out')) + }, 15_000) + + socket.once('error', (error: Error): void => { + clearTimeout(timer) + reject(error) + }) + socket.once('open', (): void => { + socket.send(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'ping', params: {} })) + }) + socket.on('message', (data: WebSocket.RawData): void => { + const response: { id?: number; result?: unknown; error?: unknown } = JSON.parse(data.toString()) + + if (response.id === 1) { + clearTimeout(timer) + resolve(response) + } + }) + }) + } finally { + socket.terminate() + } +} + +test.skipIf(process.platform === 'win32')( + 'a PM source launcher reaches real health and RPC without adopting a legacy venv (POSIX)', + async (): Promise => { + const temp: string = fs.mkdtempSync(path.join(os.tmpdir(), 'desktop-pm-start-')) + const home: string = path.join(temp, 'home with spaces') + + const env: NodeJS.ProcessEnv = Object.fromEntries( + Object.entries(process.env).filter( + ([key]: [string, string | undefined]): boolean => !/^(HERMES_|PYTHON|UV_|VIRTUAL_ENV|XDG_)/.test(key) + ) + ) + + Object.assign(env, { + HOME: home, + USERPROFILE: home, + HERMES_HOME: path.join(home, '.hermes'), + HERMES_RUNTIME_DIR: path.join(temp, 'tools'), + HERMES_DISABLE_LAZY_INSTALLS: '1', + XDG_CONFIG_HOME: path.join(temp, 'config'), + XDG_CONFIG_DIRS: path.join(temp, 'config'), + PYTHONDONTWRITEBYTECODE: '1', + UV_CACHE_DIR: path.join(temp, 'cache'), + UV_OFFLINE: '1' + }) + const python: string = process.env.HERMES_PYTHON || 'python3' + const fixtureScript: string = path.join(import.meta.dirname, 'fixtures', 'source-backend.py') + const token: string = 'desktop-pm-contract' + env.HERMES_DASHBOARD_SESSION_TOKEN = token + vi.stubEnv('HOME', home) + + try { + const fixture: Fixture = JSON.parse( + execFileSync(python, ['-I', fixtureScript, temp], { + cwd: temp, + env, + encoding: 'utf8', + timeout: 90_000 + }) + ) as Fixture + + assert.equal(fs.existsSync(path.join(fixture.root, 'venv')), false) + assert.equal(fs.existsSync(path.join(fixture.root, '.venv')), false) + + const origin: { python: string; module: string; value: string } = JSON.parse( + execFileSync(fixture.launcher, ['--run-module', 'desktop_launch_probe'], { + cwd: temp, + env, + encoding: 'utf8', + timeout: 15_000 + }) + ) + + assert.equal(origin.python, fixture.python) + assert.ok(origin.module.startsWith(`${fixture.selected}${path.sep}`)) + assert.equal(origin.value, 'selected by PM') + + for (const poisoned of [false, true]) { + const poison: string = path.join(temp, 'legacy-python-used') + + if (poisoned) { + for (const suffix of ['bin/python', 'Scripts/python.exe']) { + const oldPython: string = path.join(fixture.root, 'venv', suffix) + fs.mkdirSync(path.dirname(oldPython), { recursive: true }) + fs.writeFileSync(oldPython, `#!/bin/sh\ntouch '${poison}'\nexit 93\n`, { mode: 0o755 }) + } + } + + const backend: SourceBackend | null = resolveSourceInstallationBackend(fixture.root, serveBackendArgs(), { + hermesHome: env.HERMES_HOME, + env + }) + + assert.ok(backend, 'a published, runnable PM installation must be accepted') + assert.equal(backend.command, fixture.launcher) + assert.equal(backend.bootstrap, false) + + const child: ChildProcess = spawn(backend.command, backend.args, { + cwd: temp, + env: { ...env, ...backend.env }, + shell: backend.shell, + stdio: ['ignore', 'pipe', 'pipe'] + }) + + let output: string = '' + child.stdout?.on('data', (data: Buffer): void => { + output += data.toString() + }) + child.stderr?.on('data', (data: Buffer): void => { + output += data.toString() + }) + + try { + const port: number = (await waitForDashboardPort(child, 45_000)) as number + + const response: Response = await fetch(`http://127.0.0.1:${port}/api/health`, { + headers: { 'X-Hermes-Session-Token': token } + }) + + assert.equal(response.status, 200, output) + assert.deepEqual(await ping(port, token), { jsonrpc: '2.0', id: 1, result: { pong: true } }) + assert.equal(fs.existsSync(poison), false, 'the stale checkout interpreter must never execute') + console.info('PM backend verified', { poisoned, port, health: response.status, origin }) + } catch (error: unknown) { + throw new Error(`${String(error)}\n${output}`) + } finally { + await stop(child) + } + + if (poisoned) { + const source: SourceBackend | null = createSourcePythonBackend(fixture.root, fixture.python, ['--version'], { + isWindows: true, + env + }) + + assert.ok(source) + assert.equal(source.command, fixture.python) + assert.match( + execFileSync(source.command, source.args, { + cwd: source.root, + env: { ...env, ...source.env }, + encoding: 'utf8', + timeout: 15_000 + }), + /Hermes/ + ) + assert.equal(fs.existsSync(poison), false) + } + } + + fs.unlinkSync(fixture.launcher) + assert.equal( + resolveSourceInstallationBackend(fixture.root, serveBackendArgs(), { hermesHome: env.HERMES_HOME, env }), + null, + 'a missing PM command must not fall back to the stale venv' + ) + } finally { + fs.rmSync(temp, { recursive: true, force: true }) + vi.unstubAllEnvs() + } + }, + 150_000 +) + +test('Windows console selection uses only the selected interpreter directory', (): void => { + const temp: string = fs.mkdtempSync(path.join(os.tmpdir(), 'desktop-console-policy-')) + const root: string = path.join(temp, 'repo') + const selected: string = path.join(temp, 'external', 'pythonw.exe') + const consolePython: string = path.join(path.dirname(selected), 'python.exe') + + try { + fs.mkdirSync(path.join(root, 'venv', 'Scripts'), { recursive: true }) + fs.writeFileSync(path.join(root, 'venv', 'Scripts', 'python.exe'), 'stale') + fs.mkdirSync(path.dirname(selected), { recursive: true }) + fs.writeFileSync(selected, '') + assert.equal(createSourcePythonBackend(root, selected, [], { isWindows: true })?.command, selected) + fs.writeFileSync(consolePython, '') + const backend: SourceBackend | null = createSourcePythonBackend(root, selected, ['serve'], { isWindows: true }) + assert.equal(backend?.command, consolePython) + assert.equal(backend?.env.PYTHONPATH, '') + assert.equal(backend?.env.PYTHONHOME, '') + assert.equal(createSourcePythonBackend(root, selected, [], { isWindows: false })?.command, selected) + assert.equal(createSourcePythonBackend(root, null, []), null) + } finally { + fs.rmSync(temp, { recursive: true, force: true }) + } +}) diff --git a/apps/desktop/electron/source-backend.ts b/apps/desktop/electron/source-backend.ts new file mode 100644 index 0000000000..f1d6884532 --- /dev/null +++ b/apps/desktop/electron/source-backend.ts @@ -0,0 +1,105 @@ +import { existsSync } from 'node:fs' +import path from 'node:path' + +import { buildDesktopBackendEnv } from './backend-env' +import { execProbeSync, PROBE_TIMEOUT_MS } from './backend-probes' +import { resolveInstallationLauncher } from './updater-process' + +export interface SourceBackend { + kind: 'command' | 'python' + label: string + command: string + args: string[] + env: NodeJS.ProcessEnv + root: string + bootstrap: false + shell: boolean + local: 'installed' +} + +interface SourceOptions { + isWindows?: boolean + env?: NodeJS.ProcessEnv +} + +/** Keep the validated command. PM owns interpreter and generation selection. */ +export function resolveSourceInstallationBackend( + root: string, + args: string[], + options: SourceOptions & { hermesHome?: string } = {} +): SourceBackend | null { + if (!existsSync(path.join(root, 'hermes_cli', 'main.py'))) { + return null + } + + const isWindows: boolean = options.isWindows ?? process.platform === 'win32' + const launcher: string | null = resolveInstallationLauncher(root, isWindows, options.hermesHome) + + if (!launcher) { + return null + } + + const shell: boolean = isWindows && /\.(cmd|bat)$/i.test(launcher) + const command: string = shell ? `"${launcher}"` : launcher + const env: NodeJS.ProcessEnv = buildDesktopBackendEnv({ currentEnv: options.env ?? process.env }) + + try { + execProbeSync(command, ['--version'], { + cwd: root, + env: { ...process.env, ...options.env, ...env }, + shell, + stdio: 'ignore', + timeout: PROBE_TIMEOUT_MS, + windowsHide: true + }) + } catch { + return null + } + + return { + kind: 'command', + label: `Hermes at ${root}`, + command, + args: [...args], + env, + root, + bootstrap: false, + shell, + local: 'installed' + } +} + +/** Developer overrides retain their interpreter, even outside the checkout. */ +export function createSourcePythonBackend( + root: string, + python: string | null, + args: string[], + options: SourceOptions = {} +): SourceBackend | null { + if (!python) { + return null + } + + let command: string = python + + if ((options.isWindows ?? process.platform === 'win32') && /[\\/]pythonw\.exe$/i.test(python)) { + // Use only the console interpreter beside the selected windowless one. + const consolePython: string = python.replace(/pythonw\.exe$/i, 'python.exe') + + if (existsSync(consolePython)) { + command = consolePython + } + } + + return { + kind: 'python', + label: `Hermes source at ${root}`, + command, + args: ['-m', 'hermes_cli.main', ...args], + env: buildDesktopBackendEnv({ currentEnv: options.env ?? process.env }), + root, + bootstrap: false, + shell: false, + local: 'installed' + } +} diff --git a/gateway/run.py b/gateway/run.py index 35ab82ca1a..a0baea445e 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1515,13 +1515,11 @@ def _run_pm_startup() -> None: import pm pm.adopt() - problems = pm.check() + problems = pm.activate() if problems: logging.getLogger("gateway.run").warning( f"install out of sync ({'; '.join(problems)}) — run `hermes pm install`" ) - else: - pm.activate() except Exception: logging.getLogger("gateway.run").debug("pm startup check failed", exc_info=True) diff --git a/hermes_cli/_launchers.py b/hermes_cli/_launchers.py index 7c8d081626..30b749d2b9 100644 --- a/hermes_cli/_launchers.py +++ b/hermes_cli/_launchers.py @@ -229,6 +229,7 @@ def _launcher_script(name: str, repo_root: Path, dependencies: Path | None) -> s "os.environ.pop('PYTHONPATH', None)\n" f"sys.path.insert(0, {str(repo_root.resolve())!r})\n" "if sys.argv[1:2] == ['--print-runtime-command']:\n" + " sys.dont_write_bytecode = True\n" " from pathlib import Path\n" " from hermes_cli._launchers import print_runtime_command\n" f" print_runtime_command(Path({str(repo_root.resolve())!r}), sys.argv[2:])\n" diff --git a/hermes_cli/_old_updater.py b/hermes_cli/_old_updater.py index 89a4bca1c8..8df87cef76 100644 --- a/hermes_cli/_old_updater.py +++ b/hermes_cli/_old_updater.py @@ -1,14 +1,146 @@ -"""Shims to stop the old updater doing work until relaunch.""" +"""The newly imported escape hatch from an already-running historical updater. +This module must remain stdlib-only: the parent still has the old interpreter, +modules and native extensions. Only the child imports the updated application. +""" +from __future__ import annotations + +import dataclasses +import json +import os +from pathlib import Path +import subprocess import sys -from typing import NoReturn +import tempfile +from typing import Any, NoReturn + + +_result: int | None = None + + +def _historical_context() -> tuple[dict, list[dict], Any]: + """Carry data already held by old frames, without importing their modules. + + Older callers did not pass a continuation object. Read only the historical + updater's known local names; do not serialize frames or arbitrary globals. + """ + names = ("had_desktop_app_before_update", "pre_update_snapshot_id", + "pre_update_version", "gateway_mode", "assume_yes", "_pre_update_plan") + found = {} + resumes = [] + restart_update = None + frame = sys._getframe(1) + try: + while frame is not None: + # Capture hooks can be imported BEFORE the old updater has pulled. + # A declared-but-unassigned version in its innermost known frame + # proves early entry; a None value is still a post-capture value. + if (restart_update is None + and frame.f_globals.get("__name__") in ("hermes_cli.update_cmd", "hermes_cli.main") + and frame.f_code.co_name in ("_cmd_update_impl", "cmd_update") + and "pre_update_version" in frame.f_code.co_varnames): + restart_update = "pre_update_version" not in frame.f_locals + for name in names: + if name not in found and name in frame.f_locals: + found[name] = frame.f_locals[name] + token = frame.f_locals.get("_windows_gateway_resume") + if isinstance(token, dict) and not any(token is item for item in resumes): + resumes.append(token) + frame = frame.f_back + finally: + del frame + receipt_module = sys.modules.get("hermes_cli.update_receipt") + receipt_slot = vars(receipt_module).get("_current") if receipt_module else None + get_current = getattr(receipt_slot, "get", None) + current = get_current() if get_current is not None else receipt_slot + receipt = getattr(current, "data", None) + plan = found.get("_pre_update_plan") + if dataclasses.is_dataclass(plan) and not isinstance(plan, type): + plan = dataclasses.asdict(plan) + elif not isinstance(plan, dict): + plan = receipt.get("plan") if isinstance(receipt, dict) else None + return { + "restart_update": restart_update is True, + "desktop": bool(found.get("had_desktop_app_before_update", False)), + "pre_update_snapshot_id": found.get("pre_update_snapshot_id"), + "pre_update_version": found.get("pre_update_version"), + "gateway_mode": bool(found.get("gateway_mode", "--gateway" in sys.argv)), + "assume_yes": bool(found.get("assume_yes", "--yes" in sys.argv)), + "windows_resume": resumes[0] if resumes else None, + "plan": plan, + "receipt": receipt if isinstance(receipt, dict) else None, + }, resumes, receipt_slot + + +def _run_child(request: dict) -> tuple[int, dict]: + """One JSON exchange; the old process never imports the updated graph.""" + root = Path(__file__).resolve().parents[1] + home = os.environ.get("HERMES_HOME") + constants = sys.modules.get("hermes_constants") + override = vars(constants).get("_HERMES_HOME_OVERRIDE") if constants else None + if override is not None: + value = override.get() + if isinstance(value, (str, Path)) and value: + home = str(value) + request.update(root=str(root), home=home, argv=list(sys.argv)) + env = {key: value for key, value in os.environ.items() + if not key.startswith(("PYTHON", "UV_")) and key != "VIRTUAL_ENV"} + if home: + env["HERMES_HOME"] = home + env.setdefault("HERMES_UPDATE_HANDOFF_PID", str(os.getpid())) + with tempfile.TemporaryDirectory(prefix="hermes-update-takeover-") as directory: + context = Path(directory) / "request.json" + result = Path(directory) / "result.json" + context.write_text(json.dumps(request), encoding="utf-8") + child = subprocess.run( + [sys.executable, "-I", "-S", "-B", "-X", "utf8", str(root / "hermes_cli/_update_takeover.py"), + str(context), str(result)], cwd=root, env=env, + ) + completed = json.loads(result.read_text(encoding="utf-8")) if result.is_file() else {} + if not isinstance(completed, dict): + raise ValueError("invalid update takeover acknowledgement") + return child.returncode if child.returncode >= 0 else 1, completed def stop_for_relaunch() -> NoReturn: - """Do not return: old callers would fall back to pip or claim completion.""" - print( - "You're updating from an older version of Hermes Agent. " - "To complete this update, run `hermes` again.", - file=sys.stderr, - ) - raise SystemExit(0) + """Finish in a fresh child, then exit rather than resume an old fallback. + + The historical name is retained. A finally/atexit path can call another + shim while unwinding; it must receive the same result, not start again. + """ + global _result + if _result is not None: + raise SystemExit(_result) + _result = 1 + try: + request, resumes, receipt_slot = _historical_context() + print("Completing the update with the new Hermes updater…", file=sys.stderr, flush=True) + _result, completed = _run_child(request) + if completed.get("resume_handled"): + for token in resumes: + token["resume_needed"] = False + if completed.get("receipt_handled"): + if hasattr(receipt_slot, "set"): + receipt_slot.set(None) + elif receipt_slot is not None: + vars(sys.modules["hermes_cli.update_receipt"])["_current"] = None + except (OSError, ValueError, TypeError) as exc: + _result = 1 + print(f"Update takeover failed: {exc}", file=sys.stderr, flush=True) + raise SystemExit(_result) + + +def relaunch_stopped_serves(token: dict) -> None: + """A historical atexit token is separate work, not another whole update.""" + if not token.get("pending"): + return + code = 1 + try: + code, completed = _run_child({"stopped_serves": token}) + if completed.get("serves_handled") is True: + token["pending"] = False + except (OSError, ValueError, TypeError) as exc: + print(f"Stopped serve recovery failed: {exc}", file=sys.stderr, flush=True) + if code or token.get("pending"): + print("Stopped serve recovery did not complete; restart the affected " + "serve/dashboard backends manually.", file=sys.stderr, flush=True) diff --git a/hermes_cli/_scan_venv_blockers.py b/hermes_cli/_scan_venv_blockers.py index eb60c79222..ffd9a472c5 100644 --- a/hermes_cli/_scan_venv_blockers.py +++ b/hermes_cli/_scan_venv_blockers.py @@ -1,159 +1,21 @@ -"""Standalone venv-process scan for JSON consumption (``python -m hermes_cli._scan_venv_blockers``). +"""Compatibility entry point for historical Desktop venv preflights. -Exits 0 for valid clear or blocked results. Non-zero exit signals probe failure (the detector itself -crashed, psutil unavailable, etc.). Exactly one JSON document on stdout; diagnostics on stderr only. +PM stages fresh runtime generations instead of modifying a live venv. Current +Desktop no longer calls this module, but older binaries can load it from an +updated checkout. Keep their JSON protocol without scanning or stopping anything. """ from __future__ import annotations import json -import math import sys -from pathlib import PureWindowsPath -from typing import NoReturn - -# Long CLI flags whose argument value must be redacted from the cmdline. Short flags (-t, -k, -p) -# are intentionally not redacted — ambiguous and useful diagnostics (toolset, port, profile). -_SENSITIVE_LONG_FLAGS: list[str] = [ - "--token", "--api-key", "--password", "--secret", "--authorization", "--access-key", - "--private-key", "--session-key", -] - -_PYTHON_PROCESS_NAMES = {"python.exe", "pythonw.exe", "python", "pythonw"} -_UPDATER_STOPPABLE_PURPOSES = ("serve", "dashboard") - - -def _probe_fail_json(diagnostic: str = "probe failed") -> str: - """The standard probe-failure JSON document. - - ``ok: false`` plus ``probe_failed: true`` means the detector itself could not run — this is - *not* a clear scan. Callers must treat ``ok is not True`` / non-zero exit as probe failure, - never as ``blocked: false`` "clear". - - See #83149. - """ - return json.dumps({"ok": False, "probe_failed": True, "blocked": False, "processes": [], - "error": diagnostic}) - - -def _emit_probe_fail(diagnostic: str) -> NoReturn: - """Print one JSON to stdout, diagnostic to stderr, exit non-zero.""" - print(_probe_fail_json(diagnostic)) - print(diagnostic, file=sys.stderr) - sys.exit(1) - - -def _find_flag(text: str, flag: str) -> int: - """Index of *flag* (case-insensitive) at string start or after a space; -1 if absent.""" - low, fl, pos = text.lower(), flag.lower(), 0 - while True: - idx = low.find(fl, pos) - if idx == -1 or idx == 0 or text[idx - 1] == " ": - return idx - pos = idx + 1 - - -def _redact_sensitive_cmdline(cmdline: str) -> str: - """Apply the shared secret redactor, then replace everything after a sensitive long flag.""" - try: - from agent.redact import redact_sensitive_text # noqa: PLC0415 - - cmdline = redact_sensitive_text(cmdline, force=True) - except Exception: - return "" - # --flag=value → preserve "--flag="; --flag value → preserve "--flag ". - earliest = len(cmdline) - for flag in _SENSITIVE_LONG_FLAGS: - for suffix in ("=", " "): - idx = _find_flag(cmdline, flag + suffix) - if idx != -1 and idx + len(flag) + 1 < earliest: - earliest = idx + len(flag) + 1 - if earliest < len(cmdline): - return cmdline[:earliest] + "" - return cmdline - - -def _classify_local_preview_args(args: object) -> dict[str, object]: - """Safe UI metadata for an exact ``python -m http.server`` argv; ``{}`` otherwise. - - The general holder detector truncates its diagnostic command line; reading argv separately - preserves a useful directory label without exposing an unbounded command line to the renderer. - """ - if not isinstance(args, (list, tuple)) or not all(isinstance(arg, str) for arg in args): - return {} - # ``-m`` must be the first argument after the executable: a later ``-m http.server`` can be - # data passed to an unrelated script and must never authorize termination. - if len(args) < 3 or args[1] != "-m" or args[2].lower() != "http.server": - return {} - port = 8000 - if len(args) > 3 and args[3].isdigit() and 0 < int(args[3]) <= 65535: - port = int(args[3]) - label = "" - try: - directory_index = args.index("--directory") - if directory_index + 1 < len(args): - label = PureWindowsPath(args[directory_index + 1]).name - except ValueError: - pass - metadata: dict[str, object] = {"kind": "local-preview", "safeToStop": True, "port": port} - if label: - metadata["label"] = label - return metadata - - -def _local_preview_metadata(pid: int, name: str) -> dict[str, object]: - if name.lower() not in _PYTHON_PROCESS_NAMES: - return {} - try: - import psutil # noqa: PLC0415 - - process = psutil.Process(pid) - metadata = _classify_local_preview_args(process.cmdline()) - if metadata: - metadata["createTime"] = process.create_time() - return metadata - except Exception: - return {} - - -def _terminate_safe_preview( - pid: int, expected_create_time: float, *, psutil_module: object | None = None, -) -> tuple[bool, str | None]: - """Terminate one verified local preview process tree. - - A fresh ``psutil.Process`` identity check and exact argv classification occur immediately before - termination; psutil guards mutating Process methods against PID reuse (no taskkill stale-PID - race). - """ - try: - if psutil_module is None: - import psutil as psutil_module # type: ignore[no-redef] # noqa: PLC0415 - - process = psutil_module.Process(pid) # type: ignore[attr-defined] - if abs(process.create_time() - expected_create_time) > 0.001: - return False, "process identity changed" - if not _classify_local_preview_args(process.cmdline()): - return False, "process is no longer a local preview" - targets = [*reversed(process.children(recursive=True)), process] - for target in targets: - target.terminate() - _gone, alive = psutil_module.wait_procs(targets, timeout=3) # type: ignore[attr-defined] - for target in alive: - target.kill() - if alive: - psutil_module.wait_procs(alive, timeout=2) # type: ignore[attr-defined] - return True, None - except Exception as exc: - return False, f"termination failed: {type(exc).__name__}" def _is_pausable_gateway(cmdline: str) -> bool: - """True when *cmdline* is a gateway process the updater can pause. + """Historical post-swap import; preserve the canonical gateway predicate. - Only gateway invocations are exempted; anything else running from the venv has no pause - machinery downstream and must keep blocking the handoff. Delegates to the canonical - ``gateway run`` matcher so this exemption, pause discovery and the updater's guard share one - parser. + Required by tests/compat/old_updater_surface.json even though the Desktop + preflight is retired. An unavailable matcher must still fail closed. """ try: from gateway.status import looks_like_gateway_command_line # noqa: PLC0415 @@ -162,160 +24,25 @@ def _is_pausable_gateway(cmdline: str) -> bool: return looks_like_gateway_command_line(cmdline) -def _is_updater_owned_backend(pid: int, cmdline: str) -> bool: - """True when *pid* is a Hermes backend the CLI updater can stop (positive ledger identity). - - The gateway exemption above keeps ``gateway run`` holders out of the blocker list because the updater's - own pause machinery stops and resumes them. ``hermes serve`` / ``hermes dashboard`` backends had no such - deferral, so a leaked serve child (or a Desktop-owned backend the teardown lost track of) dead-ended the - hand-off with ``venv-blocked`` — or, worse, survived the hand-off and made the shim quarantine fail with - ``os error 32`` (#98336) — even though the updater downstream owns exactly this case with its ledger - rungs (`_ledger_reapable_backend_pids` reaps dead-spawner orphans; `_ledger_manual_serve_holders` stops - manual serves and relaunches them on their recorded host/port). - Positive identity only — never name/substring matching (#90778, and the 99558 identity-guard contract): - """ - return _updater_owned_backend_entry(pid, cmdline) is not None - - -def _updater_owned_backend_entry(pid: int, cmdline: str) -> dict | None: - """Ledger entry for a deferred serve/dashboard backend, or ``None`` when it must block. - - Returning the entry lets ``main()`` emit sanitized decision evidence — structured identity - fields only, never argv, which can carry tokens or private endpoints. - - See #98350. - """ - try: - from hermes_cli.update_cmd import _hermes_holder_subcommand # noqa: PLC0415 - - purpose = _hermes_holder_subcommand(cmdline) - except Exception: - return None - if purpose not in _UPDATER_STOPPABLE_PURPOSES: - return None - try: - from hermes_cli.process_identity import ledger_entries, spawner_is_dead # noqa: PLC0415 - - entries = ledger_entries() - except Exception: - return None - for entry in entries: - if entry.get("pid") != pid: - continue - if entry.get("purpose") not in _UPDATER_STOPPABLE_PURPOSES: - return None - # Spawner dead, unrecorded, or unprovable-but-registered: the updater's ledger rungs own - # this holder (reap or stop+relaunch). - if spawner_is_dead(entry) is not False or _spawner_is_this_handoff_desktop(entry): - return entry - return None - return None - - -def _deferred_backend_evidence(entries: list[dict]) -> list[dict]: - """Sanitized evidence (pid, purpose, recorded port — never argv) for deferred backends. - - Structured ledger fields only — pid, purpose, recorded port — never the command line, which can carry - tokens or private endpoints. Lets the scan result explain *why* a holder disappeared from ``processes`` - without echoing argv (#98350). - """ - return [{"pid": entry.get("pid"), "purpose": entry.get("purpose"), "port": entry.get("port")} - for entry in entries if isinstance(entry.get("pid"), int)] - - -def _spawner_is_this_handoff_desktop(entry: dict) -> bool: - """True when the entry's live spawner is an ancestor of this scan. - - The scan is spawned by the Desktop app's update preflight, so the Desktop performing the - hand-off is in our ancestor chain. Identity is ``(pid, create_time)`` — a recycled PID cannot - forge the pair. - """ - spawner_pid = entry.get("spawner_pid") - if not isinstance(spawner_pid, int) or spawner_pid <= 0: - return False - try: - import psutil # noqa: PLC0415 - - for ancestor in psutil.Process().parents(): - if ancestor.pid != spawner_pid: - continue - expected = entry.get("spawner_create") - if expected is None: - return True - return abs(float(ancestor.create_time()) - float(expected)) < 2.0 - except Exception: - return False - return False - - def main() -> None: - """Entry point. Prints one JSON doc to stdout. Exits 0 for valid scan.""" - try: - import psutil # noqa: PLC0415, F401 - except Exception as exc: - _emit_probe_fail(f"psutil is not available: {exc}") - try: - from hermes_cli.update_cmd import _detect_venv_python_processes - - matches = _detect_venv_python_processes() - except Exception as exc: - _emit_probe_fail(f"scan aborted: {exc}") - - processes = [] - exempted_gateways = 0 - deferred_entries: list[dict] = [] - for pid, name, cmdline in matches: - if _is_pausable_gateway(cmdline): - exempted_gateways += 1 - continue - deferred_entry = _updater_owned_backend_entry(pid, cmdline) - if deferred_entry is not None: - # Ledger-verified backend the updater's own rungs stop (and relaunch) downstream — - # reporting it here would dead-end the hand-off before that machinery can run. - # See #98336. - deferred_entries.append(deferred_entry) - continue - # Truncate for display AFTER the gateway exemption has seen the full cmdline (long - # managed-runtime interpreter paths would otherwise swallow the `gateway run` argv). - process = {"pid": pid, "name": name, "cmdline": _redact_sensitive_cmdline(cmdline)[:120]} - process.update(_local_preview_metadata(pid, name)) - processes.append(process) - - # pausable_gateways / deferred_backends / deferred_backend_evidence are diagnostic only. - data = { + """Emit one JSON document for old Desktop callers, without process access.""" + if sys.argv[1:2] == ["--terminate-safe"]: + # Old Desktop treats exit 0 as a successful stop without reading stdout. + # A stale preview request must never claim to have stopped a process. + print(json.dumps({ + "ok": False, + "error": "Legacy preview termination is retired; no processes were stopped.", + })) + raise SystemExit(1) + print(json.dumps({ "ok": True, - "blocked": bool(processes), - "processes": processes, - "pausable_gateways": exempted_gateways, - # Diagnostic only: ledger-verified serve/dashboard backends deferred to the updater's stop/relaunch - # rungs (#98336). - "deferred_backends": len(deferred_entries), - # Diagnostic only: sanitized evidence (structured ledger identity, never argv) explaining which - # holders the deferral consumed (#98350). - "deferred_backend_evidence": _deferred_backend_evidence(deferred_entries), - } - print(json.dumps(data)) - sys.exit(0) - - -def _terminate_safe_main(argv: list[str]) -> NoReturn: - if len(argv) != 2: - print(json.dumps({"ok": False, "error": "expected pid and create time"})) - raise SystemExit(2) - try: - pid = int(argv[0]) - create_time = float(argv[1]) - if pid <= 0 or not math.isfinite(create_time) or create_time <= 0: - raise ValueError - except ValueError: - print(json.dumps({"ok": False, "error": "invalid process identity"})) - raise SystemExit(2) - stopped, error = _terminate_safe_preview(pid, create_time) - print(json.dumps({"ok": stopped, "pid": pid, "error": error})) - raise SystemExit(0 if stopped else 1) + "blocked": False, + "processes": [], + "retired": True, + "message": "Legacy venv preflight is retired; PM stages fresh runtime generations.", + })) + raise SystemExit(0) if __name__ == "__main__": - if len(sys.argv) > 1 and sys.argv[1] == "--terminate-safe": - _terminate_safe_main(sys.argv[2:]) - main() + main() \ No newline at end of file diff --git a/hermes_cli/_update_takeover.py b/hermes_cli/_update_takeover.py new file mode 100644 index 0000000000..e2afa66240 --- /dev/null +++ b/hermes_cli/_update_takeover.py @@ -0,0 +1,108 @@ +"""Absolute-path bootstrap for historical updater takeover; no app imports yet.""" +from __future__ import annotations + +import json +from pathlib import Path +import subprocess +import sys + + +def prepare(request: dict) -> tuple[Path, dict[str, str]]: + """Provision the new graph before entering its application interpreter.""" + root = Path(request["root"]) + sys.path.insert(0, str(root)) + from pm import paths, receipt + from pm.client import ensure, sync_venv, venv_is_current + from pm.lock import Lockfile + from pm.registry import get_package + from hermes_cli.runtime_paths import activation_environment, install_state_dir, runtime_facts_path + from hermes_cli._launchers import resolve_store_python + from hermes_cli.venv_sync import publish_launchers + + correlation = request["update_id"] + with receipt.worker_context(correlation): + lock = Lockfile(paths.lockfile_path()) + # A pre-PM installation has no required-tool facts. A current Python + # generation alone does not prove its Node/Git/tool closure is ready. + for name in lock.names(): + if not get_package(name).optional or name == "python": + ensure(name, explicit=True) + extras = ["all"] if not runtime_facts_path(root).is_file() else None + repair_marker = install_state_dir(root) / ".repair-incomplete" + # Repair preserves the old stamp. Changed source inputs instead need + # an ordinary sync, which builds and validates a fresh generation too. + repair = repair_marker.is_file() and venv_is_current(project_root=root) + sync_venv(None if repair else extras, explicit=True, project_root=root, repair=repair) + request["pm_receipt"] = receipt.last_for_update(correlation) + publish_launchers(root) + repair_marker.unlink(missing_ok=True) + for name in (".update-incomplete", ".lazy-refresh-incomplete"): + (root / name).unlink(missing_ok=True) + python = resolve_store_python(root) + if python is None: + raise RuntimeError("updated installation has no managed interpreter") + return python, activation_environment(root) + + +def _record_failure(request: dict, result: Path, code: int, detail: str) -> None: + from hermes_cli import update_receipt + from hermes_constants import get_hermes_home + + update_receipt.record_step("historical_takeover", False, detail) + saved = update_receipt.finalize_pending_update_receipt(code, detail) + if request.get("gateway_mode"): + (get_hermes_home() / ".update_exit_code").write_text(f"{code}\n", encoding="utf-8") + result.write_text(json.dumps({"receipt_handled": saved is not None, "resume_handled": False}), encoding="utf-8") + + +def main() -> int: + context, result = map(Path, sys.argv[1:3]) + request = json.loads(context.read_text(encoding="utf-8")) + sys.path.insert(0, request["root"]) + if "stopped_serves" in request: + # Historical atexit cleanup may run after the update's result is fixed. + # It must reuse that installation, never start a second update/repair. + from hermes_cli._launchers import resolve_store_python + from hermes_cli.runtime_paths import activation_environment + + root = Path(request["root"]) + python = resolve_store_python(root) + if python is None: + print("Cannot resume stopped backends: no selected Hermes interpreter", file=sys.stderr) + return 1 + return subprocess.run( + [str(python), "-I", "-B", "-X", "utf8", str(root / "hermes_cli/update_serve_resume.py"), + str(context), str(result)], cwd=root, env=activation_environment(root), + ).returncode + from hermes_cli import update_receipt + from hermes_cli.update_lock import UpdateLock, describe_holder + + lock = UpdateLock() + if not lock.acquire(): + print(describe_holder(lock.holder), file=sys.stderr) + return 2 + old_receipt = request.get("receipt") or {} + update_receipt.begin_update_receipt(previous=old_receipt, correlation_id=old_receipt.get("update_id")) + request["update_id"] = update_receipt.current_correlation_id() + try: + python, env = prepare(request) + request["receipt"] = update_receipt._current.get().data + context.write_text(json.dumps(request), encoding="utf-8") + # This file is new too. Direct execution bypasses normal launch-time + # update liveness checks while the waiting parent still holds its lock. + command = [str(python), "-I", "-B", "-X", "utf8", str(Path(request["root"]) / "hermes_cli/update_finish.py"), + str(context), str(result)] + code = subprocess.run(command, cwd=request["root"], env=env).returncode + if code != 0 and not result.is_file(): + _record_failure(request, result, code, f"completion child exited {code} without acknowledgement") + return code + except Exception as exc: + print(f"Update preparation failed: {exc}", file=sys.stderr, flush=True) + _record_failure(request, result, 1, f"historical takeover preparation failed: {exc}") + return 1 + finally: + lock.release() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/hermes_cli/boot_bootstrap.py b/hermes_cli/boot_bootstrap.py index 0b0ef9f5ac..cb94758b30 100644 --- a/hermes_cli/boot_bootstrap.py +++ b/hermes_cli/boot_bootstrap.py @@ -33,7 +33,7 @@ branch's vocabulary: stamps via ``hermes_cli.steward``, managed tools via """ from __future__ import annotations -from hermes_cli.runtime_paths import install_state_dir, installs_root +from hermes_cli.runtime_paths import install_state_dir import json import logging import os @@ -137,128 +137,6 @@ def current_install_identity(project_root: Path) -> str | None: # the per-install state folder: installs// under the DEFAULT home # --------------------------------------------------------------------------- -def ensure_install_dir(project_root: Path) -> Path: - """The state folder, created with its identity record on first touch. - - install.json is the REVERSE map (sha16 → canonical root) that makes - orphan GC possible: `hermes doctor` enumerates installs/*/install.json - and flags entries whose recorded root no longer exists. Written once, - under the same single-flight lock the records use; the steward comes - from hermes_cli.steward so the record says who owns the tree, not who - touched it first. - """ - state = install_state_dir(project_root) - marker = state / "install.json" - if marker.is_file(): - return state - state.mkdir(parents=True, exist_ok=True) - lock = _RecordLock(state / ".install-json.lock") - if not lock.acquire(): - return state # someone else is writing it right now — theirs wins - try: - if not marker.is_file(): - from datetime import datetime, timezone - - from hermes_cli.steward import sealed_steward - - steward = sealed_steward(Path(project_root)) - payload = { - "root": str(Path(project_root).resolve()), - "steward": steward if steward is not None else "checkout", - "firstSeen": datetime.now(timezone.utc).isoformat(), - } - tmp = marker.with_suffix(".json.tmp") - tmp.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") - os.replace(tmp, marker) - finally: - lock.release() - return state - - -def orphaned_installs() -> list[tuple[Path, str]]: - """State folders whose recorded root no longer exists. - - ``(folder, recorded_root)`` pairs for `hermes doctor`'s sweep. A - folder without a readable install.json is orphaned by definition — - nothing can ever claim it again, because claiming goes through - ensure_install_dir which writes the record first. - """ - root = installs_root() - if not root.is_dir(): - return [] - orphans: list[tuple[Path, str]] = [] - for entry in sorted(root.iterdir()): - if not entry.is_dir(): - continue - try: - recorded = json.loads( - (entry / "install.json").read_text(encoding="utf-8-sig") - ).get("root", "") - except (OSError, ValueError): - orphans.append((entry, "")) - continue - if not recorded or not Path(recorded).exists(): - orphans.append((entry, recorded or "")) - return orphans - - -def orphaned_store_entries() -> list[tuple[Path, int]]: - """Tool-store entries the installed-state ledger no longer references. - - ``(entry_dir, size_bytes)`` pairs for `hermes doctor`'s sweep. pm's - facts.json is the only authority consulted — the same ledger - ``pm.ensure`` resolves by, so this can never flag an entry pm would - still hand out. An entry is REFERENCED when facts records it (tool - entries) or when it is a ``fetch-*`` archive cache entry backing a - referenced install. Everything else is bytes no lookup can ever - return: superseded versions left behind by pin bumps. - - Doubt errs toward KEEP: a facts file that exists but cannot be read - aborts the whole sweep (empty result), because its references are - unknowable and any entry might be one of them. No facts file at all - means pm never installed anything — nothing is referenced, but there - is also nothing to GC against, so the sweep reports nothing. - """ - from pm import paths as pm_paths - from pm.lock import Facts - - store = pm_paths.store_root() - if not store.is_dir(): - return [] - facts_file = pm_paths.facts_path() - if not facts_file.is_file(): - return [] - try: - raw = json.loads(facts_file.read_text(encoding="utf-8-sig")) - if not isinstance(raw, dict): - return [] - except (OSError, ValueError): - # Unreadable ledger: references unknowable — keep everything. - return [] - referenced = Facts(facts_file).entries_in_use() - - orphans: list[tuple[Path, int]] = [] - for entry in sorted(store.iterdir()): - # Scratch dirs (.staging-*), the ledger itself, and stray files - # are pm's own cleanup problem, never GC candidates. fetch-* - # archive cache entries are content-addressed and cheap to keep; - # skip them too (re-fetch avoidance is their whole point). - if not entry.is_dir() or entry.name.startswith("."): - continue - if entry.name.startswith("fetch-"): - continue - if entry.name in referenced: - continue - size = 0 - for f in entry.rglob("*"): - try: - if f.is_file() and not f.is_symlink(): - size += f.stat().st_size - except OSError: - continue - orphans.append((entry, size)) - return orphans - def record_path(project_root: Path, scope: str) -> Path: """Where the last-known record for ``project_root`` lives. diff --git a/hermes_cli/doctor_tools.py b/hermes_cli/doctor_tools.py index 77f1ee1992..d14b223f8f 100644 --- a/hermes_cli/doctor_tools.py +++ b/hermes_cli/doctor_tools.py @@ -24,32 +24,17 @@ def _safe_which(cmd: str) -> str | None: def _pm_tool_path(name: str) -> Path | None: - """Answer a tool's binary from the pm store (facts.json), not PATH. + """Use PM's current selection, including writable payload extensions. - Doctor probes tools (git, rg, ...) that pinned installs run out of - the store; nothing puts the store on an interactive shell's PATH, so - a PATH-only probe reports a healthy managed install as "not found". - This answers from pm's registry/store/package authority: the recorded - entry's binary when it exists on disk, None when unstaged or the - recorded binary is gone (deleted = report missing, never guess). - Contract tests: tests/hermes_cli/test_doctor_pm_store_probe.py. + Old facts are diagnostic evidence, not an available runtime tool. """ try: - import pm # noqa: F401 — imports pm.packages, registering the definitions - from pm import paths, registry, store - from pm.lock import Facts + from pm import installed_package - fact = Facts(paths.facts_path()).get(name) or {} - entry_name = fact.get("entry") - if not entry_name: - return None - package = registry.get_package(name) - binary = package.binary(store.Store(paths.store_root()).entry(entry_name), store.current_target()) + installed = installed_package(name) except Exception: return None - if binary is None or not binary.is_file(): - return None - return binary + return installed.binary if installed is not None else None def _pm_package_for_command(command: str) -> str | None: diff --git a/hermes_cli/main.py b/hermes_cli/main.py index a2ea0df684..735e872588 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -3414,14 +3414,12 @@ def main(): import pm pm.adopt() - problems = pm.check() + problems = pm.activate() if problems: print( f"⚠ install out of sync ({'; '.join(problems)}) — run `hermes pm install`", file=sys.stderr, ) - else: - pm.activate() except Exception: import logging diff --git a/hermes_cli/old_updater_deps.py b/hermes_cli/old_updater_deps.py index 533d0fdd9e..1cedfab716 100644 --- a/hermes_cli/old_updater_deps.py +++ b/hermes_cli/old_updater_deps.py @@ -49,8 +49,9 @@ def _ledger_manual_serve_holders(matches: list[tuple[int, str, str]]) -> NoRetur stop_for_relaunch() -def _relaunch_stopped_serves(token: dict) -> NoReturn: - stop_for_relaunch() +def _relaunch_stopped_serves(token: dict) -> None: + from hermes_cli._old_updater import relaunch_stopped_serves + relaunch_stopped_serves(token) def _orphaned_desktop_backend_pids(matches: list[tuple[int, str, str]]) -> NoReturn: diff --git a/hermes_cli/plugin_packs.py b/hermes_cli/plugin_packs.py index 83e366fd0f..dc77bc219b 100644 --- a/hermes_cli/plugin_packs.py +++ b/hermes_cli/plugin_packs.py @@ -314,14 +314,12 @@ def install_pack_plugins( from hermes_cli.plugins_cmd import ( PluginOperationError, _declared_capabilities_from_manifest, - _get_disabled_set, - _get_enabled_set, _install_plugin_core, _prompt_plugin_env_vars, _run_capability_consent, - _save_disabled_set, - _save_enabled_set, + _set_plugin_enabled, ) + from hermes_cli.plugins_admission import AdmissionRefused results: List[PackInstallResult] = [] def _fail(display: str, error: str) -> None: @@ -351,12 +349,11 @@ def install_pack_plugins( except Exception: logger.debug("requires_env prompt failed for %s", installed_name, exc_info=True) - enabled = _get_enabled_set() - disabled = _get_disabled_set() - enabled.add(installed_name) - disabled.discard(installed_name) - _save_enabled_set(enabled) - _save_disabled_set(disabled) + try: + _set_plugin_enabled(installed_name, enable=True) + except AdmissionRefused as exc: + _fail(display, str(exc)) + continue # Per-plugin capability consent — the SAME flow as a single install (#64228). A pack never # bulk-grants capabilities. diff --git a/hermes_cli/plugins_admission.py b/hermes_cli/plugins_admission.py index 7cdd20484b..e8c74468a1 100644 --- a/hermes_cli/plugins_admission.py +++ b/hermes_cli/plugins_admission.py @@ -3,7 +3,7 @@ from __future__ import annotations from pathlib import Path -from typing import Iterable, Optional +from typing import Callable, Iterable, Optional class AdmissionRefused(RuntimeError): @@ -75,8 +75,8 @@ def _config_commit(candidate_enabled: set, candidate_disabled: set): def admit_plugin_set_change( - candidate_enabled: set, - candidate_disabled: set, + candidate_enabled: set | Callable[[], tuple[set, set]], + candidate_disabled: set | None, *, active_plugins_dir: Optional[Path] = None, extra_dirs: Iterable[Path] = (), @@ -93,13 +93,26 @@ def admit_plugin_set_change( from pm.client import sync_venv extra_dirs = tuple(extra_dirs) + selection = None + + def members(): + nonlocal selection + # Commands supply a read/modify/write proposal, evaluated only after + # the worker holds the shared lock. A second acknowledged enable must + # extend the first, not overwrite its pre-lock snapshot. + selection = (candidate_enabled() if callable(candidate_enabled) + else (set(candidate_enabled), set(candidate_disabled or ()))) + return candidate_member_dirs(*selection, active_plugins_dir=active_plugins_dir, extra_dirs=extra_dirs) + + def commit(): + assert selection is not None, "PM must select members before publication" + return _config_commit(*selection) + try: sync_venv( explicit=True, - plugin_dirs=lambda: candidate_member_dirs( - candidate_enabled, candidate_disabled, active_plugins_dir=active_plugins_dir, extra_dirs=extra_dirs - ), - before_publish=lambda: _config_commit(candidate_enabled, candidate_disabled), + plugin_dirs=members, + before_publish=commit, ) except Exception as exc: raise AdmissionRefused(str(exc)) from exc diff --git a/hermes_cli/plugins_cmd.py b/hermes_cli/plugins_cmd.py index 2acd905aa5..4a3bb62443 100644 --- a/hermes_cli/plugins_cmd.py +++ b/hermes_cli/plugins_cmd.py @@ -13,7 +13,7 @@ import sys import tempfile import urllib.parse from pathlib import Path -from typing import Any, Optional +from typing import Any, Callable, Optional from hermes_constants import get_hermes_home from hermes_cli._subprocess_compat import noninteractive_git_env @@ -260,7 +260,12 @@ def _repo_name_from_url(url: str) -> str: def _native_manifest_file(plugin_dir: Path) -> Optional[Path]: """``plugin.yaml`` (or ``plugin.yml``) under *plugin_dir*, or None when neither exists.""" - return next((p for p in (plugin_dir / "plugin.yaml", plugin_dir / "plugin.yml") if p.exists()), None) + from pm.plugin_declarations import native_manifest_file + + try: + return native_manifest_file(plugin_dir) + except ValueError as exc: + raise PluginOperationError(str(exc)) from exc def _has_portable_manifest(plugin_dir: Path) -> bool: @@ -271,9 +276,9 @@ def _has_portable_manifest(plugin_dir: Path) -> bool: def _load_yaml_manifest(manifest_file: Path): """``yaml.safe_load`` of *manifest_file* (``{}`` when empty); raises on any read/parse error.""" - import hermes_yaml as yaml - with open(manifest_file, encoding="utf-8-sig") as f: - return yaml.safe_load(f) or {} + from pm.plugin_declarations import read_native_manifest + + return read_native_manifest(manifest_file) def _read_manifest(plugin_dir: Path) -> dict: @@ -348,11 +353,11 @@ def _install_plugin_python_deps( needed); a decline/skip returns False and NOTHING is installed. Never raises — the caller keeps the plugin installed-but-disabled. """ - deps = manifest.get("python_dependencies") or [] - if not isinstance(deps, list): - return True, None - deps = [d.strip() for d in deps if isinstance(d, str) and d.strip()] - has_pyproject = (target / "pyproject.toml").is_file() + from pm.plugin_declarations import read_python_declaration + + declaration = read_python_declaration(target) + deps = declaration.requirements + has_pyproject = declaration.pyproject is not None has_package_json = (target / "package.json").is_file() if not deps and not has_pyproject and not has_package_json: return True, None # no declared deps at all @@ -603,27 +608,12 @@ def _scrub_cloned_origin(repo: Path, git_exe: str, git_url: str) -> None: def _check_manifest_version(manifest: dict, plugin_name: str) -> None: """Reject manifests declaring a newer ``manifest_version`` than this installer supports.""" - from hermes_cli.plugins_manifest import SUPPORTED_MANIFEST_VERSION, requires_hermes_error + from pm.plugin_declarations import manifest_version_error - reason = requires_hermes_error(manifest) + reason = manifest_version_error(manifest, plugin_name) if reason: - raise PluginOperationError(f"Plugin '{plugin_name}' {reason}") - mv = manifest.get("manifest_version") - if mv is None: - return - try: - mv_int = int(mv) - except (ValueError, TypeError): - raise PluginOperationError( - f"Plugin '{plugin_name}' has invalid manifest_version '{mv}' (expected an integer).", - ) from None - if mv_int > SUPPORTED_MANIFEST_VERSION: from hermes_cli.config import recommended_update_command - raise PluginOperationError( - f"Plugin '{plugin_name}' requires manifest_version {mv}, " - f"but this installer only supports up to {SUPPORTED_MANIFEST_VERSION}. " - f"Run {recommended_update_command()} to update Hermes.", - ) from None + raise PluginOperationError(f"{reason} Run {recommended_update_command()} to update Hermes.") def _clone_plugin_repo(tmp_clone: Path, git_url: str, revision: Optional[str]) -> str: @@ -747,7 +737,7 @@ def _install_plugin_core( from hermes_cli.plugins_transaction import publish_plugin try: - publish_plugin(tmp_target, target, old_metadata, new_metadata) + publish_plugin(tmp_target, target, old_metadata, new_metadata, require_consent=True) except Exception as exc: raise PluginOperationError(f"Plugin '{plugin_name}' was not published: {exc}") from exc @@ -823,11 +813,16 @@ def cmd_install( f"plugin.json, or __init__.py. It may not be a valid Hermes plugin.") _prompt_plugin_env_vars(installed_manifest, console) + from pm.workspace import enabled_plugin_dirs + + # Active replacements settled consent against the staged tree before PM + # prepared or published it. Do not present a second, ineffective veto. + already_active = target.resolve() in enabled_plugin_dirs() should_enable = enable - if should_enable is None: + if should_enable is None and not already_active: should_enable = _is_tty() and _ask_yes(f" Enable '{installed_name}' now? [y/N]: ") deps_ok, deps_reason = (True, None) - if should_enable: + if should_enable and not already_active: deps_ok, deps_reason = _install_plugin_python_deps(installed_manifest, target, console) _display_after_install(target, identifier) @@ -849,21 +844,13 @@ def cmd_install( ) should_enable = False - if should_enable: + if already_active: + console.print("[dim]Replacement installed; plugin selection was not changed.[/dim]") + elif should_enable: from hermes_cli.plugins_admission import AdmissionRefused - enabled = _get_enabled_set() - disabled = _get_disabled_set() - enabled.add(installed_name) - disabled.discard(installed_name) try: - _admit_and_save_plugin_sets( - enabled, - disabled, - extra_dirs=[target], - console=console, - action=f"Enable '{installed_name}'", - ) + _set_plugin_enabled(installed_name, enable=True, console=console) except AdmissionRefused: console.print( "[dim]The plugin stays installed but disabled; re-enable " @@ -1004,22 +991,16 @@ _get_disabled_set = functools.partial(_config_name_set, "plugins", "disabled") _get_enabled_set = functools.partial(_config_name_set, "plugins", "enabled") -def _save_disabled_set(disabled: set) -> None: - _write_config_value("plugins", "disabled", sorted(disabled)) - - def _save_enabled_set(enabled: set) -> None: - _write_config_value("plugins", "enabled", sorted(enabled)) + """Frozen old-updater import: never resurrect a raw plugin-selection write.""" + from hermes_cli._old_updater import stop_for_relaunch - -def _save_plugin_sets(enabled: set, disabled: set) -> None: - from hermes_cli.plugins_admission import admit_plugin_set_change - - admit_plugin_set_change(enabled, disabled, active_plugins_dir=_plugins_dir()) + stop_for_relaunch() def _admit_and_save_plugin_sets( - enabled: set, disabled: set, *, extra_dirs=(), console=None, action: str = "enable" + enabled: set | Callable[[], tuple[set, set]], disabled: set | None, + *, extra_dirs=(), console=None, action: str = "enable" ) -> None: """ONE admission authority for proposed enabled/disabled sets (C13): the candidate union is resolved against the ACTIVE environment and @@ -1069,13 +1050,23 @@ def _discard_key_and_leaf(names: set, key: str) -> None: names.discard(key.split("/")[-1]) -def _set_plugin_enabled(name: str, *, enable: bool) -> None: - """Persist the proposed selection through the same dependency transaction.""" - enabled = _get_enabled_set() - disabled = _get_disabled_set() - (enabled.add if enable else enabled.discard)(name) - (disabled.discard if enable else disabled.add)(name) - _save_plugin_sets(enabled, disabled) +def _set_plugin_enabled(name: str, *, enable: bool, aliases=(), console=None) -> None: + """Apply the command's delta to the latest selection under PM's lock.""" + def selection(): + from pm.plugins_state import _read_home_config + + config = _read_home_config(get_hermes_home()) or {} + plugins = config.get("plugins") or {} + enabled = set(plugins.get("enabled") or ()) + disabled = set(plugins.get("disabled") or ()) + removed = disabled if enable else enabled + _discard_key_and_leaf(removed, name) + removed.difference_update(aliases) + (enabled if enable else disabled).add(name) + return enabled, disabled + + _admit_and_save_plugin_sets(selection, None, console=console, + action=f"{'Enable' if enable else 'Disable'} '{name}'") def _resolve_plugin_key(name: str) -> Optional[str]: @@ -1139,15 +1130,11 @@ def cmd_enable(name: str, allow_tool_override: Optional[bool] = None) -> None: if key in enabled and key not in disabled: console.print(f"[dim]Plugin '{key}' is already enabled.[/dim]") else: - enabled.add(key) # The loader's disable check matches BOTH the canonical key (``web/firecrawl``) and the # manifest name (``web-firecrawl``); a stale entry under either form would silently veto # this enable ("explicit disable wins"), so drop the key, its bare leaf, and the name. - _discard_key_and_leaf(disabled, key) manifest_name = next((e[0] for e in _discover_all_plugins() if e[5] == key), None) - if manifest_name is not None: - disabled.discard(manifest_name) - _admit_and_save_plugin_sets(enabled, disabled, console=console, action=f"Enable '{key}'") + _set_plugin_enabled(key, enable=True, aliases=(() if manifest_name is None else (manifest_name,)), console=console) console.print(f"[green]✓[/green] Plugin [bold]{key}[/bold] enabled. Takes effect on next session.") # Built-in tool override is a privileged grant; bundled plugins are trusted. @@ -1318,10 +1305,7 @@ def cmd_disable(name: str) -> None: if key not in enabled and key in disabled: console.print(f"[dim]Plugin '{key}' is already disabled.[/dim]") return - # Also drop a stale legacy bare-name entry so it can't keep a nested plugin loading. - _discard_key_and_leaf(enabled, key) - disabled.add(key) - _save_plugin_sets(enabled, disabled) + _set_plugin_enabled(key, enable=False, console=console) console.print( f"[yellow]\u2298[/yellow] Plugin [bold]{key}[/bold] disabled. Takes effect on next session.") diff --git a/hermes_cli/plugins_transaction.py b/hermes_cli/plugins_transaction.py index 7a70ddac16..b3b190e8ef 100644 --- a/hermes_cli/plugins_transaction.py +++ b/hermes_cli/plugins_transaction.py @@ -82,7 +82,8 @@ class PluginPublication: recover_plugin_publication(self.project, self.row, self.journal) -def publish_plugin(staged: Path, target: Path, old_metadata: dict, new_metadata: dict) -> None: +def publish_plugin(staged: Path, target: Path, old_metadata: dict, new_metadata: dict, + *, require_consent: bool = False) -> None: from hermes_cli import plugins_cmd from hermes_cli.runtime_state import recover_publication, runtime_lock from pm import paths @@ -106,6 +107,12 @@ def publish_plugin(staged: Path, target: Path, old_metadata: dict, new_metadata: # PM snapshots the staged inputs into the candidate generation's workspace. active = target.resolve() in member_sources(enabled_plugin_dirs()) if active: + if require_consent: + consented, reason = plugins_cmd._install_plugin_python_deps( + plugins_cmd._read_manifest_for_install(staged), staged, plugins_cmd._console()) + if not consented: + raise plugins_cmd.PluginOperationError( + f"Reinstall declined: {reason}. The installed plugin and active environment are unchanged.") sync_venv(explicit=True, plugin_dirs=candidate_members, before_publish=lambda: PluginPublication(project, staged, target, new_metadata)) else: diff --git a/hermes_cli/proxy_cli.py b/hermes_cli/proxy_cli.py index 53a03da0d3..bb5fba8f29 100644 --- a/hermes_cli/proxy_cli.py +++ b/hermes_cli/proxy_cli.py @@ -25,8 +25,8 @@ def register_cli(parent_parser: argparse.ArgumentParser) -> None: # (name, help, handler, [(flag, add_argument kwargs), ...]) — declaration order is the # ``--help`` order, so keep it stable. commands = [ - ("install", f"Download iron-proxy binary (v{ip._IRON_PROXY_VERSION})", cmd_install, [ - ("--force", dict(action="store_true", help="Re-download even if a managed copy already exists")), + ("install", "Install the PM-pinned iron-proxy binary", cmd_install, [ + ("--force", dict(action="store_true", help="Verify and repair the managed copy")), ]), ("setup", "Interactive wizard: install + CA + mint tokens + write config", cmd_setup, [ ("--tunnel-port", dict(type=int, default=None, diff --git a/hermes_cli/secrets_cli.py b/hermes_cli/secrets_cli.py index 2398b1e5dd..bd08d04294 100644 --- a/hermes_cli/secrets_cli.py +++ b/hermes_cli/secrets_cli.py @@ -15,13 +15,6 @@ from rich.console import Console from rich.panel import Panel from rich.table import Table -# The Bitwarden backend pulls in ``cryptography`` at import time; on Windows that mapped native -# module makes the ``hermes update`` self-lock preflight defer. This module is registered -# parse-time from ``hermes_cli.main``, so the backend import stays lazy (nothing touches ``bw`` -# until a handler runs) and ``_BWS_VERSION`` is duplicated here for the ``install --help`` text. -# ``agent.secret_sources.bitwarden._BWS_VERSION`` is the source of truth; bump both together. -# See #86781. -_BWS_VERSION = "2.0.0" from hermes_cli._secrets_common import ( arg, cfg_str, cli_version, disable_secret_source, flag, print_status_panel, print_table, @@ -91,8 +84,8 @@ def register_cli(parent_parser: argparse.ArgumentParser) -> None: flag("--apply", "Actually export the secrets into the current shell's env (default: dry-run)"), )), ("disable", "Turn off the Bitwarden integration", cmd_disable, ()), - ("install", f"Download and verify the pinned bws binary (v{_BWS_VERSION})", cmd_install, ( - flag("--force", "Re-download even if a managed copy already exists"), + ("install", "Install the PM-pinned bws binary", cmd_install, ( + flag("--force", "Verify and repair the managed copy"), )), )) diff --git a/hermes_cli/update_cmd.py b/hermes_cli/update_cmd.py index e0383b2c26..4ede5662f5 100644 --- a/hermes_cli/update_cmd.py +++ b/hermes_cli/update_cmd.py @@ -1259,7 +1259,9 @@ def _current_branch_name(git_cmd, *, check: bool = False) -> str: def _handle_update_called_process_error( e, args, gateway_mode: bool, had_desktop_app_before_update: bool, - *, target_sha: str | None = None, target_repository: str | None = None) -> None: + *, target_sha: str | None = None, target_repository: str | None = None, + pre_update_snapshot_id=None, pre_update_version=None, + _pre_update_plan=None, _windows_gateway_resume=None) -> None: """Git/installer failure: ZIP-fallback when safe, else report and ``sys.exit(1)``.""" stage = _format_update_failure_stage(e) if _should_zip_fallback_on_update_error(e): @@ -1268,10 +1270,11 @@ def _handle_update_called_process_error( print() update_complete = _update_via_zip( args, had_desktop_app_before_update=had_desktop_app_before_update, + gateway_mode=gateway_mode, pre_update_snapshot_id=pre_update_snapshot_id, + pre_update_version=pre_update_version, + _pre_update_plan=_pre_update_plan, _windows_gateway_resume=_windows_gateway_resume, target_sha=target_sha, **({"target_repository": target_repository} if target_repository else {})) - if gateway_mode: - _write_gateway_update_exit_code(update_complete) if not update_complete: sys.exit(1) else: @@ -1369,23 +1372,14 @@ def _apply_pulled_update( print() print(f"✓ Code updated!{_branch_head_suffix(git_cmd, _m().PROJECT_ROOT)}") - update_complete = _run_post_update_maintenance( + from hermes_cli.update_finish import finish_update + + finish_update( assume_yes=opts.assume_yes, gateway_mode=gateway_mode, pre_update_snapshot_id=pre_update_snapshot_id, had_desktop_app_before_update=had_desktop_app_before_update, - pre_update_version=opts.pre_update_version) - - # Exit code *before* the restart: under --gateway this process lives in the gateway's - # systemd cgroup and the systemctl-restart fallback SIGKILLs it (KillMode=mixed), so - # the marker would never land and the new gateway's watcher would time out spuriously. - if gateway_mode: - _write_gateway_update_exit_code(update_complete) - - _restart = _restart_gateway_fleet_after_update(_pre_update_plan, gateway_mode) - _resume_windows_gateways_and_merge_outcome(_restart, _windows_gateway_resume, gateway_mode) - _verify_fleet_after_update( - _restart, _pre_update_plan=_pre_update_plan, _windows_gateway_resume=_windows_gateway_resume, - update_complete=update_complete) + pre_update_version=opts.pre_update_version, + plan=_pre_update_plan, windows_resume=_windows_gateway_resume) def _cmd_update_impl(args, gateway_mode: bool): @@ -1450,12 +1444,14 @@ def _cmd_update_impl(args, gateway_mode: bool): try: update_complete = _update_via_zip( args, had_desktop_app_before_update=had_desktop_app_before_update, + gateway_mode=gateway_mode, pre_update_snapshot_id=pre_update_snapshot_id, + pre_update_version=opts.pre_update_version, + _pre_update_plan=_pre_update_plan, _windows_gateway_resume=_windows_gateway_resume, target_sha=release_sha, **({"target_repository": target_repository} if target_repository else {})) finally: - _m()._resume_windows_gateways_after_update(_windows_gateway_resume) - if gateway_mode: - _write_gateway_update_exit_code(update_complete) + if _windows_gateway_resume and _windows_gateway_resume.get("resume_needed"): + _m()._resume_windows_gateways_after_update(_windows_gateway_resume) if not update_complete: sys.exit(1) return @@ -1543,9 +1539,12 @@ def _cmd_update_impl(args, gateway_mode: bool): try: _handle_update_called_process_error( e, args, gateway_mode, had_desktop_app_before_update, target_sha=release_sha, - target_repository=target_repository) + target_repository=target_repository, + pre_update_snapshot_id=pre_update_snapshot_id, pre_update_version=opts.pre_update_version, + _pre_update_plan=_pre_update_plan, _windows_gateway_resume=_windows_gateway_resume) finally: - _m()._resume_windows_gateways_after_update(_windows_gateway_resume) + if _windows_gateway_resume and _windows_gateway_resume.get("resume_needed"): + _m()._resume_windows_gateways_after_update(_windows_gateway_resume) # ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ---- diff --git a/hermes_cli/update_cmd_git.py b/hermes_cli/update_cmd_git.py index 293e3a9a20..834ea157fd 100644 --- a/hermes_cli/update_cmd_git.py +++ b/hermes_cli/update_cmd_git.py @@ -45,7 +45,7 @@ def _git_run(git_cmd, args, cwd=None, *, check=False): def _git_stdout(git_cmd, args, cwd, **kw) -> Optional[str]: """Stripped stdout of a successful ``_git_run``; ``None`` on non-zero exit or any exception.""" - from hermes_cli.update_cmd_git import _git_run + from hermes_cli.update_cmd import _git_run with suppress(Exception): result = _git_run(git_cmd, args, cwd, **kw) if result.returncode == 0: diff --git a/hermes_cli/update_cmd_zip.py b/hermes_cli/update_cmd_zip.py index 20ea9b056b..945500f8ec 100644 --- a/hermes_cli/update_cmd_zip.py +++ b/hermes_cli/update_cmd_zip.py @@ -14,8 +14,6 @@ import sys from pathlib import Path from typing import Optional -from hermes_cli.update_cmd_common import _best_effort - # Log-record parity with the origin module. logger = logging.getLogger("hermes_cli.update_cmd") @@ -319,24 +317,23 @@ def _download_and_swap_zip(branch: str, zip_url: str) -> None: def _update_via_zip(args, *, had_desktop_app_before_update: bool = False, - target_sha: str | None = None, target_repository: str | None = None) -> bool: + target_sha: str | None = None, target_repository: str | None = None, + gateway_mode: bool | None = None, pre_update_snapshot_id=None, + pre_update_version=None, + _pre_update_plan=None, _windows_gateway_resume=None) -> bool: """Update via ZIP when Windows git file I/O fails; dependency/build failures propagate. A supplied commit keeps the archive on the target selected before Git failed. """ - from hermes_cli.update_cmd import ( - _m, - _print_curator_first_run_notice, - _print_curator_recent_run_notice, - _read_project_version, - _verify_and_restore_state_dbs_post_update, - ) + from hermes_cli.update_cmd import _m, _read_project_version from hermes_cli.update_cmd_maint import ( - _prepare_updated_checkout, _refresh_dashboard_after_update, - _print_verified_update_completion, _update_complete_message) - from hermes_cli.update_cmd_maint import _print_bundled_skills_sync_report - from hermes_cli.update_cmd_maint import _sweep_bytecode_after_update - pre_update_version = _read_project_version() # snapshot before files are replaced, for the completion line + _prepare_updated_checkout, _sweep_bytecode_after_update) + from hermes_cli.update_finish import finish_update + + if pre_update_version is None: + pre_update_version = _read_project_version() + if gateway_mode is None: + gateway_mode = bool(getattr(args, "gateway", False)) # The static archive would silently ignore --branch — the exact silent-divergence bug it exists to # prevent. Refuse rather than lie. branch = _m()._resolve_update_branch(args) @@ -360,25 +357,17 @@ def _update_via_zip(args, *, had_desktop_app_before_update: bool = False, _download_and_swap_zip(branch, f"https://github.com/{repository}/archive/{ref}.zip") _sweep_bytecode_after_update(branch) _prepare_updated_checkout(_m().PROJECT_ROOT, desktop=had_desktop_app_before_update) - with suppress(Exception): - print("→ Syncing bundled skills...") - _print_bundled_skills_sync_report() - # Seed the model-catalog disk cache from the fresh checkout (same rationale as _cmd_update_impl). Non-fatal. - with _best_effort('Model catalog seed during zip update failed: %s'): - from hermes_cli.model_catalog import seed_cache_from_checkout - if seed_cache_from_checkout(_m().PROJECT_ROOT): - print(" ✓ Model catalog cache refreshed from checkout") - # state.db integrity guard: root home AND every sibling profile, each auto-restored from its own snapshot. - with _best_effort('Post-update state.db integrity check (zip path) failed: %s'): - # See #97994. - _verify_and_restore_state_dbs_post_update() - update_complete = _print_verified_update_completion(_update_complete_message(pre_update_version)) - with _best_effort('Curator first-run notice failed: %s'): - _print_curator_first_run_notice() - with _best_effort('Curator recent-run notice failed: %s'): - _print_curator_recent_run_notice() - _refresh_dashboard_after_update() - with _best_effort('Update receipt finalize (zip path) failed: %s'): - from hermes_cli.update_receipt import finalize_update_receipt - finalize_update_receipt("success" if update_complete else "partial") - return update_complete + try: + finish_update( + assume_yes=bool(getattr(args, "yes", False)), gateway_mode=gateway_mode, + pre_update_snapshot_id=pre_update_snapshot_id, + had_desktop_app_before_update=had_desktop_app_before_update, + pre_update_version=pre_update_version, + plan=_pre_update_plan, windows_resume=_windows_gateway_resume) + except SystemExit as exc: + # Shared completion reports an unsafe runtime/incomplete fleet with exit 1. + # Keep ZIP's legacy bool contract; swap and preparation failures still raise. + if exc.code != 1: + raise + return False + return True diff --git a/hermes_cli/update_finish.py b/hermes_cli/update_finish.py new file mode 100644 index 0000000000..55a8bf4ecf --- /dev/null +++ b/hermes_cli/update_finish.py @@ -0,0 +1,137 @@ +"""Post-checkout completion shared by current and historical update callers.""" +from __future__ import annotations + +import json +from pathlib import Path +import sys + + +def finish_update(*, assume_yes, gateway_mode, pre_update_snapshot_id, + had_desktop_app_before_update, pre_update_version, + plan, windows_resume) -> None: + """Finish the selected checkout; never fetch, switch branches or restore a stash.""" + from hermes_cli.update_cmd import ( + _run_post_update_maintenance, + _restart_gateway_fleet_after_update, _verify_fleet_after_update, + _write_gateway_update_exit_code, _resume_windows_gateways_and_merge_outcome, + ) + + complete = _run_post_update_maintenance( + assume_yes=assume_yes, gateway_mode=gateway_mode, + pre_update_snapshot_id=pre_update_snapshot_id, + had_desktop_app_before_update=had_desktop_app_before_update, + pre_update_version=pre_update_version, + ) + # Restart can kill this process's gateway cgroup; record its result first. + if gateway_mode: + _write_gateway_update_exit_code(complete) + restarted = _restart_gateway_fleet_after_update(plan, gateway_mode) + _resume_windows_gateways_and_merge_outcome(restarted, windows_resume, gateway_mode) + _verify_fleet_after_update(restarted, _pre_update_plan=plan, + _windows_gateway_resume=windows_resume, update_complete=complete) + + +def _restore_plan(data): + if not data: + return None + from dataclasses import fields + from hermes_cli.update_inventory import RuntimeRecord, UpdatePlan + + values = {field.name: data[field.name] for field in fields(UpdatePlan) if field.name in data} + names = {field.name for field in fields(RuntimeRecord)} + values["runtimes"] = [RuntimeRecord(**{key: value for key, value in row.items() if key in names}) + for row in data.get("runtimes", [])] + return UpdatePlan(**values) + + +def main(context: Path, result: Path) -> int: + request = json.loads(context.read_text(encoding="utf-8")) + root = Path(request["root"]) + sys.path.insert(0, str(root)) + from hermes_cli import update_receipt + + token = request.get("windows_resume") + resume = None + restarting = request.get("restart_update", False) + cli_started = False + begun = False + code = 1 + try: + if not restarting: + update_receipt.begin_update_receipt(previous=request.get("receipt"), correlation_id=request["update_id"]) + begun = update_receipt._current.get() is not None + if not begun: + raise RuntimeError("cannot create takeover receipt") + from pm import receipt + receipt.accept_worker_receipt(request.get("pm_receipt"), request["update_id"]) + update_receipt.record_step("historical_takeover", True, "continuing in the selected interpreter") + # Preparation has already committed the dependency generation and + # selected store Python. Bootstrap must run before any app imports; + # its ordinary currency check is now a no-op, not another update. + sys.argv = list(request["argv"]) if restarting else [str(root / "hermes"), "update"] + import hermes_bootstrap # noqa: F401 + # Import failures are update failures too: keep the original receipt + # open before importing the application graph from the new checkout. + from hermes_cli import main as cli + from hermes_cli.source_build import build_update_products + from hermes_cli.update_lock import UpdateLock, describe_holder + from hermes_cli.update_cmd_windows import _resume_windows_gateways_after_update + + cli.PROJECT_ROOT = root + if restarting: + # A known pre-pull capture has no completion worklist yet. The + # current CLI owns its plan, receipt and lock; don't start a nested + # receipt or execute the finish-only path against the old checkout. + cli_started = True + cli.main() + else: + plan = _restore_plan(request.get("plan")) + with UpdateLock() as lock: + if not lock.acquired and lock.holder is not None: + print(describe_holder(lock.holder), file=sys.stderr) + code = 2 + return code + resume = _resume_windows_gateways_after_update + build_update_products(root, desktop=request["desktop"]) + finish_update( + assume_yes=request["assume_yes"], gateway_mode=request["gateway_mode"], + pre_update_snapshot_id=request.get("pre_update_snapshot_id"), + had_desktop_app_before_update=request["desktop"], + pre_update_version=request.get("pre_update_version"), + plan=plan, windows_resume=token, + ) + code = 0 + except SystemExit as exc: + code = exc.code if isinstance(exc.code, int) else 1 + except Exception as exc: + update_receipt.record_step("historical_completion", False, str(exc)) + print(f"Update completion failed: {exc}", file=sys.stderr, flush=True) + finally: + if code and request.get("gateway_mode"): + # Even an application import failure must wake the gateway watcher. + from hermes_constants import get_hermes_home + from hermes_cli.runtime_state import _atomic_bytes + + _atomic_bytes(get_hermes_home() / ".update_exit_code", b"1") + if restarting and not cli_started: + # Startup failed before the replacement command could own a + # receipt. Preserve the original handoff, just like preparation. + update_receipt.begin_update_receipt(previous=request.get("receipt"), correlation_id=request["update_id"]) + begun = update_receipt._current.get() is not None + if resume is not None: + try: + resume(token) + except Exception as exc: + code = 1 + print(f"Gateway recovery failed: {exc}", file=sys.stderr, flush=True) + handled = cli_started or (begun and update_receipt._current.get() is None) + written = update_receipt.finalize_pending_update_receipt(code, "historical takeover completion") + result.write_text(json.dumps({ + "resume_handled": not token or not token.get("resume_needed"), + "receipt_handled": handled or written is not None, + }), encoding="utf-8") + return code + + +if __name__ == "__main__": + raise SystemExit(main(Path(sys.argv[1]), Path(sys.argv[2]))) diff --git a/hermes_cli/update_lock.py b/hermes_cli/update_lock.py index 6b79bdd676..95de0ab6e4 100644 --- a/hermes_cli/update_lock.py +++ b/hermes_cli/update_lock.py @@ -48,18 +48,12 @@ def update_marker_path() -> Path: def _pid_alive(pid: int) -> bool: - """True when a process with ``pid`` currently exists. - - Delegates to :func:`gateway.status._pid_exists`. Do NOT hand-roll ``os.kill(pid, 0)``: on - Windows CPython routes ``sig=0`` to ``GenerateConsoleCtrlEvent``, which Ctrl+C's the - target's whole console process group (bpo-14484). Any pid we cannot evaluate counts as - dead so a corrupt marker never wedges the lock. - """ + """Use the dependency-free, Windows-safe probe before PM is available.""" if pid <= 0: return False try: - from gateway.status import _pid_exists - return bool(_pid_exists(pid)) + from hermes_cli._early_recovery import _pid_is_running + return _pid_is_running(pid) except Exception as exc: logger.debug("Could not probe pid %s: %s", pid, exc) return False @@ -84,6 +78,8 @@ def _is_ancestor_pid(pid: int) -> bool: """ if pid <= 0: return False + if pid == os.getppid(): + return True try: import psutil return any(parent.pid == pid for parent in psutil.Process().parents()) diff --git a/hermes_cli/update_receipt.py b/hermes_cli/update_receipt.py index 0c6ae7da9d..037363660b 100644 --- a/hermes_cli/update_receipt.py +++ b/hermes_cli/update_receipt.py @@ -173,12 +173,12 @@ class UpdateReceipt: def _receipt_dir() -> Path: - from hermes_cli.config import get_hermes_home + from hermes_constants import get_hermes_home return get_hermes_home() / "logs" / "update_receipts" -def begin_update_receipt() -> None: +def begin_update_receipt(*, previous: dict | None = None, correlation_id: str | None = None) -> None: """Start recording a new update receipt. Nested updates are safe: the previous receipt (if any) is preserved @@ -187,6 +187,10 @@ def begin_update_receipt() -> None: correlation. Never raises.""" try: receipt = UpdateReceipt() + if previous: + receipt.data.update(copy.deepcopy(previous)) + receipt.correlation_id = correlation_id or receipt.correlation_id + receipt.data.update(update_id=receipt.correlation_id, outcome="running", finished_at=None) except Exception as exc: # pragma: no cover - defensive logger.debug("Could not start update receipt: %s", exc) return @@ -290,15 +294,16 @@ def finalize_update_receipt(outcome: str, fleet: list | None = None, stop_reason # the same process+second — the correlation id makes the name unique # per update run. Atomic write for BOTH the stamped receipt and the # latest.json pointer (no torn readers). - import utils + from hermes_cli.runtime_state import _atomic_bytes path = directory / ( f"update_{time.strftime('%Y%m%d_%H%M%S')}_{os.getpid()}_" f"{receipt.correlation_id}.json" ) - utils.atomic_json_write(path, receipt.data, default=str) + payload = (json.dumps(receipt.data, indent=2, default=str) + "\n").encode("utf-8") + _atomic_bytes(path, payload) with suppress(Exception): # stable pointer for the dashboard/desktop - utils.atomic_json_write(directory / "latest.json", receipt.data, default=str) + _atomic_bytes(directory / "latest.json", payload) _prune_old_receipts(directory) return path except Exception as exc: # pragma: no cover - defensive diff --git a/hermes_cli/update_serve_resume.py b/hermes_cli/update_serve_resume.py new file mode 100644 index 0000000000..c4732f38da --- /dev/null +++ b/hermes_cli/update_serve_resume.py @@ -0,0 +1,59 @@ +"""Fresh-process adapter for manual backends paused by historical updaters.""" +from __future__ import annotations + +import json +from pathlib import Path +import sys + + +def main(context: Path, result: Path) -> int: + handled = False + try: + request = json.loads(context.read_text(encoding="utf-8")) + root = Path(request["root"]) + token = request["stopped_serves"] + if not token.get("pending"): + handled = True + return 0 + sys.path.insert(0, str(root)) + from hermes_cli.runtime_paths import activate_dependencies + activate_dependencies(root) + from hermes_cli.dashboard_procs import _filter_dashboard_respawn_candidates + from hermes_cli.main_dashboard import _respawn_dashboard_processes + + candidates = [] + skipped = 0 + for entry in token.get("entries") or []: + port = entry.get("port") + if (entry.get("purpose") not in ("serve", "dashboard") + or type(port) is not int or not 0 < port <= 65535): + skipped += 1 + continue + command = [sys.executable, str(root / "hermes")] + profile = entry.get("profile") + if profile and profile != "default": + command += ["--profile", str(profile)] + command.append(entry["purpose"]) + if entry.get("host"): + command += ["--host", str(entry["host"])] + command += ["--port", str(port)] + candidates.append((entry.get("pid", 0), command, entry.get("hermes_home") or None)) + commands = _filter_dashboard_respawn_candidates(candidates, own_home=request.get("home")) + skipped += len(candidates) - len(commands) + # An acknowledged attempt is terminal even if spawning failed: replaying + # the whole token at atexit would duplicate the successful backends. + failed = _respawn_dashboard_processes(commands) if commands else [] + handled = True + if skipped or failed: + print("Some stopped backends could not be relaunched; restart them manually.", file=sys.stderr) + return 1 + return 0 + except Exception as exc: + print(f"Stopped serve recovery failed: {exc}", file=sys.stderr, flush=True) + return 1 + finally: + result.write_text(json.dumps({"serves_handled": handled}), encoding="utf-8") + + +if __name__ == "__main__": + raise SystemExit(main(Path(sys.argv[1]), Path(sys.argv[2]))) diff --git a/pm/__init__.py b/pm/__init__.py index 801d1e997b..b5733040c2 100644 --- a/pm/__init__.py +++ b/pm/__init__.py @@ -73,3 +73,4 @@ __all__ = [ ] import pm.packages # noqa: E402,F401 (registers the built-in definitions) +import pm.security_packages # noqa: E402,F401 diff --git a/pm/build_operations.py b/pm/build_operations.py index 38d258b774..57a39ce114 100644 --- a/pm/build_operations.py +++ b/pm/build_operations.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections.abc import Mapping, Sequence from pathlib import Path -import shutil import sys from pm.package import InstallError @@ -64,7 +63,7 @@ def build_requirements_environment( Wheelhouse builds disable indexes and source builds. Failure removes only this invocation's exclusively claimed output, not prior builds. """ - from pm.environment import managed_environment, prune_site_pth + from pm.environment import _fresh_build, managed_environment from pm.operations import _require_install_allowed, _requirements _require_install_allowed(explicit) @@ -80,16 +79,8 @@ def build_requirements_environment( cache=Path(cache) if cache is not None else None, env=env, offline=offline, explicit=explicit, output=sys.stderr, ) - out.mkdir(parents=True) - try: - environment.create() + with _fresh_build(environment, sealed=sealed): environment.install_requirements(requirements, wheelhouse=wheelhouse) - environment.check() - if sealed: - prune_site_pth(out) - except BaseException: - shutil.rmtree(out, ignore_errors=True) - raise return environment.executable diff --git a/pm/cli.py b/pm/cli.py index 6632e88399..8c52ebb65e 100644 --- a/pm/cli.py +++ b/pm/cli.py @@ -8,7 +8,6 @@ import os import shutil import subprocess import sys -import threading from pathlib import Path from pm.ensure import _facts, _lockfile, _store, ensure, stage_only @@ -245,37 +244,14 @@ def cmd_gc(args) -> int: def _run_live(cmd: list[str], *, cwd, env, timeout: int = 3600) -> tuple[int, str]: - """Run cmd with its output streamed through our stdout — a long uv - venv build must prove liveness in a piped (CI) log, not vanish until - exit — while still capturing the tail for the failure message. A - reader thread drains output so proc.wait(timeout) keeps the wall-clock - kill the old subprocess.run(timeout=) had. Returns (returncode, last - ~2k chars of combined output).""" - proc = subprocess.Popen( - cmd, cwd=cwd, env=env, stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, text=True, bufsize=1, errors="replace", - ) - tail = "" - lock = threading.Lock() + """Stream CI progress with the Python engine's bounded drain and diagnostic tail.""" + from pm.environment import _run_streaming - def drain() -> None: - nonlocal tail - for line in proc.stdout: - print(line, end="", flush=True) - with lock: - tail = (tail + line)[-2000:] - - thread = threading.Thread(target=drain, daemon=True) - thread.start() try: - code = proc.wait(timeout=timeout) - except subprocess.TimeoutExpired: - proc.kill() - proc.wait() - raise RuntimeError(f"{cmd[0]} timed out after {timeout}s") - thread.join() - with lock: - return code, tail + result = _run_streaming(cmd, cwd=cwd, env=env, timeout=timeout, output=sys.stdout) + except subprocess.TimeoutExpired as exc: + raise RuntimeError(f"{cmd[0]} timed out after {timeout}s") from exc + return result.returncode, result.stderr def cmd_update(args) -> int: diff --git a/pm/ensure.py b/pm/ensure.py index b1e2474773..e57d55997d 100644 --- a/pm/ensure.py +++ b/pm/ensure.py @@ -7,6 +7,7 @@ import json import logging import shutil import threading +from contextlib import contextmanager from dataclasses import dataclass from pathlib import Path from typing import Optional @@ -26,11 +27,32 @@ LOG = logging.getLogger(__name__) # liveness to a UI. -def _artifact_progress(progress, index: int, count: int): - if progress is None: - return None - label = f"{index + 1}/{count}" if count > 1 else "" - return lambda done, total: progress("download", done, total, label) +def _prepare_artifacts(package, store, scratch, artifacts, version, target, *, + progress=None, pause_event=None, download_progress=None): + def tick(done, total, ranges): + if progress is not None: + active = next(reversed(ranges)) + index = next(i for i, artifact in enumerate(artifacts) if artifact["url"] == active) + label = f"{index + 1}/{len(artifacts)}" if len(artifacts) > 1 else "" + progress("download", done, total, label) + if download_progress is not None: + download_progress(done, total, ranges) + + staged = scratch / "tree" + archives = store.fetch_many(artifacts, scratch, progress=tick, pause_event=pause_event) + for index, archive in enumerate(archives): + if pause_event is not None and pause_event.is_set(): + raise DownloadPaused("install paused") + if progress is not None: + label = f"{index + 1}/{len(artifacts)}" if len(artifacts) > 1 else "" + progress("unpack", 0, 0, label) + # unpack empties its destination; extract additional archives separately. + destination = staged if index == 0 else scratch / f"extra-{index}" + package.unpack(archive, destination, target) + if index: + merge_tree(destination, staged) + package.stage(store, staged, version, target) + return staged def _lockfile() -> Lockfile: @@ -221,6 +243,25 @@ def _restore_previous_entry(store: Store, entry, previous) -> None: _remove_entry(store, displaced.name) +@contextmanager +def _publish_entry(package, store, staged, entry, previous_entry, target): + """Keep rollback live through the caller's native facts commit, if any.""" + if entry.exists() or entry.is_symlink(): + entry.rename(previous_entry) + try: + store.publish(staged, entry.name) + reason = package.verify(entry, target) + if reason: + raise InstallError(package.name, f"published entry failed verification: {reason}") + yield + except BaseException: + if previous_entry.exists(): + _restore_previous_entry(store, entry, previous_entry) + raise + if previous_entry.exists(): + _remove_entry(store, previous_entry.name) + + def _install( package: Package, lockfile: Lockfile, @@ -275,15 +316,6 @@ def _install( staged = scratch / "tree" previous = facts.get(package.name) try: - def tick(done, total, ranges): - if progress is not None: - active = next(reversed(ranges)) - index = next(i for i, artifact in enumerate(artifacts) if artifact["url"] == active) - label = f"{index + 1}/{len(artifacts)}" if len(artifacts) > 1 else "" - progress("download", done, total, label) - if download_progress is not None: - download_progress(done, total, ranges) - if copy_from is not None: source_facts, source_store = copy_from source = source_facts.get(package.name) @@ -295,22 +327,9 @@ def _install( if tree_digest(staged) != source["digest"]: raise InstallError(package.name, "copied bytes do not match the bundled source") else: - archives = store.fetch_many(artifacts, scratch, progress=tick, pause_event=pause_event) - for index, archive in enumerate(archives): - if pause_event is not None and pause_event.is_set(): - raise DownloadPaused("install paused") - label = f"{index + 1}/{len(artifacts)}" if len(artifacts) > 1 else "" - if progress is not None: - progress("unpack", 0, 0, label) - if index == 0: - package.unpack(archive, staged, target) - continue - # unpack() empties its destination; merge additional - # archives only after extracting them separately. - extra = scratch / f"extra-{index}" - package.unpack(archive, extra, target) - merge_tree(extra, staged) - package.stage(store, staged, version, target) + staged = _prepare_artifacts(package, store, scratch, artifacts, version, target, + progress=progress, pause_event=pause_event, + download_progress=download_progress) if pause_event is not None and pause_event.is_set(): raise DownloadPaused("install paused") if progress is not None: @@ -318,24 +337,12 @@ def _install( reason = package.verify(staged, target) if reason: raise InstallError(package.name, f"staged entry failed verification: {reason}") - if entry.exists(): - entry.rename(previous_entry) - try: - store.publish(staged, entry_name) - reason = package.verify(entry, target) - if reason: - raise InstallError(package.name, f"published entry failed verification: {reason}") + with _publish_entry(package, store, staged, entry, previous_entry, target): facts.record( package.name, version, entry_name, package.env(entry, target), store.root, target=target, artifacts=[a["sha256"] for a in artifacts], digest=tree_digest(entry), ) - except BaseException: - if previous_entry.exists(): - _restore_previous_entry(store, entry, previous_entry) - raise - if previous_entry.exists(): - _remove_entry(store, previous_entry.name) _remove_downloads(store, artifacts) except (InstallError, DownloadPaused): raise @@ -405,37 +412,14 @@ def stage_only(name: str, target: str, progress=None) -> "Path": "run `hermes pm lock --bump` for this package", ) with store.scratch() as scratch: - staged = scratch / "tree" - for index, artifact in enumerate(artifacts): - archive = store.fetch( - artifact["url"], artifact["sha256"], scratch, - progress=_artifact_progress(progress, index, len(artifacts)), - ) - if index == 0: - package.unpack(archive, staged, target) - else: - extra = scratch / f"extra-{index}" - package.unpack(archive, extra, target) - merge_tree(extra, staged) - package.stage(store, staged, version, target) + staged = _prepare_artifacts(package, store, scratch, artifacts, version, target, + progress=progress) reason = package.verify(staged, target) if reason: raise InstallError(package.name, f"staged entry failed verification: {reason}") (staged / ".pm-stage-pin.json").write_text(pin, encoding="utf-8") - # Keep the old pin usable until the replacement has been verified. - if entry.exists() or entry.is_symlink(): - entry.rename(previous_entry) - try: - store.publish(staged, entry_name) - reason = package.verify(entry, target) - if reason: - raise InstallError(package.name, f"published entry failed verification: {reason}") - except BaseException: - if previous_entry.exists(): - _restore_previous_entry(store, entry, previous_entry) - raise - if previous_entry.exists(): - _remove_entry(store, previous_entry.name) + with _publish_entry(package, store, staged, entry, previous_entry, target): + pass # Foreign entries carry the pin marker, never host facts. _remove_downloads(store, artifacts) return store.entry(entry_name) @@ -454,7 +438,7 @@ def ensure( policy names, so the policy does not apply to them. ``progress(stage, done, total, label)`` reports the slow parts of an - install to a UI; see _artifact_progress. + install to a UI; see _prepare_artifacts. """ if isinstance(get_package(name), StatePackage): sync_venv(explicit=explicit) @@ -575,7 +559,11 @@ def sync_venv(extras: Optional[list[str]] = None, *, explicit: bool = False, plu if unsupported: raise InstallError("venv", f"extras {unsupported} are not supported by this Python/platform", "choose a supported provider; no dependency environment was changed") - frozen = read_features() if repair or not lazy_installs_allowed() else None + frozen = read_features() + # Without a frozen declaration, explicit source setup needs no policy + # read: the config loader initializes/chmods unrelated user state. + if frozen is not None and not repair and lazy_installs_allowed(): + frozen = None if frozen is not None and extras: outside = sorted(set(extras) - set(frozen)) if outside: @@ -781,11 +769,13 @@ def _store_path_dirs() -> list[str]: return dirs -def activate() -> None: +def activate() -> list[str]: """Make the installed store usable: prepend its tool dirs to os.environ['PATH'] so reactive `shutil.which('git'|'bash'|'ffmpeg'|...)` resolves the bundled binaries. The gate is `check()` — if the store is broken, refuse to inject (fail fast rather than serving a partial PATH). + Return the check's problems, or an empty list on success, so startup + callers can report the verdict without checking the store twice. This is the ONE sanctioned global PATH write: PATH is the discovery contract every `which` reads, not a tool-specific env leak. Store-first @@ -793,14 +783,16 @@ def activate() -> None: """ import os - if check(): - return # broken store → do not provision; callers surface `hermes pm install` + problems = check() + if problems: + return problems dirs = _store_path_dirs() if not dirs: - return + return [] existing = os.environ.get("PATH", "") prefix = os.pathsep.join(dirs) existing_lower = {p.lower() for p in existing.split(os.pathsep) if p} missing = [d for d in dirs if d.lower() not in existing_lower] if missing: os.environ["PATH"] = os.pathsep.join([*missing, existing]) if existing else os.pathsep.join(missing) + return [] diff --git a/pm/environment.py b/pm/environment.py index 55c4eb2293..2b6321a307 100644 --- a/pm/environment.py +++ b/pm/environment.py @@ -6,11 +6,13 @@ staged toolchain directly without recursing through the worker it is building. from __future__ import annotations import codecs -from collections.abc import Mapping, Sequence +from collections.abc import Iterator, Mapping, Sequence +from contextlib import contextmanager from dataclasses import dataclass import io import os from pathlib import Path +import shutil import subprocess import tempfile import time @@ -123,6 +125,23 @@ def managed_environment(destination: Path, *, python: Path | None = None, ) +@contextmanager +def _fresh_build(environment: PythonEnvironment, *, sealed: bool) -> Iterator[None]: + """Own only the output claimed by this build, including failed validation.""" + out = environment.destination + # A concurrent creator wins intact: never enter cleanup before mkdir succeeds. + out.mkdir(parents=True) + try: + environment.create() + yield + environment.check() + if sealed: + prune_site_pth(out) + except BaseException: + shutil.rmtree(out, ignore_errors=True) + raise + + @dataclass(frozen=True, kw_only=True) class PythonEnvironment: uv: Path diff --git a/pm/lock.json b/pm/lock.json index 502dccfda2..bd69c5924a 100644 --- a/pm/lock.json +++ b/pm/lock.json @@ -9,6 +9,35 @@ }, "version": "0.26.0" }, + "bws": { + "artifacts": { + "darwin-arm64": { + "sha256": "67ab9bc345e2ec3b5dfddd116f938fdab79538042623a6bcca5ca0c1b0c42d95", + "url": "https://github.com/bitwarden/sdk-sm/releases/download/bws-v2.0.0/bws-macos-universal-2.0.0.zip" + }, + "darwin-x64": { + "sha256": "67ab9bc345e2ec3b5dfddd116f938fdab79538042623a6bcca5ca0c1b0c42d95", + "url": "https://github.com/bitwarden/sdk-sm/releases/download/bws-v2.0.0/bws-macos-universal-2.0.0.zip" + }, + "linux-arm64": { + "sha256": "de61bfbacbcf6e3648ddc8dbd6ca8bb2245438ccf5f94d54129997528f5a752d", + "url": "https://github.com/bitwarden/sdk-sm/releases/download/bws-v2.0.0/bws-aarch64-unknown-linux-musl-2.0.0.zip" + }, + "linux-x64": { + "sha256": "ea6eb18f1270388f12d15a35f919ccb3488d32a777433baefe883d561f46fc73", + "url": "https://github.com/bitwarden/sdk-sm/releases/download/bws-v2.0.0/bws-x86_64-unknown-linux-musl-2.0.0.zip" + }, + "win32-arm64": { + "sha256": "1a02fca3a855ba32d8f0c40813af8da705cf1e7cf9b932164959531e57eccbe0", + "url": "https://github.com/bitwarden/sdk-sm/releases/download/bws-v2.0.0/bws-aarch64-pc-windows-msvc-2.0.0.zip" + }, + "win32-x64": { + "sha256": "4284944f3b0c7b97a4d4105c715cd814c744ceff0405481a213937955e31d866", + "url": "https://github.com/bitwarden/sdk-sm/releases/download/bws-v2.0.0/bws-x86_64-pc-windows-msvc-2.0.0.zip" + } + }, + "version": "2.0.0" + }, "chromium": { "artifacts": { "darwin-arm64": { @@ -142,6 +171,83 @@ }, "version": "2.53.0+3" }, + "iron-proxy": { + "artifacts": { + "darwin-arm64": [ + { + "sha256": "6aa2d3d78ba87bdfacaef8f8465ffd289f8d23ab4d3f433e8c0b2b77f887632a", + "url": "https://github.com/paradigmxyz/iron-proxy/releases/download/v0.39.0/iron-proxy_0.39.0_darwin_arm64.tar.gz" + }, + { + "sha256": "8fb3193b53ca296e2f93ff0fd69d289340cffe9a36c0de22e47ea5cfea899b37", + "url": "https://github.com/paradigmxyz/iron-proxy/releases/download/v0.39.0/checksums.txt" + }, + { + "sha256": "1ff70f0bd8699b2f1c2c3545e6aa9034d24267fc01d1b9aaa93d73e893cb610b", + "url": "https://github.com/paradigmxyz/iron-proxy/releases/download/v0.39.0/checksums.txt.asc" + }, + { + "sha256": "ce685e36e7e7d184ffa82b35bd5cbe83db010ac38bc504a0e225333453992c56", + "url": "https://raw.githubusercontent.com/paradigmxyz/iron-proxy/be5f255d0d9d10d8573bd65f480dd48a07772bf1/public-key.asc" + } + ], + "darwin-x64": [ + { + "sha256": "9c278663d2e9c04fb3f6b1c18588fc2095974b42347c7885d10014835fc4b24a", + "url": "https://github.com/paradigmxyz/iron-proxy/releases/download/v0.39.0/iron-proxy_0.39.0_darwin_amd64.tar.gz" + }, + { + "sha256": "8fb3193b53ca296e2f93ff0fd69d289340cffe9a36c0de22e47ea5cfea899b37", + "url": "https://github.com/paradigmxyz/iron-proxy/releases/download/v0.39.0/checksums.txt" + }, + { + "sha256": "1ff70f0bd8699b2f1c2c3545e6aa9034d24267fc01d1b9aaa93d73e893cb610b", + "url": "https://github.com/paradigmxyz/iron-proxy/releases/download/v0.39.0/checksums.txt.asc" + }, + { + "sha256": "ce685e36e7e7d184ffa82b35bd5cbe83db010ac38bc504a0e225333453992c56", + "url": "https://raw.githubusercontent.com/paradigmxyz/iron-proxy/be5f255d0d9d10d8573bd65f480dd48a07772bf1/public-key.asc" + } + ], + "linux-arm64": [ + { + "sha256": "1316cb4f20fcfba5042b06d07cd523ffa617de9de2999cd5f1f44474777c48ad", + "url": "https://github.com/paradigmxyz/iron-proxy/releases/download/v0.39.0/iron-proxy_0.39.0_linux_arm64.tar.gz" + }, + { + "sha256": "8fb3193b53ca296e2f93ff0fd69d289340cffe9a36c0de22e47ea5cfea899b37", + "url": "https://github.com/paradigmxyz/iron-proxy/releases/download/v0.39.0/checksums.txt" + }, + { + "sha256": "1ff70f0bd8699b2f1c2c3545e6aa9034d24267fc01d1b9aaa93d73e893cb610b", + "url": "https://github.com/paradigmxyz/iron-proxy/releases/download/v0.39.0/checksums.txt.asc" + }, + { + "sha256": "ce685e36e7e7d184ffa82b35bd5cbe83db010ac38bc504a0e225333453992c56", + "url": "https://raw.githubusercontent.com/paradigmxyz/iron-proxy/be5f255d0d9d10d8573bd65f480dd48a07772bf1/public-key.asc" + } + ], + "linux-x64": [ + { + "sha256": "0ab2e031074a01410caecaa2769c0b38f69cbdf4b6333bce498fe73919f6f9f6", + "url": "https://github.com/paradigmxyz/iron-proxy/releases/download/v0.39.0/iron-proxy_0.39.0_linux_amd64.tar.gz" + }, + { + "sha256": "8fb3193b53ca296e2f93ff0fd69d289340cffe9a36c0de22e47ea5cfea899b37", + "url": "https://github.com/paradigmxyz/iron-proxy/releases/download/v0.39.0/checksums.txt" + }, + { + "sha256": "1ff70f0bd8699b2f1c2c3545e6aa9034d24267fc01d1b9aaa93d73e893cb610b", + "url": "https://github.com/paradigmxyz/iron-proxy/releases/download/v0.39.0/checksums.txt.asc" + }, + { + "sha256": "ce685e36e7e7d184ffa82b35bd5cbe83db010ac38bc504a0e225333453992c56", + "url": "https://raw.githubusercontent.com/paradigmxyz/iron-proxy/be5f255d0d9d10d8573bd65f480dd48a07772bf1/public-key.asc" + } + ] + }, + "version": "0.39.0" + }, "llamacpp-cpu": { "artifacts": { "darwin-arm64": { @@ -355,6 +461,83 @@ }, "version": "sha256:c14ffb703abf44f2d3bf7fdffdcc50df7d1454d09c705be190e0342a0b47f303" }, + "tirith": { + "artifacts": { + "darwin-arm64": [ + { + "sha256": "551f9a6ebf58344e7d0aa06bcc78d7a4f2f7b44b8011be563e0a91976cc9c4df", + "url": "https://github.com/sheeki03/tirith/releases/download/v0.4.2/tirith-aarch64-apple-darwin.tar.gz" + }, + { + "sha256": "bcbb3eeda0f9971db5d60d2eb512970c14601432092f596d7059e0141e272585", + "url": "https://github.com/sheeki03/tirith/releases/download/v0.4.2/checksums.txt" + }, + { + "sha256": "8d069fa95606f59919a897ff0914b06600f3cede4a8e83a0c4b339d9188c42b0", + "url": "https://github.com/sheeki03/tirith/releases/download/v0.4.2/checksums.txt.sig" + }, + { + "sha256": "1f39241c7a334e465f78794533675e94e424ed38e9aa1534b5d7c893bd4fb71c", + "url": "https://github.com/sheeki03/tirith/releases/download/v0.4.2/checksums.txt.pem" + } + ], + "darwin-x64": [ + { + "sha256": "dda8a14df00a49c4b0f607c15a82259f96c94da91b09718a7ebbc34a831d6f54", + "url": "https://github.com/sheeki03/tirith/releases/download/v0.4.2/tirith-x86_64-apple-darwin.tar.gz" + }, + { + "sha256": "bcbb3eeda0f9971db5d60d2eb512970c14601432092f596d7059e0141e272585", + "url": "https://github.com/sheeki03/tirith/releases/download/v0.4.2/checksums.txt" + }, + { + "sha256": "8d069fa95606f59919a897ff0914b06600f3cede4a8e83a0c4b339d9188c42b0", + "url": "https://github.com/sheeki03/tirith/releases/download/v0.4.2/checksums.txt.sig" + }, + { + "sha256": "1f39241c7a334e465f78794533675e94e424ed38e9aa1534b5d7c893bd4fb71c", + "url": "https://github.com/sheeki03/tirith/releases/download/v0.4.2/checksums.txt.pem" + } + ], + "linux-arm64": [ + { + "sha256": "c550b1bfb0c8c872ab3421cd6ef756f260f7cf4981a18cedd49f141fa2d77569", + "url": "https://github.com/sheeki03/tirith/releases/download/v0.4.2/tirith-aarch64-unknown-linux-gnu.tar.gz" + }, + { + "sha256": "bcbb3eeda0f9971db5d60d2eb512970c14601432092f596d7059e0141e272585", + "url": "https://github.com/sheeki03/tirith/releases/download/v0.4.2/checksums.txt" + }, + { + "sha256": "8d069fa95606f59919a897ff0914b06600f3cede4a8e83a0c4b339d9188c42b0", + "url": "https://github.com/sheeki03/tirith/releases/download/v0.4.2/checksums.txt.sig" + }, + { + "sha256": "1f39241c7a334e465f78794533675e94e424ed38e9aa1534b5d7c893bd4fb71c", + "url": "https://github.com/sheeki03/tirith/releases/download/v0.4.2/checksums.txt.pem" + } + ], + "linux-x64": [ + { + "sha256": "efa6bf414a83dba385d4f13137e8677f850ced9102fe74ebb14c72f31df0dc77", + "url": "https://github.com/sheeki03/tirith/releases/download/v0.4.2/tirith-x86_64-unknown-linux-gnu.tar.gz" + }, + { + "sha256": "bcbb3eeda0f9971db5d60d2eb512970c14601432092f596d7059e0141e272585", + "url": "https://github.com/sheeki03/tirith/releases/download/v0.4.2/checksums.txt" + }, + { + "sha256": "8d069fa95606f59919a897ff0914b06600f3cede4a8e83a0c4b339d9188c42b0", + "url": "https://github.com/sheeki03/tirith/releases/download/v0.4.2/checksums.txt.sig" + }, + { + "sha256": "1f39241c7a334e465f78794533675e94e424ed38e9aa1534b5d7c893bd4fb71c", + "url": "https://github.com/sheeki03/tirith/releases/download/v0.4.2/checksums.txt.pem" + } + ] + }, + "version": "0.4.2" + }, "uv": { "artifacts": { "darwin-arm64": { diff --git a/pm/operations.py b/pm/operations.py index a857d3c99f..2568af3eb2 100644 --- a/pm/operations.py +++ b/pm/operations.py @@ -39,7 +39,7 @@ def build_environment( Failure removes only the destination exclusively created by this invocation. Sealed builds prune only the .pth files that refer to build-time state. """ - from pm.environment import managed_environment, prune_site_pth + from pm.environment import _fresh_build, managed_environment source, out = Path(source).absolute(), Path(out).absolute() if not (source / "pyproject.toml").is_file(): @@ -54,17 +54,9 @@ def build_environment( cache=Path(cache) if cache is not None else None, env=env, offline=offline, explicit=explicit, output=sys.stderr, ) - out.mkdir(parents=True) - try: - environment.create() + with _fresh_build(environment, sealed=sealed): environment.sync(source, extras=extras, groups=groups, only_groups=only_groups, all_extras=all_extras, no_install_project=no_install_project, frozen=frozen, timeout=timeout) - environment.check() - if sealed: - prune_site_pth(out) - except BaseException: - shutil.rmtree(out, ignore_errors=True) - raise return environment.executable diff --git a/pm/packages.py b/pm/packages.py index a9491e77b4..31d9ea8bb5 100644 --- a/pm/packages.py +++ b/pm/packages.py @@ -999,29 +999,14 @@ class LlamaCpp(BinaryPackage): def _github_release_digests(repo: str, tag: str) -> dict[str, str]: - import json - import os - import urllib.request + from pm.update import _get_json cached = _release_digest_cache.get((repo, tag)) if cached is not None: return cached url = f"https://api.github.com/repos/{repo}/releases/tags/{tag}" - headers = {"User-Agent": "hermes-pm"} - token = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN") - if token: - headers["Authorization"] = f"Bearer {token}" - from pm.network import retry_network - - def request(): - with urllib.request.urlopen( - urllib.request.Request(url, headers=headers), - timeout=120, - ) as resp: - return json.load(resp) - try: - release = retry_network(request) + release = _get_json(url) except Exception: return {} digests = {} diff --git a/pm/plugin_declarations.py b/pm/plugin_declarations.py new file mode 100644 index 0000000000..7a2f288a5a --- /dev/null +++ b/pm/plugin_declarations.py @@ -0,0 +1,107 @@ +"""Read plugin build declarations without importing CLI configuration or plugin code.""" +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +import tomllib + + +def native_manifest_file(plugin_dir: Path) -> Path | None: + for path in (plugin_dir / "plugin.yaml", plugin_dir / "plugin.yml"): + try: + path.stat() + except FileNotFoundError: + continue + except OSError as exc: + raise ValueError(f"Could not inspect plugin manifest {path}: {exc}") from exc + return path + return None + + +def read_native_manifest(path: Path) -> dict: + import hermes_yaml as yaml + + try: + data = yaml.safe_load(path.read_text(encoding="utf-8-sig")) + except (OSError, UnicodeError, yaml.YAMLError) as exc: + raise ValueError(f"Could not read plugin manifest {path}: {exc}") from exc + if data is None: + return {} + if not isinstance(data, dict): + raise ValueError(f"Plugin manifest must be a mapping: {path}") + return data + + +def manifest_version_error(manifest: dict, name: str) -> str | None: + from hermes_cli.plugins_manifest import SUPPORTED_MANIFEST_VERSION, requires_hermes_error + + reason = requires_hermes_error(manifest) + if reason: + return f"Plugin '{name}' {reason}" + version = manifest.get("manifest_version") + if version is None: + return None + try: + parsed = int(version) + except (TypeError, ValueError): + return f"Plugin '{name}' has invalid manifest_version '{version}' (expected an integer)." + if parsed > SUPPORTED_MANIFEST_VERSION: + return (f"Plugin '{name}' requires manifest_version {version}, " + f"but this installer only supports up to {SUPPORTED_MANIFEST_VERSION}.") + return None + + +@dataclass(frozen=True) +class PythonDeclaration: + files: tuple[Path, ...] + pyproject: Path | None + requirements: tuple[str, ...] + manifest: dict + + @property + def is_member(self) -> bool: + return self.pyproject is not None or bool(self.requirements) + + +def read_python_declaration(plugin_dir: Path) -> PythonDeclaration: + """One effective surface for consent, membership, builds and currency. + + A real pyproject owns packaging. Legacy manifests bridge plugins without + one, including old PM-generated pyprojects. Both YAML suffixes and both + dependency aliases have identical semantics. Reading never initializes a + home, discovers code, or depends on the caller's CLI import state. + """ + native = native_manifest_file(plugin_dir) + files = [native] if native is not None else [] + manifest = read_native_manifest(native) if native is not None else {} + if native is None: + portable = plugin_dir / "plugin.json" + try: + portable.lstat() + except FileNotFoundError: + pass + else: + from hermes_cli.agent_plugins import read_agent_plugin_manifest + + manifest, _diagnostics = read_agent_plugin_manifest(plugin_dir) + files.append(portable) + project = plugin_dir / "pyproject.toml" + try: + text = project.read_text(encoding="utf-8-sig") + except FileNotFoundError: + text = None + if text is not None: + files.append(project) + if "GENERATED by pm" not in text: + document = tomllib.loads(text) + specs = document.get("project", {}).get("dependencies", []) + if not isinstance(specs, list) or any(not isinstance(v, str) for v in specs): + raise ValueError(f"invalid project.dependencies: {project}") + return PythonDeclaration(tuple(files), project, tuple(specs), manifest) + specs = [] + for key in ("pip_dependencies", "python_dependencies"): + values = manifest.get(key, []) + if not isinstance(values, list) or any(not isinstance(v, str) for v in values): + raise ValueError(f"invalid {key}: {native}") + specs.extend(values) + return PythonDeclaration(tuple(files), None, tuple(dict.fromkeys(specs)), manifest) \ No newline at end of file diff --git a/pm/security_packages.py b/pm/security_packages.py new file mode 100644 index 0000000000..45057efcfc --- /dev/null +++ b/pm/security_packages.py @@ -0,0 +1,129 @@ +"""Optional security executables. Acquisition and publication belong to PM.""" +from __future__ import annotations + +import logging +import shutil +from pathlib import Path +from urllib.parse import urlparse + +from pm import paths +from pm.lock import Lockfile +from pm.package import InstallError +from pm.packages import BinaryPackage, _RUST_TRIPLE +from pm.registry import register +from pm.store import flatten_single_dir + + +@register +class Bws(BinaryPackage): + name = "bws" + optional = True + binary_rel = {"posix": "bws", "win32": "bws.exe"} + gaps = {"linux-arm64-bionic": "upstream does not ship an Android binary"} + + def fetch_url(self, version: str, target: str) -> str: + # The static musl build runs on glibc too. One portable Linux artifact + # removes the consumer's ldd probe without losing musl support. + triple = _RUST_TRIPLE[target].replace("linux-gnu", "linux-musl") + platform = "macos-universal" if target.startswith("darwin") else triple + return f"https://github.com/bitwarden/sdk-sm/releases/download/bws-v{version}/bws-{platform}-{version}.zip" + + def stage(self, store, staged: Path, version: str, target: str) -> None: + super().stage(store, staged, version, target) + binary = self.binary(staged, target) + if binary is not None and binary.is_file(): + binary.chmod(0o755) + + +class _SignedBinary(BinaryPackage): + """The same PM artifact transaction also acquires pinned provenance files. + + The consumer owns signature semantics, not a second downloader. Checksum + signatures must cover the *archive* pinned by PM, not an unrelated blob. + Every locked provenance artifact is required even without a verifier on + PATH. This intentionally strengthens cold-install availability: a missing + or corrupt signature/key/certificate cannot silently reduce the pinned + closure. Verifier execution remains optional; explicit rejection is fatal. + """ + optional = True + gaps = { + "win32-x64": "this integration requires a Linux/macOS host (or WSL)", + "win32-arm64": "this integration requires a Linux/macOS host (or WSL)", + "linux-arm64-bionic": "upstream does not ship an Android binary", + } + + def unpack(self, archive: Path, staged: Path, target: str) -> None: + if archive.name.endswith((".txt", ".sig", ".pem", ".asc")): + directory = staged / ".provenance" + directory.mkdir(parents=True) + shutil.copyfile(archive, directory / archive.name) + else: + super().unpack(archive, staged, target) + flatten_single_dir(staged) + + def stage(self, store, staged: Path, version: str, target: str) -> None: + binary = self.binary(staged, target) + if binary is None or not binary.is_file() or binary.is_symlink(): + raise InstallError(self.name, "archive binary is not a regular file") + binary.chmod(0o755) + provenance = staged / ".provenance" + artifacts = Lockfile(paths.lockfile_path()).artifacts(self.name, target) + archive = artifacts[0] + filename = Path(urlparse(archive["url"]).path).name + rows = (line.split() for line in (provenance / "checksums.txt").read_text(encoding="utf-8-sig").splitlines()) + expected = next((row[0] for row in rows if len(row) == 2 and row[1].lstrip("*") == filename), None) + if expected != archive["sha256"]: + raise InstallError(self.name, "release checksums do not cover the pinned archive") + self.verify_provenance(provenance) + + def verify_provenance(self, directory: Path) -> None: + raise NotImplementedError + + +@register +class Tirith(_SignedBinary): + name = "tirith" + binary_rel = {"posix": "tirith"} + + def fetch_url(self, version: str, target: str) -> str: + return f"https://github.com/sheeki03/tirith/releases/download/v{version}/tirith-{_RUST_TRIPLE[target]}.tar.gz" + + def fetch_urls(self, version: str, target: str) -> list[str]: + archive = self.fetch_url(version, target) + base = archive.rsplit("/", 1)[0] + return [archive, *[f"{base}/{name}" for name in ("checksums.txt", "checksums.txt.sig", "checksums.txt.pem")]] + + def verify_provenance(self, directory: Path) -> None: + from tools.tirith_security import _verify_release_provenance + + _, reason = _verify_release_provenance(directory, logging.getLogger(__name__).warning) + if reason: + raise InstallError(self.name, reason) + + +@register +class IronProxy(_SignedBinary): + name = "iron-proxy" + binary_rel = {"posix": "iron-proxy"} + # The release omits its public key asset; pin the key from its source commit. + signing_key_url = "https://raw.githubusercontent.com/paradigmxyz/iron-proxy/be5f255d0d9d10d8573bd65f480dd48a07772bf1/public-key.asc" + + def _probe_env(self) -> dict: + from agent.proxy_sources.iron_proxy import _allowlisted_env + + return _allowlisted_env() + + def fetch_url(self, version: str, target: str) -> str: + platform, arch = target.split("-") + arch = "amd64" if arch == "x64" else arch + return f"https://github.com/paradigmxyz/iron-proxy/releases/download/v{version}/iron-proxy_{version}_{platform}_{arch}.tar.gz" + + def fetch_urls(self, version: str, target: str) -> list[str]: + archive = self.fetch_url(version, target) + base = archive.rsplit("/", 1)[0] + return [archive, f"{base}/checksums.txt", f"{base}/checksums.txt.asc", self.signing_key_url] + + def verify_provenance(self, directory: Path) -> None: + from agent.proxy_sources.iron_proxy import _verify_checksums_signature + + _verify_checksums_signature(directory, directory / "checksums.txt") \ No newline at end of file diff --git a/pm/update.py b/pm/update.py index bbd65ad87d..89b8fa9588 100644 --- a/pm/update.py +++ b/pm/update.py @@ -65,10 +65,6 @@ def minor_of(version: str) -> Optional[tuple[int, int]]: return (nums[0], nums[1]) -def version_in_minor(version: str, minor: tuple[int, int]) -> bool: - return minor_of(version) == minor - - def best_in_minor(versions: list[str], minor: tuple[int, int]) -> Optional[str]: """The highest version in `versions` whose major.minor == `minor`.""" best = None diff --git a/pm/workspace.py b/pm/workspace.py index db37ff6938..64ef95b707 100644 --- a/pm/workspace.py +++ b/pm/workspace.py @@ -18,6 +18,7 @@ if TYPE_CHECKING: from pm import paths from pm.package import InstallError +from pm.plugin_declarations import read_python_declaration, manifest_version_error WORKSPACE_DIRNAME = ".pm-workspace" _MEMBER_EXCLUDE = frozenset({".git", ".venv", "venv", "node_modules", "__pycache__"}) @@ -81,10 +82,10 @@ def members_stamp(plugin_dirs) -> str: for identity, entry in sorted(member_sources(plugin_dirs).items()): h.update(str(identity).encode("utf-8")) h.update(b"\0") - for name in ("pyproject.toml", "plugin.yaml"): - source = entry / name - if source.is_file(): - h.update(source.read_bytes()) + declaration = read_python_declaration(entry) + for source in declaration.files: + h.update(source.name.encode("utf-8")) + h.update(source.read_bytes()) h.update(b"\0") if (entry / "pyproject.toml").is_file(): for directory, dirs, files in os.walk(entry): @@ -209,29 +210,7 @@ def _seed_lock(root: Path, seed_lock: Optional[Path] = None, *, source: Optional def _is_member_candidate(plugin_dir: Path) -> bool: - """A plugin dir is a workspace-member candidate when it declares python - deps: a pyproject.toml (modern), or legacy pip_dependencies/ - python_dependencies in plugin.yaml (the bridge materializes those).""" - import stat - - for name in ("pyproject.toml", "plugin.yaml"): - path = plugin_dir / name - try: - mode = path.stat().st_mode - except FileNotFoundError: - continue - except OSError as exc: - raise ValueError(f"could not inspect plugin metadata: {path}") from exc - if not stat.S_ISREG(mode): - continue - if name == "pyproject.toml": - return True - try: - text = path.read_text(encoding="utf-8-sig") - except (OSError, UnicodeError) as exc: - raise ValueError(f"could not read plugin metadata: {path}") from exc - return "pip_dependencies" in text or "python_dependencies" in text - return False + return read_python_declaration(plugin_dir).is_member def enabled_plugin_dirs(*, proposed_home=None, enabled=None, disabled=None) -> list[Path]: @@ -257,30 +236,16 @@ def enabled_plugin_dirs(*, proposed_home=None, enabled=None, disabled=None) -> l def enabled_member_dirs(*, proposed_home=None, enabled=None, disabled=None) -> list[Path]: """Keep every selected member or refuse an incompatible selection.""" - from hermes_cli.plugins_cmd import _check_manifest_version, _read_manifest_for_install - selected = enabled_plugin_dirs(proposed_home=proposed_home, enabled=enabled, disabled=disabled) + members = [] for path in selected: - _check_manifest_version(_read_manifest_for_install(path), path.name) - return [path for path in selected if _is_member_candidate(path)] - - -def _legacy_requirements(plugin_dir: Path) -> list[str]: - from utils import fast_safe_load - - manifest = plugin_dir / "plugin.yaml" - if not manifest.is_file(): - return [] - data = fast_safe_load(manifest.read_text(encoding="utf-8-sig")) - if not isinstance(data, dict): - raise InstallError("venv", f"invalid plugin manifest: {manifest}") - specs = [] - for key in ("pip_dependencies", "python_dependencies"): - values = data.get(key, []) - if not isinstance(values, list) or any(not isinstance(v, str) for v in values): - raise InstallError("venv", f"invalid {key}: {manifest}") - specs.extend(values) - return list(dict.fromkeys(specs)) + declaration = read_python_declaration(path) + reason = manifest_version_error(declaration.manifest, path.name) + if reason: + raise InstallError("venv", reason) + if declaration.is_member: + members.append(path) + return members def _workspace_member(plugin_dir: Path, root: Path, *, identity: Path | None = None) -> Path: @@ -290,8 +255,9 @@ def _workspace_member(plugin_dir: Path, root: Path, *, identity: Path | None = N import tomllib key = hashlib.sha256(str((identity or plugin_dir).resolve()).encode()).hexdigest()[:16] - pyproject = plugin_dir / "pyproject.toml" - if pyproject.is_file() and "GENERATED by pm" not in pyproject.read_text(encoding="utf-8-sig"): + declaration = read_python_declaration(plugin_dir) + pyproject = declaration.pyproject + if pyproject is not None: member = root / "plugin-sources" / key if member.exists(): shutil.rmtree(member) @@ -316,7 +282,7 @@ def _workspace_member(plugin_dir: Path, root: Path, *, identity: Path | None = N (member / "pyproject.toml").write_text(tomli_w.dumps(document), encoding="utf-8") return member - specs = _legacy_requirements(plugin_dir) + specs = declaration.requirements member = root / "plugin-deps" / key member.mkdir(parents=True, exist_ok=True) (member / "pyproject.toml").write_text( diff --git a/scripts/audit-old-updater-imports.py b/scripts/audit-old-updater-imports.py index 0b9d0afba7..24b3f337a7 100644 --- a/scripts/audit-old-updater-imports.py +++ b/scripts/audit-old-updater-imports.py @@ -53,12 +53,19 @@ are recorded. Unresolved expressions remain visible for manual review: HISTORY COVERAGE AND STATIC LIMITS --------------------------------- -History discovery inventories every reachable origin/main commit, including +The old-updater contract stops BEFORE the PM migration. History discovery +inventories every commit reachable from an explicit pre-PM cutoff, including merge parents, and scans every distinct Python blob for entrypoint ASTs. All historical versions of discovered paths, updater siblings, renamed seed helpers, and statically referenced imported functions are audited. Witness commits identify versions; unchanged descendants are not re-parsed. +The checked-in freeze retains an existing history-plus-tree superset. That +does not require continually adding new PM updater imports: regeneration +audits history only and requires --ref so a later origin/main cannot silently +advance the contract. Use the existing freeze's stats.history.history_ref +as the cutoff, not the current branch or working tree. + This is NOT a complete Python call-graph proof. Reflection, computed module names, arbitrary alias reassignment, class/instance dispatch, and module objects passed through opaque callbacks require manual review. Known helper @@ -70,10 +77,11 @@ of committed merge markers audits every arm combination and records recovery; this is conservative archaeology, not a claim that broken source could run. Usage: - python scripts/audit-old-updater-imports.py # report - python scripts/audit-old-updater-imports.py --json - python scripts/audit-old-updater-imports.py --check # CI gate - python scripts/audit-old-updater-imports.py --explain hermes_constants + python scripts/audit-old-updater-imports.py --ref PRE_PM_COMMIT # report + python scripts/audit-old-updater-imports.py --ref PRE_PM_COMMIT --json + python scripts/audit-old-updater-imports.py --ref PRE_PM_COMMIT --check + python scripts/audit-old-updater-imports.py --ref PRE_PM_COMMIT --freeze PATH +Shallow CI resolves the checked-in freeze via test_old_updater_compat_surface.py. """ from __future__ import annotations @@ -112,10 +120,8 @@ POST_SWAP_HELPER_MODULES = ( "hermes_cli/backup_restore.py", "hermes_cli/managed_uv.py", "hermes_cli/psutil_android.py", - # pm era: `hermes update` drives the store through the pm package. A pm - # module imported BEFORE the swap keeps running as old code afterwards, - # so its lazy loads resolve against the NEW tree -- same failure shape - # as managed_uv. Files absent at a given revision are simply skipped. + # Diagnostic tree audits also inspect the PM updater. These seeds do + # not extend the historical cutoff: files absent there are skipped. "pm/__init__.py", "pm/cli.py", "pm/ensure.py", @@ -711,14 +717,14 @@ def _git(*args: str, input: bytes | None = None) -> bytes: raise AuditError(exc.stderr.decode("utf-8", "replace").strip()) from exc -def _full_history_ref() -> str: +def _full_history_ref(ref: str = "origin/main") -> str: if _git("rev-parse", "--is-shallow-repository").strip() != b"false": raise AuditError( "Cannot audit shallow history. Run git fetch --unshallow origin " - "and fetch origin/main before regenerating; shallow CI must use " + "and fetch the cutoff commit before regenerating; shallow CI must use " "the checked-in frozen JSON." ) - return _git("rev-parse", "--verify", "origin/main^{commit}").decode().strip() + return _git("rev-parse", "--verify", f"{ref}^{{commit}}").decode().strip() def shipped_commits() -> list[str]: @@ -982,8 +988,9 @@ def _audit_versions(index: HistoryIndex, entrypaths: set[str], read_sources, *, return surface -def audit_history() -> Surface: - ref = _full_history_ref() # pin once; a concurrent fetch cannot mix DAGs +def audit_history(ref: str = "origin/main") -> Surface: + """Audit the full DAG at ref; freezes must pass the pre-PM cutoff.""" + ref = _full_history_ref(ref) # pin once; a concurrent fetch cannot mix DAGs index = _history_index(ref) print(f"[audit] indexed {len(index.versions)} historical Python paths", file=sys.stderr, flush=True) surface = _audit_versions( @@ -991,7 +998,16 @@ def audit_history() -> Surface: progress=True, ) if not surface.stats["entrypoint_paths"]: - raise AuditError("No updater entrypoint found in origin/main history") + raise AuditError(f"No updater entrypoint found in history at {ref}") + witnesses = {commit for commits in surface.required.values() for commit in commits} + for module, symbol, witness, site in REVIEWED_DYNAMIC_LOADS: + if witness not in witnesses: + continue + key = (module, symbol) + surface.required.setdefault(key, set()).add(witness) + surface.kinds.setdefault(key, set()).add("reviewed-module-call") + surface.sites.setdefault(key, set()).add(site) + surface.guarded_only.discard(key) surface.stats.update({ "mode": "history", "discovery": index.discovery_stats, @@ -1005,7 +1021,7 @@ def audit_history() -> Surface: def audit_tree() -> Surface: - """Audit the current tree without reading any Git history (shallow CI safe).""" + """Diagnostic only: current-tree loads do not extend the historical contract.""" names = _git("ls-files", "--cached", "--others", "--exclude-standard", "-z", "--", "*.py") paths = {p.decode() for p in names.split(b"\0") if p} index = HistoryIndex() @@ -1027,47 +1043,6 @@ def audit_tree() -> Surface: return surface -def audit_union() -> Surface: - """The frozen contract: every name any SHIPPED updater can lazy-load - after a checkout swap (full history walk over origin/main, following - the updater through every path it has lived at), UNION everything the - CURRENT updater loads (so a future rename turns the test red before it - bricks a live update). - - A pair is guarded_only only when EVERY load site — historical or - current — sits under a swallowing ``try``. - """ - history = audit_history() - tree = audit_tree() - - surface = Surface() - for part in (history, tree): - for key, commits in part.required.items(): - surface.required.setdefault(key, set()).update(commits) - for key, kinds in part.kinds.items(): - surface.kinds.setdefault(key, set()).update(kinds) - for key, sites in part.sites.items(): - surface.sites.setdefault(key, set()).update(sites) - surface.unresolved.update(part.unresolved) - surface.parse_recoveries.update(part.parse_recoveries) - - bare: set[tuple[str, str]] = set() - for part in (history, tree): - bare |= set(part.required) - part.guarded_only - surface.guarded_only = set(surface.required) - bare - witnesses = {commit for commits in history.required.values() for commit in commits} - for module, symbol, witness, site in REVIEWED_DYNAMIC_LOADS: - if witness not in witnesses: - continue - key = (module, symbol) - surface.required.setdefault(key, set()).add(witness) - surface.kinds.setdefault(key, set()).add("reviewed-module-call") - surface.sites.setdefault(key, set()).add(site) - surface.guarded_only.discard(key) - surface.stats = {"mode": "union", "history": history.stats, "tree": tree.stats} - return surface - - # ─── resolution against the working tree ──────────────────────────────── @@ -1140,33 +1115,38 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument( "--history", action="store_true", - help="Audit only the complete origin/main history. By default the " - "audit unions that history with the current working tree. Both " - "modes require a full clone; CI reads the checked-in frozen JSON.", + help="Audit only the complete history at --ref (already the default). " + "Requires a full clone; CI reads the checked-in frozen JSON.", + ) + parser.add_argument( + "--ref", + required=True, + metavar="PRE_PM_COMMIT", + help="Explicit shipped-history cutoff before the PM migration. Use the " + "recorded history_ref from the frozen JSON, not a moving origin/main.", ) parser.add_argument( "--freeze", metavar="PATH", help="Write the surface as JSON (the file the enforcing test reads). " - "The default audits the union of the current tree AND the full " - "shipped-history walk over origin/main (needs a full clone); " - "--history freezes the history half alone.", + "Audits the complete history at --ref, never the current working tree.", ) ns = parser.parse_args(argv) try: - surface = audit_history() if ns.history else audit_union() + surface = audit_history(ns.ref) except AuditError as exc: parser.error(str(exc)) if ns.freeze: payload = { "_comment": ( - "Generated by scripts/audit-old-updater-imports.py --freeze. " + "Generated by scripts/audit-old-updater-imports.py --ref PRE_PM_COMMIT --freeze. " "Names an already-running `hermes update` loads from the NEW " "tree after the checkout swap. Deleting a bare name bricks " "every release that loads it, mid-update, on a half-new " - "tree. Regenerate after changing the update flow; never " + "tree. The pre-PM cutoff is recorded in stats.history_ref; " + "new updater imports do not expand this contract. Never " "hand-trim. History enumeration is complete; static call-graph " "limits and unresolved_dynamic still require manual review." ), @@ -1249,26 +1229,12 @@ def main(argv: list[str] | None = None) -> int: return 1 if (missing and ns.check) else 0 st = surface.stats - if st.get("mode") == "union": - hist, tree = st["history"], st["tree"] - print( - f"Union of {hist['commits']} reachable shipped commits in the update " - f"flow ({hist['revisions_read']} file revisions, " - f"{hist['distinct_file_versions']} distinct versions) and the " - f"current tree ({len(tree['files_analyzed'])} files)." - ) - elif st.get("mode") == "tree": - print( - f"Audited the update flow in this working tree: " - f"{len(st['files_analyzed'])} files " - f"({', '.join(st['files_analyzed'])})." - ) - else: - print( - f"Walked every reachable shipped commit, auditing distinct update versions: " - f"{st['commits']} commits, {st['revisions_read']} file revisions, " - f"{st['distinct_file_versions']} distinct versions." - ) + print( + f"Walked every shipped commit reachable from {st['history_ref']}, " + f"auditing distinct update versions: {st['commits']} commits, " + f"{st['revisions_read']} file revisions, " + f"{st['distinct_file_versions']} distinct versions." + ) print() by_kind: dict[str, int] = {} for kinds in surface.kinds.values(): diff --git a/scripts/build/node-deps.mjs b/scripts/build/node-deps.mjs index 10a5ced933..2829f34020 100644 --- a/scripts/build/node-deps.mjs +++ b/scripts/build/node-deps.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node -import { execFileSync } from 'node:child_process' +import { execFileSync, spawnSync } from 'node:child_process' import { createHash } from 'node:crypto' -import { existsSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs' import { createRequire } from 'node:module' import { delimiter, dirname, join, resolve } from 'node:path' import { pathToFileURL } from 'node:url' @@ -16,10 +16,14 @@ export function npmCommand({ env = process.env } = {}) { const bin = join(dir, name) if (!existsSync(bin)) continue const prefix = dirname(realpathSync(bin)) + const lib = join(prefix, '../lib') candidates.push( join(prefix, 'node_modules/npm/bin/npm-cli.js'), join(prefix, '../lib/node_modules/npm/bin/npm-cli.js'), - join(prefix, '../lib/npm/bin/npm-cli.js'), + // Nix packages use lib/npm or a versioned lib/npm* directory. + // Discover the installed JS entrypoint, never parse/execute its shell wrapper. + ...(existsSync(lib) ? readdirSync(lib).filter(name => name.startsWith('npm')).sort() + .map(name => join(lib, name, 'bin/npm-cli.js')) : []), realpathSync(bin), ) } @@ -95,6 +99,13 @@ export function prepareNodeDependencies({ source, workspaces, env = process.env, } if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + // The Python bundle driver uses this same resolver, not a second layout probe. + if (process.argv[2] === '--npm') { + const [node, ...command] = npmCommand() + const child = spawnSync(node, [...command, ...process.argv.slice(3)], { stdio: 'inherit' }) + if (child.error) throw child.error + process.exit(child.status ?? 1) + } const { values } = parseArgs({ options: { source: { type: 'string' }, workspace: { type: 'string', multiple: true }, reuse: { type: 'boolean', default: false }, diff --git a/scripts/bundles/desktop.py b/scripts/bundles/desktop.py index cca12232f9..4434e9a067 100644 --- a/scripts/bundles/desktop.py +++ b/scripts/bundles/desktop.py @@ -35,19 +35,8 @@ def release_version(repo: Path, tag: str) -> str: def npm_command(node: str) -> list[str]: - # npm.cmd needs cmd.exe; Node's CLI accepts argv directly, including spaces. - npm = shutil.which("npm") - if not npm: - raise FileNotFoundError("npm is required") - prefix = Path(npm).resolve().parent - candidates = [prefix / "node_modules/npm/bin/npm-cli.js", prefix.parent / "lib/node_modules/npm/bin/npm-cli.js"] - for candidate in candidates: - if candidate.is_file(): - return [node, str(candidate)] - # POSIX npm is normally a symlink to its CLI file. - if os.name != "nt": - return [node, str(Path(npm).resolve())] - raise FileNotFoundError(f"npm CLI missing beside {npm}") + # Dependency preparation and packaging must resolve the same npm identity. + return [node, str(ROOT / "scripts/build/node-deps.mjs"), "--npm"] def build(repo: Path, tag: str | None, variant: str, builder_args: list[str], diff --git a/scripts/bundles/native.py b/scripts/bundles/native.py index 7895348f8d..ee5bddd594 100644 --- a/scripts/bundles/native.py +++ b/scripts/bundles/native.py @@ -113,6 +113,16 @@ def stage_native(args) -> int: return subprocess.run(command, cwd=root, env=env).returncode +def prune_staged_store(store_dir: Path, names: list[str]) -> None: + """Prune this build's store, never the ambient user's tool store.""" + facts = Facts(store_dir / "facts.json", strict=True) + facts.retain({package.name for package in walk(names)}) + keep = facts.entries_in_use() + for entry in store_dir.iterdir(): + if entry.is_dir() and not entry.name.startswith(".") and entry.name not in keep: + shutil.rmtree(entry) + + def _stage_native(args) -> int: """Stage a complete payload for THIS machine's target into --out: repo snapshot + store + facts (via the normal install path, redirected) @@ -156,12 +166,7 @@ def _stage_native(args) -> int: # Only this build's store is ours to prune; machine-wide partials are not. # Cached facts may still name packages removed from the current selection. # Retain the dependency closure before using facts as the deletion roots. - facts = Facts(store_dir / "facts.json", strict=True) - facts.retain({package.name for package in walk(names)}) - keep = facts.entries_in_use() - for entry in store_dir.iterdir(): - if entry.is_dir() and not entry.name.startswith(".") and entry.name not in keep: - shutil.rmtree(entry) + prune_staged_store(store_dir, names) python_fact = _facts().get("python") diff --git a/scripts/ci/test_install_ps1_cli_launchers.ps1 b/scripts/ci/test_install_ps1_cli_launchers.ps1 deleted file mode 100644 index 9bf222e332..0000000000 --- a/scripts/ci/test_install_ps1_cli_launchers.ps1 +++ /dev/null @@ -1,188 +0,0 @@ -# Behavioral test for install.ps1's hermes launcher staging. -# -# Run: powershell.exe -NoProfile -File scripts/ci/test_install_ps1_cli_launchers.ps1 -# -# The test lifts the real Stage-Path function from the PowerShell AST and -# executes it against a temporary install tree. It never reads or changes -# the user's PATH (the registry I/O is stubbed to a capture variable). -# -# Under pm (no-boot-through-venv) Stage-Path must stage launchers that boot -# the pm STORE python with PYTHONPATH=repo;venv-site-packages — never the -# venv interpreter (pyvenv.cfg is inert dead config). The real minting -# machinery (hermes_cli/_launchers.py) runs via the venv python, exactly as -# the installer does. - -Set-StrictMode -Version Latest -$ErrorActionPreference = 'Stop' - -$repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..\..') -$installPs1 = Join-Path $repoRoot 'scripts\install.ps1' -$ast = [System.Management.Automation.Language.Parser]::ParseFile( - $installPs1, [ref]$null, [ref]$null) - -$fn = $ast.Find({ - param($n) - $n -is [System.Management.Automation.Language.FunctionDefinitionAst] -and - $n.Name -eq 'Stage-Path' -}, $true) - -if (-not $fn) { - throw "Stage-Path not found in $installPs1" -} - -# Stub the installer's process-level helpers before expanding the function: -# Fail must throw (not exit the test process) and the HKCU Path write must -# land in a capture variable, never in the user's registry. -$script:userPathWrite = $null -$script:fakeUserPath = '' -$body = $fn.Extent.Text -$body = $body.Replace( - '[Environment]::GetEnvironmentVariable("Path", "User")', - '$script:fakeUserPath') -$body = $body.Replace( - '[Environment]::SetEnvironmentVariable("Path", "$binDir;$userPath", "User")', - '$script:userPathWrite = "$binDir;$userPath"') -Invoke-Expression $body - -function Fail([string]$msg) { throw "stage failed: $msg" } -function Log([string]$msg) { Write-Host "[hermes] $msg" } - -$tempBase = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath()) -$caseRoot = [System.IO.Path]::GetFullPath((Join-Path $tempBase ( - 'hermes-cli-launcher-test-' + [guid]::NewGuid().ToString('N') -))) -if (-not $caseRoot.StartsWith($tempBase, [System.StringComparison]::OrdinalIgnoreCase)) { - throw "Refusing to create test directory outside the system temp directory: $caseRoot" -} - -$script:Failures = 0 - -function Assert-True { - param([bool]$Condition, [string]$Name) - if ($Condition) { - Write-Host " PASS $Name" - } else { - Write-Host " FAIL $Name" - $script:Failures++ - } -} - -function Get-LauncherContent { - param([string]$Path) - $bytes = [System.IO.File]::ReadAllBytes($Path) - return [System.Text.Encoding]::ASCII.GetString($bytes) -} - -# A real python for the launcher-minting step (the installer runs it via -# the venv python). Prefer the repo checkout's venv interpreter — a -# standalone CPython; a bare `python` on PATH may be a store/managed -# python that cannot run when copied outside its own layout. -$python = Join-Path $repoRoot '.venv\Scripts\python.exe' -if (-not (Test-Path -LiteralPath $python)) { - $python = (Get-Command python.exe -ErrorAction SilentlyContinue).Source -} -if (-not $python) { $python = (Get-Command python -ErrorAction SilentlyContinue).Source } -if (-not $python) { - Write-Host "SKIP: no python on PATH — launcher minting cannot be exercised" - exit 0 -} - -try { - $installRoot = Join-Path $caseRoot 'hermes-agent' - # Stage-Path derives its destination from $HermesHome\bin — point - # $HermesHome at the case root so the asserts below read the same dir. - $HermesHome = $caseRoot - $binDir = Join-Path $caseRoot 'bin' - $InstallDir = $installRoot - - # Case 1: no venv yet — the stage must fail loudly, staging nothing. - $missingThrew = $false - try { - Stage-Path | Out-Null - } catch { - $missingThrew = $_.Exception.Message -like '*venv python missing*' - } - Assert-True $missingThrew 'missing venv python fails the launcher stage' - - # Build a fake install: venv + the REAL launchers module (copied from - # the repo, importable from the install root cwd like the installer's - # Push-Location does) + a fake pm store with a materialized python. - $venvScripts = Join-Path $installRoot 'venv\Scripts' - New-Item -ItemType Directory -Force -Path $venvScripts | Out-Null - Copy-Item $python (Join-Path $venvScripts 'python.exe') - New-Item -ItemType Directory -Force -Path (Join-Path $installRoot 'hermes_cli') | Out-Null - Copy-Item (Join-Path $repoRoot 'hermes_cli\_launchers.py') (Join-Path $installRoot 'hermes_cli\_launchers.py') - Copy-Item (Join-Path $repoRoot 'hermes_constants.py') (Join-Path $installRoot 'hermes_constants.py') - - $store = Join-Path $caseRoot 'store' - $entryName = 'python-3.14.7+x20260901-win32-arm64' - New-Item -ItemType Directory -Force -Path (Join-Path $store $entryName) | Out-Null - Copy-Item $python (Join-Path $store "$entryName\python.exe") - ('{"schema": 1, "packages": {"python": {"entry": "' + $entryName + '"}}}') | - Set-Content -Path (Join-Path $store 'facts.json') -Encoding Ascii - $env:HERMES_RUNTIME_DIR = $store - - Stage-Path | Out-Null - - foreach ($name in @('hermes', 'hermes-acp')) { - $exe = Join-Path $binDir "$name.exe" - $cmd = Join-Path $binDir "$name.cmd" - if (Test-Path -LiteralPath $exe) { - $content = Get-LauncherContent $exe - } elseif (Test-Path -LiteralPath $cmd) { - $content = Get-LauncherContent $cmd - } else { - $content = '' - } - Assert-True ($content -ne '') "$name launcher staged" - $venvPython = (Join-Path $venvScripts 'python.exe') - Assert-True (-not $content.Contains($venvPython)) ` - "$name launcher does not reference the venv python" - Assert-True $content.Contains((Join-Path $store $entryName)) ` - "$name launcher binds to the pm store interpreter" - } - - Assert-True ($null -ne $script:userPathWrite) 'user PATH write captured the bin dir' - Assert-True ($script:userPathWrite -eq "$binDir;") 'user PATH capture has the right shape' - - # The minted launcher must actually boot the STORE interpreter: give - # the fake repo a hermes_cli.main that prints what it runs under. - Set-Content -Path (Join-Path $installRoot 'hermes_cli\__init__.py') ` - -Encoding Ascii -Value '' - Set-Content -Path (Join-Path $installRoot 'hermes_cli\main.py') ` - -Encoding Ascii -Value "import sys`ndef main():`n print('PROBE', sys.executable)`n return 0`n" - $out = (& (Join-Path $binDir 'hermes.exe') 2>&1) -join ' ' - Write-Host " BOOT-OUT: $out" - # The exe must boot a python that imports hermes_cli through the - # composed PYTHONPATH (repo first). (This fixture's fake store python - # is a copied venv interpreter whose sys.executable reports its base — - # a real pm store python (python-build-standalone) reports itself; - # binding to the store interpreter is asserted above from the exe - # contents.) - Assert-True ($out.Contains('PROBE')) ` - 'minted hermes.exe boots a python that imports via the composed PYTHONPATH' -} finally { - Remove-Item Env:HERMES_RUNTIME_DIR -ErrorAction SilentlyContinue - if (Test-Path -LiteralPath $caseRoot) { - $resolvedCase = [System.IO.Path]::GetFullPath($caseRoot) - if (-not $resolvedCase.StartsWith($tempBase, [System.StringComparison]::OrdinalIgnoreCase)) { - throw "Refusing to remove test directory outside the system temp directory: $resolvedCase" - } - # The booted launcher's child may outlive the assertion briefly. - Start-Sleep -Milliseconds 800 - try { - Remove-Item -LiteralPath $resolvedCase -Recurse -Force -ErrorAction Stop - } catch { - Write-Host " (leftover temp dir could not be removed: $resolvedCase)" - } - } -} - -if ($script:Failures -gt 0) { - Write-Host "" - Write-Host "$script:Failures assertion(s) failed" - exit 1 -} - -Write-Host "" -Write-Host "all assertions passed" diff --git a/scripts/ci/test_install_ps1_path_migration.ps1 b/scripts/ci/test_install_ps1_path_migration.ps1 deleted file mode 100644 index 232d49fb24..0000000000 --- a/scripts/ci/test_install_ps1_path_migration.ps1 +++ /dev/null @@ -1,125 +0,0 @@ -# Behavioral test for install.ps1's persisted-User-PATH migration. -# -# Run: pwsh -NoProfile -File scripts/ci/test_install_ps1_path_migration.ps1 -# -# Not wired into the default CI lane — the Linux runners have no PowerShell -# host. It runs on any machine with pwsh (including via nixpkgs#powershell), -# and on a Windows runner if one is ever added. -# -# This is NOT a source-regex test. It parses install.ps1, lifts the real -# Set-ManagedNodeFirstOnUserPath body out of the AST, and rewrites *only* the -# two registry calls into an in-memory store so the actual shipped logic — -# split, dedupe, prepend, change-detection — executes for real. Rewriting from -# the AST rather than hand-copying the body means the test cannot silently -# drift away from the function it claims to cover. - -Set-StrictMode -Version Latest -$ErrorActionPreference = 'Stop' - -$installPs1 = Join-Path $PSScriptRoot '..' 'install.ps1' | Resolve-Path -$ast = [System.Management.Automation.Language.Parser]::ParseFile( - $installPs1, [ref]$null, [ref]$null) - -$fn = $ast.Find({ - param($n) - $n -is [System.Management.Automation.Language.FunctionDefinitionAst] -and - $n.Name -eq 'Set-ManagedNodeFirstOnUserPath' -}, $true) - -if (-not $fn) { - throw "Set-ManagedNodeFirstOnUserPath not found in $installPs1" -} - -# Swap the two registry calls for the in-memory store. Both must match, or the -# function has changed shape and this harness is no longer exercising it. -# Rewrite the whole definition extent (which already carries `function -# { param(...) ... }`) so the shipped param block and body run verbatim. -$definition = $fn.Extent.Text -$reads = ([regex]'\[Environment\]::GetEnvironmentVariable\("Path", "User"\)').Matches($definition).Count -$writes = ([regex]'\[Environment\]::SetEnvironmentVariable\("Path", ([^,]+), "User"\)').Matches($definition).Count -if ($reads -ne 1 -or $writes -ne 1) { - throw "expected exactly one User PATH read and one write in the function body; found $reads read(s), $writes write(s). Update this harness." -} - -$definition = $definition -replace ` - '\[Environment\]::GetEnvironmentVariable\("Path", "User"\)', '$script:FakeUserPath' -$definition = $definition -replace ` - '\[Environment\]::SetEnvironmentVariable\("Path", ([^,]+), "User"\)', '$script:FakeUserPath = $1; $script:FakeWrites++' - -Invoke-Expression $definition - -$NODE = 'C:\Users\me\AppData\Local\hermes\node' -$script:Failures = 0 - -function Invoke-Migration { - param([string]$Start, [string]$NodeDir = $NODE) - $script:FakeUserPath = $Start - $script:FakeWrites = 0 - Set-ManagedNodeFirstOnUserPath $NodeDir -} - -function Assert-Equal { - param($Expected, $Actual, [string]$Name) - if ($Expected -ceq $Actual) { - Write-Host " PASS $Name" - } else { - Write-Host " FAIL $Name" - Write-Host " expected: [$Expected]" - Write-Host " actual: [$Actual]" - $script:Failures++ - } -} - -Write-Host "install.ps1 Set-ManagedNodeFirstOnUserPath" - -# The regression this function exists for: an install made by an older -# install.ps1, which *appended*. A system Node leads and the managed dir is -# stranded at the tail, so every new shell resolves the wrong node.exe. An -# add-if-missing check would see the entry present and leave it there forever. -Invoke-Migration "C:\Program Files\nodejs;C:\Users\me\bin;$NODE" -Assert-Equal "$NODE;C:\Program Files\nodejs;C:\Users\me\bin" $script:FakeUserPath ` - 'upgrade from appending installer: managed dir becomes first entry' -Assert-Equal 1 (@($script:FakeUserPath -split ';' | Where-Object { $_ -eq $NODE }).Count) ` - 'upgrade: managed dir is not duplicated' -Assert-Equal "C:\Program Files\nodejs;C:\Users\me\bin" ` - (($script:FakeUserPath -split ';' | Where-Object { $_ -ne $NODE }) -join ';') ` - 'upgrade: unrelated entries keep their relative order' -Assert-Equal 1 $script:FakeWrites 'upgrade: persists exactly once' - -Invoke-Migration "$NODE;C:\Program Files\nodejs" -Assert-Equal "$NODE;C:\Program Files\nodejs" $script:FakeUserPath 'already correct: unchanged' -Assert-Equal 0 $script:FakeWrites 'already correct: no registry write' - -Invoke-Migration "C:\Program Files\nodejs" -Assert-Equal "$NODE;C:\Program Files\nodejs" $script:FakeUserPath 'fresh install: prepended' - -# Empty segments are legal in a real User PATH (a trailing ';' is common) and -# the installer's other PATH code preserves them. Migration must not quietly -# rewrite parts of PATH it was not asked to touch. -Invoke-Migration "C:\Program Files\nodejs;;C:\Users\me\bin;" -Assert-Equal "$NODE;C:\Program Files\nodejs;;C:\Users\me\bin;" $script:FakeUserPath ` - 'empty segments are preserved' - -# Windows paths are case-insensitive, and -ne on strings is too. -Invoke-Migration "C:\Program Files\nodejs;c:\users\me\appdata\local\HERMES\Node" -Assert-Equal "$NODE;C:\Program Files\nodejs" $script:FakeUserPath ` - 'existing entry in different case is replaced, not duplicated' - -Invoke-Migration "$NODE;C:\Program Files\nodejs;$NODE" -Assert-Equal "$NODE;C:\Program Files\nodejs" $script:FakeUserPath 'duplicates collapse' - -Invoke-Migration "" -Assert-Equal $NODE $script:FakeUserPath 'empty User PATH' - -Invoke-Migration "C:\Program Files\nodejs" "" -Assert-Equal "C:\Program Files\nodejs" $script:FakeUserPath 'empty NodeDir is a no-op' -Assert-Equal 0 $script:FakeWrites 'empty NodeDir does not write' - -if ($script:Failures -gt 0) { - Write-Host "" - Write-Host "$script:Failures assertion(s) failed" - exit 1 -} - -Write-Host "" -Write-Host "all assertions passed" diff --git a/scripts/install.sh b/scripts/install.sh index 13ab282126..ab2279e78a 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -337,8 +337,12 @@ bootstrap_python() { path[2] == "packages" && path[3] == "python" && $2 == "version" && depth == 3 { print $4; exit } ' "$INSTALL_DIR/pm/lock.json" | cut -d+ -f1 | cut -d. -f1,2)" [ -n "$_py" ] || _py="3.14" - "$UV_CMD" python install --no-bin "$_py" || fail "bootstrap Python installation failed" - boot_py="$("$UV_CMD" python find --managed-python "$_py")" || fail "bootstrap Python lookup failed" + # Stages are separate processes. Reuse uv's current managed interpreter + # without rewriting its installation on every warm path publication. + if ! boot_py="$("$UV_CMD" python find --managed-python "$_py" 2>/dev/null)"; then + "$UV_CMD" python install --no-bin "$_py" || fail "bootstrap Python installation failed" + boot_py="$("$UV_CMD" python find --managed-python "$_py")" || fail "bootstrap Python lookup failed" + fi boot_py="${boot_py%$'\r'}" } diff --git a/scripts/smoke-payload.sh b/scripts/smoke-payload.sh index e8040c5ced..9726575c55 100644 --- a/scripts/smoke-payload.sh +++ b/scripts/smoke-payload.sh @@ -1,50 +1,53 @@ #!/usr/bin/env bash -# Smoke-test a staged pm payload (`pm bundle --out`): the raw store python -# boots hermes_cli/pm out of the staged repo snapshot (cwd at the staged -# repo, no network, lazy installs off). The desktop's self-relative CLI -# trampoline is minted only by the desktop release workflow, not by -# `pm bundle`, so it is not exercised here. Usage: smoke-payload.sh -set -e +# Exercise the published launch contract after relocation. Network isolation is +# the runner's responsibility (Linux CI uses unshare --net), not a lazy-deps flag. +set -euo pipefail PAYLOAD="${1:?usage: smoke-payload.sh }" -cd "$PAYLOAD" +command -v cygpath >/dev/null 2>&1 && PAYLOAD="$(cygpath -w "$PAYLOAD")" +node --input-type=module - "$PAYLOAD" <<'JS' +import { mkdtempSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync } from 'node:fs' +import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' +import { spawnSync } from 'node:child_process' -PYTHON_ENTRY=$(node -e " - const f = require(process.argv[1] + '/tools/facts.json'); - console.log(f.packages.python.entry); -" "$PWD") -case "$(uname -s)" in - MINGW*|MSYS*|CYGWIN*) PY="$PWD/tools/$PYTHON_ENTRY/python.exe"; SP="$PWD/venv/Lib/site-packages" ;; - *) PY="$PWD/tools/$PYTHON_ENTRY/bin/python3"; SP="$PWD/venv/lib/python3.14/site-packages" ;; -esac - -[ -f "$PY" ] || { echo "FAIL: no store interpreter at $PY"; exit 1; } -[ -d "$SP" ] || { echo "FAIL: no venv site-packages at $SP"; exit 1; } -[ -f manifest.json ] || { echo "FAIL: no manifest.json"; exit 1; } -[ -f tools/facts.json ] || { echo "FAIL: no tools/facts.json"; exit 1; } - -TOOLS="$PWD/tools" -# native python must see a native path, not an MSYS one -command -v cygpath >/dev/null 2>&1 && TOOLS="$(cygpath -w "$TOOLS")" && SP="$(cygpath -w "$SP")" -# pm prints UTF-8 (✓/✗); Windows consoles default to cp1252 -export PYTHONUTF8=1 -cd hermes-agent - -echo "— store python boots + hermes_cli imports out of the payload —" -env -u PYTHONPATH -u PYTHONHOME \ - HERMES_RUNTIME_DIR="$TOOLS" HERMES_DISABLE_LAZY_INSTALLS=1 \ - PYTHONPATH="$SP" \ - "$PY" -c "import hermes_cli.config; import pm; print('imports ok')" - -echo "— hermes --version through the real entrypoint —" -env -u PYTHONPATH -u PYTHONHOME \ - HERMES_RUNTIME_DIR="$TOOLS" HERMES_DISABLE_LAZY_INSTALLS=1 \ - PYTHONPATH="$SP" \ - "$PY" -m hermes_cli.main --version - -echo "— pm sees the staged tools —" -env -u PYTHONPATH -u PYTHONHOME \ - HERMES_RUNTIME_DIR="$TOOLS" HERMES_DISABLE_LAZY_INSTALLS=1 \ - PYTHONPATH="$SP" \ - "$PY" -m pm.cli doctor - -echo "SMOKE OK" +const original = resolve(process.argv[2]) +// Same filesystem: moving a multi-GB payload should not copy it. +const scratch = mkdtempSync(join(dirname(original), '.payload-smoke-')) +const moved = join(scratch, 'relocated payload') +let relocated = false +try { + renameSync(original, moved) + relocated = true + const manifest = JSON.parse(readFileSync(join(moved, 'manifest.json'), 'utf8')) + const command = manifest.runtime.commands.hermes + if (!command) throw new Error('manifest has no hermes command') + const executable = realpathSync(resolve(moved, command)) + const inside = relative(realpathSync(moved), executable) + if (isAbsolute(command) || isAbsolute(inside) || inside === '..' || inside.startsWith(`..${sep}`)) { + throw new Error('manifest command escapes the relocated payload') + } + const home = join(scratch, 'home') + mkdirSync(home) + const env = { HOME: home, USERPROFILE: home, HERMES_HOME: join(home, '.hermes'), + PYTHONUTF8: '1', PYTHONDONTWRITEBYTECODE: '1', HERMES_DISABLE_LAZY_INSTALLS: '1', + UV_OFFLINE: '1', npm_config_offline: 'true' } + // Keep OS process necessities only; no checkout, Python, PM, or Node overrides. + for (const [key, value] of Object.entries(process.env)) { + if (/^(path|systemroot|windir|comspec|pathext|temp|tmp)$/i.test(key)) env[key] = value + } + // tools list crosses the real application/config/registry imports, unlike + // version/help and PM's stdlib bootstrap fast paths. + for (const args of [['--version'], ['--help'], ['tools', 'list'], ['pm', 'doctor']]) { + console.log(`— published hermes ${args.join(' ')} (relocated) —`) + const child = spawnSync(executable, args, { + cwd: home, env, stdio: 'inherit', timeout: 120000, + }) + if (child.error) throw child.error + if (child.status !== 0) throw new Error(`hermes ${args.join(' ')} failed: ${child.status ?? child.signal}`) + } + console.log('SMOKE OK') +} finally { + // Restore even after a command fails, so its artifact remains inspectable. + if (relocated) renameSync(moved, original) + rmSync(scratch, { recursive: true, force: true }) +} +JS \ No newline at end of file diff --git a/scripts/tests/test-install-ps1-stage-protocol.ps1 b/scripts/tests/test-install-ps1-stage-protocol.ps1 deleted file mode 100644 index 8358fad511..0000000000 --- a/scripts/tests/test-install-ps1-stage-protocol.ps1 +++ /dev/null @@ -1,134 +0,0 @@ -# Smoke tests for the install.ps1 stage protocol. -# -# Run from a PowerShell prompt: -# -# powershell -NoProfile -ExecutionPolicy Bypass -File scripts/tests/test-install-ps1-stage-protocol.ps1 -# -# These tests only exercise the metadata surface (-ProtocolVersion, -Manifest, -# unknown -Stage handling). They DO NOT actually run any install stages -- -# those have heavy side effects (winget, git clone, pip install, PATH writes) -# and are out of scope for a unit smoke test. All three metadata commands -# below return without invoking Main / Invoke-AllStages. -# -# To exercise real install stages, drive the script from a clean VM. - -$ErrorActionPreference = "Stop" -$repoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)) -$installScript = Join-Path $repoRoot "scripts\install.ps1" - -if (-not (Test-Path $installScript)) { - throw "Could not locate install.ps1 at $installScript" -} - -$failures = 0 -function Assert-Equal { - param([Parameter(Mandatory=$true)] $Expected, - [Parameter(Mandatory=$true)] $Actual, - [Parameter(Mandatory=$true)] [string]$Label) - if ($Expected -ne $Actual) { - Write-Host "FAIL: $Label" -ForegroundColor Red - Write-Host " expected: $Expected" - Write-Host " actual: $Actual" - $script:failures++ - } else { - Write-Host "OK: $Label" -ForegroundColor Green - } -} -function Assert-True { - param([Parameter(Mandatory=$true)] $Condition, - [Parameter(Mandatory=$true)] [string]$Label) - if (-not $Condition) { - Write-Host "FAIL: $Label" -ForegroundColor Red - $script:failures++ - } else { - Write-Host "OK: $Label" -ForegroundColor Green - } -} - -# ----------------------------------------------------------------------------- -# Test: -ProtocolVersion emits a single integer -# ----------------------------------------------------------------------------- -Write-Host "" -Write-Host "-- -ProtocolVersion --" -$output = & powershell -NoProfile -ExecutionPolicy Bypass -File $installScript -ProtocolVersion -Assert-Equal -Expected 0 -Actual $LASTEXITCODE -Label "-ProtocolVersion exits 0" -Assert-True ($output -match '^\d+$') -Label "-ProtocolVersion emits an integer (got: $output)" - -# ----------------------------------------------------------------------------- -# Test: -Manifest emits valid JSON with expected shape -# ----------------------------------------------------------------------------- -Write-Host "" -Write-Host "-- -Manifest --" -$manifestJson = & powershell -NoProfile -ExecutionPolicy Bypass -File $installScript -Manifest -Assert-Equal -Expected 0 -Actual $LASTEXITCODE -Label "-Manifest exits 0" - -$manifest = $null -try { - $manifest = $manifestJson | ConvertFrom-Json - Assert-True $true -Label "-Manifest output parses as JSON" -} catch { - Assert-True $false -Label "-Manifest output parses as JSON (parse error: $_)" -} - -if ($manifest) { - Assert-True ($manifest.protocol_version -is [int] -or $manifest.protocol_version -is [long]) ` - -Label "manifest.protocol_version is an integer" - Assert-True ($manifest.stages.Count -gt 0) -Label "manifest.stages is non-empty" - - # Every stage has the four required fields - $allValid = $true - foreach ($stage in $manifest.stages) { - foreach ($field in @("name", "title", "category", "needs_user_input")) { - if (-not ($stage.PSObject.Properties.Name -contains $field)) { - Write-Host " stage missing field '$field': $($stage | ConvertTo-Json -Compress)" -ForegroundColor Red - $allValid = $false - } - } - } - Assert-True $allValid -Label "every stage has name/title/category/needs_user_input" - - # Specific stage names that the GUI driver will rely on - $names = $manifest.stages | ForEach-Object { $_.name } - foreach ($expected in @("prerequisites", "repository", "venv", "python-deps", "path", "config", "setup", "gateway", "complete")) { - Assert-True ($names -contains $expected) -Label "manifest contains stage '$expected'" - } - - # The two known-interactive stages must declare needs_user_input - $interactive = $manifest.stages | Where-Object { $_.needs_user_input } | ForEach-Object { $_.name } - Assert-True ($interactive -contains "setup") -Label "'setup' stage flagged needs_user_input" - Assert-True ($interactive -contains "gateway") -Label "'gateway' stage flagged needs_user_input" -} - -# ----------------------------------------------------------------------------- -# Test: unknown stage name -> exit 2, structured JSON error -# ----------------------------------------------------------------------------- -Write-Host "" -Write-Host "-- -Stage with unknown name --" -$errOutput = & powershell -NoProfile -ExecutionPolicy Bypass -File $installScript -Stage "does-not-exist" -Assert-Equal -Expected 2 -Actual $LASTEXITCODE -Label "unknown -Stage exits 2" - -$errFrame = $null -try { - $errFrame = $errOutput | ConvertFrom-Json - Assert-True $true -Label "unknown-stage output parses as JSON" -} catch { - Assert-True $false -Label "unknown-stage output parses as JSON (parse error: $_)" -} - -if ($errFrame) { - Assert-Equal -Expected $false -Actual $errFrame.ok -Label "unknown-stage frame has ok=false" - Assert-Equal -Expected "does-not-exist" -Actual $errFrame.stage -Label "unknown-stage frame echoes stage name" - Assert-True ($errFrame.reason -match "unknown stage") -Label "unknown-stage frame explains why" -} - -# ----------------------------------------------------------------------------- -# Summary -# ----------------------------------------------------------------------------- -Write-Host "" -if ($failures -gt 0) { - Write-Host "FAILED: $failures assertion(s) failed" -ForegroundColor Red - exit 1 -} else { - Write-Host "All smoke tests passed." -ForegroundColor Green - exit 0 -} diff --git a/tests-js/node-deps.test.mjs b/tests-js/node-deps.test.mjs index 8ad15d8132..1d6b96e335 100644 --- a/tests-js/node-deps.test.mjs +++ b/tests-js/node-deps.test.mjs @@ -1,5 +1,5 @@ import { execFileSync } from 'node:child_process' -import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs' +import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync, symlinkSync } from 'node:fs' import { createRequire } from 'node:module' import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' @@ -32,6 +32,25 @@ function fixture() { return root } +test.each(['npm', 'npm-fixture'])('prepared npm discovers lib/%s without npm_execpath', (directory) => { + const [node, installedCli] = npmCommand() + const root = mkdtempSync(join(tmpdir(), 'npm layout with spaces-')) + roots.push(root) + mkdirSync(join(root, 'bin')) + mkdirSync(join(root, 'lib')) + // Real npm code in a version-suffixed package directory. The launcher name + // has no .js suffix, as with native npm.cmd and Nix's shell wrappers. + cpSync(installedCli, join(root, 'bin', process.platform === 'win32' ? 'npm.cmd' : 'npm')) + symlinkSync(dirname(dirname(installedCli)), join(root, 'lib', directory), 'junction') + const env = { ...process.env, PATH: join(root, 'bin') } + delete env.npm_execpath + const [selectedNode, cli] = npmCommand({ env }) + expect(selectedNode).toBe(node) + expect(cli).toBe(join(root, 'lib', directory, 'bin/npm-cli.js')) + const version = execFileSync(selectedNode, [cli, '--version'], { cwd: tmpdir(), env, encoding: 'utf8' }).trim() + expect(version).toMatch(/^\d+\.\d+\.\d+/) +}) + test('read-only dependency preparation reuses complete receipts but refuses missing inputs', async () => { const { prepareNodeDependencies } = await import('../scripts/build/node-deps.mjs') const source = fixture() diff --git a/tests-js/source-update-observer.test.mjs b/tests-js/source-update-observer.test.mjs new file mode 100644 index 0000000000..45d219ebfd --- /dev/null +++ b/tests-js/source-update-observer.test.mjs @@ -0,0 +1,42 @@ +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, test } from 'vitest' +import { observeSourceUpdate } from '../tests/install/e2e-assets/source-update-observer.mjs' + +const roots = [] +afterEach(() => roots.splice(0).forEach(root => rmSync(root, { recursive: true, force: true }))) +function fixture() { + const home = mkdtempSync(join(tmpdir(), 'source-update-observer-')) + roots.push(home) + const receipts = join(home, 'logs/update_receipts') + mkdirSync(receipts, { recursive: true }) + const resultPath = join(home, '.hermes-update-result.json') + const write = (name, data) => writeFileSync(join(receipts, name), JSON.stringify(data)) + return { home, resultPath, write } +} + +test('checkout movement and old receipts are not update completion', () => { + const f = fixture() + f.write('update_old.json', { outcome: 'success', finished_at: 'old', post_update: { sha: 'target' } }) + const observe = observeSourceUpdate({ ...f, expectSha: 'target' }) + expect(observe('target')).toBe(false) + f.write('pm_sync.json', { outcome: 'success', finished_at: 'now', post_update: { sha: 'target' } }) + expect(observe('target')).toBe(false) + f.write('update_new.json', { outcome: 'success', finished_at: 'now', post_update: { sha: 'target' } }) + expect(observe('old')).toBe(false) + expect(observe('target')).toBe(true) +}) + +test('failed handoff fails even at target SHA; success waits for marker removal', () => { + const f = fixture() + const observe = observeSourceUpdate({ ...f, expectSha: 'target' }) + writeFileSync(f.resultPath, JSON.stringify({ ok: false, exit_code: 1 })) + expect(() => observe('target')).toThrow(/failed/) + writeFileSync(f.resultPath, JSON.stringify({ ok: true, exit_code: 0 })) + const marker = join(f.home, '.hermes-update-in-progress') + writeFileSync(marker, 'updater still finishing') + expect(observe('target')).toBe(false) + rmSync(marker) + expect(observe('target')).toBe(true) +}) \ No newline at end of file diff --git a/tests/agent/lsp/test_install_and_lint_fixes.py b/tests/agent/lsp/test_install_and_lint_fixes.py index aba4d631f1..e6f0e3f74e 100644 --- a/tests/agent/lsp/test_install_and_lint_fixes.py +++ b/tests/agent/lsp/test_install_and_lint_fixes.py @@ -2,11 +2,9 @@ Covers: -1. ``typescript-language-server`` install recipe pulls in ``typescript`` - alongside the server, so the npm install command targets both. -2. ``hermes lsp status`` surfaces a ``Backend warnings`` section when +1. ``hermes lsp status`` surfaces a ``Backend warnings`` section when bash-language-server is installed but ``shellcheck`` is missing. -3. ``_check_lint`` returns ``skipped`` (not ``error``) when the linter +2. ``_check_lint`` returns ``skipped`` (not ``error``) when the linter command exists on PATH but couldn't actually run — e.g. ``npx tsc`` without the typescript SDK installed. This is what unblocks the LSP semantic tier on TypeScript files when the user doesn't also @@ -20,53 +18,6 @@ from unittest.mock import MagicMock, patch import pytest -from agent.lsp.install import INSTALL_RECIPES - - -# --------------------------------------------------------------------------- -# Fix 1: typescript install recipe carries the typescript SDK -# --------------------------------------------------------------------------- - - - - - - -def test_install_npm_works_without_extras(tmp_path, monkeypatch): - """Backwards compat: pyright-style recipes (no extras) still install.""" - monkeypatch.setenv("HERMES_HOME", str(tmp_path)) - - captured = {} - - def fake_run(cmd, **kwargs): - captured["cmd"] = cmd - return MagicMock(returncode=0, stderr="") - - from agent.lsp import install as install_mod - - monkeypatch.setattr(install_mod.subprocess, "run", fake_run) - # _install_npm resolves npm via hermes_constants.find_node_executable - # (managed Node first, PATH second) — not install_mod.shutil.which, so - # mock the actual seam or the early "no usable npm" return skips the - # subprocess entirely. - monkeypatch.setattr( - install_mod, "find_node_executable", - lambda c: "/usr/bin/npm" if c == "npm" else None, - ) - - install_mod._install_npm("pyright", "pyright-langserver") - - cmd = captured["cmd"] - assert "pyright" in cmd - # Should not blow up when extra_pkgs is omitted/None - install_targets = [c for c in cmd if not c.startswith("-") and c not in { - "install", "--prefix", str(install_mod.hermes_lsp_bin_dir().parent), - "/usr/bin/npm", - }] - assert install_targets == ["pyright"] - - - def test_install_python_server_uses_pm_tool_environment(tmp_path, monkeypatch): import pm diff --git a/tests/compat/README.md b/tests/compat/README.md index 3a2bdea624..2f2e1caead 100644 --- a/tests/compat/README.md +++ b/tests/compat/README.md @@ -4,19 +4,27 @@ An updater can retain old Python modules after replacing its checkout. Its next lazy import reads the new files. Current-tree tests alone cannot protect that boundary. -`old_updater_surface.json` records historical imports union current imports. -Regenerate it from a full clone: +The compatibility contract covers only updaters shipped before the PM migration, +not imports introduced on the unshipped migration branch or in the working tree. +`old_updater_surface.json` retains an existing history-plus-tree superset; keep +every frozen name, without continually adding new PM updater imports. + +Regeneration audits history only and requires a full clone and an explicit +pre-PM cutoff. Use the existing JSON's `stats.history.history_ref` (or +`stats.history_ref` in history-only output), not a later `origin/main`: ```sh -python3 scripts/audit-old-updater-imports.py --freeze tests/compat/old_updater_surface.json +python3 scripts/audit-old-updater-imports.py --ref PRE_PM_COMMIT --freeze tests/compat/old_updater_surface.json ``` -The walker pins `origin/main` once. The JSON records that exact commit, the +The walker resolves `--ref` once. The JSON records that exact commit, the history roots, discovered entrypoints, selected paths, and parse recoveries. It inventories all reachable commits, including merge parents. It follows historical filenames, deleted helpers, extraction imports, and rename edges. -A shallow clone cannot regenerate the file. Shallow CI checks the frozen names -and that the current imports remain a subset of the freeze. +A shallow clone cannot regenerate the file. Shallow CI resolves every frozen +bare name against the current tree and checks historical completeness; it does +not require fresh-tree imports to be a subset of the freeze. An update-flow +change alone is not grounds for regeneration or for advancing the cutoff. The surface deliberately over-approximates reachability. Matching update entrypoints and same-named functions can include unrelated callers. Do not trim @@ -26,11 +34,20 @@ classes. It does not execute module bodies or prove every conditional export. ## Runtime behavior -Retired dependency entrypoints stop with a relaunch message. Returning `None` -can activate old pip fallback code. Other shims retain conservative return -shapes without installs, downloads, marker writes, or PM delegation. Each shim -explains why it exists. Current application code must use the live implementation, -not these historical exports. +Retired dependency entrypoints hand off to the fresh absolute-path updater +child, wait, then exit with its status. Returning `None` can activate old pip +fallback code. The old parent must not import PM, reload live modules, install, +or download. Other shims retain conservative return shapes. Current application +code must use the live implementation, not these historical exports. + +`old_updater_support.py` supplies the broad export tests' shared child-process +seam: only the isolated takeover command is accepted, and the real temporary +request/result bridge still runs. Each test resets the parent result cache so +one export cannot pass just because another already handed off. The tests retain +historical arguments and return shapes, and forbid old-parent fallbacks. +`tests/hermes_cli/test_old_updater_takeover.py` separately executes a real child +to prove waiting, status propagation and no reentry; these export probes do not +replace that integration coverage. A fresh source launch checks PM's existing successful dependency facts. It synchronizes stale state and switches to the managed interpreter before @@ -54,10 +71,12 @@ The reviewed categories are: not a statically proven contract. - `managed_uv` and `_subprocess_compat`: the historical Windows installer calls `_subprocess_compat.run`. `REVIEWED_DYNAMIC_LOADS` retains that named call with - its witness commit. Its shim stops before invoking an installer. -- PM operations and build operations: fixed dispatch modules, seeded explicitly - with the other post-swap helpers. PM registry import names can also come from - package definitions outside the checkout. + its witness commit, including in history-only output at that cutoff. + Its shim stops before invoking an installer. +- PM operations and build operations: fixed dispatch modules recorded by the + retained union's tree half. New PM imports do not extend the historical + contract. Diagnostic tree audits still inspect these modules; PM registry + import names can also come from package definitions outside the checkout. - Plugin configuration hooks and deferred tool registration: import targets depend on enabled plugins and runtime manifests. A core-tree freeze cannot enumerate names supplied by independently installed plugins. diff --git a/tests/compat/old_updater_support.py b/tests/compat/old_updater_support.py new file mode 100644 index 0000000000..a58b8e4330 --- /dev/null +++ b/tests/compat/old_updater_support.py @@ -0,0 +1,123 @@ +"""Shared guards for historical exports; real child execution lives in test_old_updater_takeover.""" + +import builtins +from contextlib import contextmanager +import importlib +import importlib.util +import json +import os +from pathlib import Path +import socket +import subprocess +import sys +import urllib.request + +import pytest + + +ROOT = Path(__file__).resolve().parents[2] + + +@pytest.fixture +def no_external_work(monkeypatch): + """Reject old-parent installs, process control, downloads and module reloads.""" + import pm + + def forbidden(*args, **kwargs): + pytest.fail(f"old updater attempted work outside the fresh child: {args!r}") + + for name in ("ensure", "sync_venv", "ensure_environment", "build_environment", + "ensure_python_tool", "ensure_import"): + monkeypatch.setattr(pm, name, forbidden) + for name in ("Popen", "run"): + monkeypatch.setattr(subprocess, name, forbidden) + for name in ("system", "kill"): + monkeypatch.setattr(os, name, forbidden) + monkeypatch.setattr(importlib, "reload", forbidden) + monkeypatch.setattr(socket, "create_connection", forbidden) + monkeypatch.setattr(urllib.request, "urlopen", forbidden) + monkeypatch.setattr(urllib.request, "urlretrieve", forbidden) + return forbidden + + +class FreshChild: + def __init__(self, monkeypatch): + self.monkeypatch = monkeypatch + # Nonzero by default: an unconditional SystemExit(0) is not a handoff. + self.returncode = 19 + self.result = {} + self.requests = [] + self.paths = [] + + def run(self, command, *, cwd, env): + """Intercept ONLY the fresh-child seam, leaving the real JSON bridge intact.""" + assert command[:7] == [ + sys.executable, "-I", "-S", "-B", "-X", "utf8", str(ROOT / "hermes_cli/_update_takeover.py"), + ], f"old installer/fallback ran instead of takeover: {command!r}" + assert len(command) == 9 + assert Path(command[0]).is_absolute() + assert Path(cwd) == ROOT + assert not any(key.startswith(("PYTHON", "UV_")) or key == "VIRTUAL_ENV" for key in env) + context, result = map(Path, command[7:]) + assert context.is_absolute() and result.is_absolute() + assert context.parent == result.parent and context != result + request = json.loads(context.read_text(encoding="utf-8")) + assert request["root"] == str(ROOT) + assert request["home"] == os.environ["HERMES_HOME"] + assert request["argv"] == sys.argv + self.requests.append(request) + self.paths.append((context, result)) + result.write_text(json.dumps(self.result), encoding="utf-8") + return subprocess.CompletedProcess(command, self.returncode) + + @contextmanager + def exits(self): + """Each independent probe must actually hand off, not reuse another test's result.""" + before_calls = len(self.requests) + before_env = dict(os.environ) + before_modules = dict(sys.modules) + home = Path(os.environ["HERMES_HOME"]) + before_files = {p: p.read_bytes() for p in home.rglob("*") if p.is_file()} + real_import = builtins.__import__ + real_import_module = importlib.import_module + + def check_import(name): + if name == "pm" or name.startswith("pm.") or name in { + "hermes_cli._update_takeover", "hermes_cli.update_finish", + }: + pytest.fail(f"fresh updater imported in the old parent: {name}") + + def guarded_import(name, globals=None, locals=None, fromlist=(), level=0): + resolved = importlib.util.resolve_name("." * level + name, globals["__package__"]) if level else name + check_import(resolved) + for attr in fromlist or (): + check_import(f"{resolved}.{attr}") + return real_import(name, globals, locals, fromlist, level) + + def guarded_import_module(name, package=None): + check_import(importlib.util.resolve_name(name, package) if name.startswith(".") else name) + return real_import_module(name, package) + + with self.monkeypatch.context() as guard: + guard.setattr(builtins, "__import__", guarded_import) + guard.setattr(importlib, "import_module", guarded_import_module) + with pytest.raises(SystemExit) as stopped: + yield + assert stopped.value.code == self.returncode + assert len(self.requests) == before_calls + 1 + assert all(not path.exists() for path in self.paths[-1]) + assert dict(os.environ) == before_env + assert all(sys.modules.get(name) is module for name, module in before_modules.items()) + assert {p: p.read_bytes() for p in home.rglob("*") if p.is_file()} == before_files + + +@pytest.fixture +def fresh_child(monkeypatch, no_external_work): + from hermes_cli import _old_updater + + # Production caches across finally/atexit reentry. Test probes model separate + # historical processes, so they must not inherit the previous probe's status. + monkeypatch.setattr(_old_updater, "_result", None) + child = FreshChild(monkeypatch) + monkeypatch.setattr(subprocess, "run", child.run) + return child diff --git a/tests/docker/test_tui_prebuilt_bundle.py b/tests/docker/test_tui_prebuilt_bundle.py index c4050ca0dd..b00107d4ea 100644 --- a/tests/docker/test_tui_prebuilt_bundle.py +++ b/tests/docker/test_tui_prebuilt_bundle.py @@ -30,7 +30,7 @@ def _exec_py(image: str, py: str) -> str: # Drop to the hermes user (UID 10000) so we exercise the same path the # dashboard PTY child runs as — not root. cmd = [ - "docker", "run", "--rm", "--entrypoint", "su", image, + "docker", "run", "--rm", "--network=none", "--entrypoint", "su", image, "hermes", "-s", "/bin/bash", "-c", inner, ] r = subprocess.run(cmd, capture_output=True, text=True, timeout=120) @@ -51,6 +51,24 @@ def test_hermes_tui_dir_env_is_set(built_image: str) -> None: ) +def test_photon_baked_dependencies_load_without_writes_or_network(built_image: str) -> None: + """The non-root runtime uses the baked sidecar, including its npm patch.""" + py = ''' +import subprocess +from plugins.platforms.photon.sidecar_paths import SOURCE_SIDECAR_DIR, resolve_sidecar_dir, dir_writable +sidecar = resolve_sidecar_dir() +assert sidecar == SOURCE_SIDECAR_DIR, sidecar +assert not dir_writable(sidecar / 'node_modules') +child = subprocess.run(['node', '--input-type=module', '-e', + "import {patchSpectrumTs} from './patch-spectrum-mixed-attachments.mjs'; " + "patchSpectrumTs(); await import('spectrum-ts'); console.log('PHOTON_LOADED')"], + cwd=sidecar, capture_output=True, text=True, timeout=30) +assert child.returncode == 0, child.stderr +print(child.stdout.strip()) +''' + assert _exec_py(built_image, py).endswith('PHOTON_LOADED') + + def test_prebuilt_bundle_present_and_no_runtime_install(built_image: str) -> None: """The launcher must (a) find the prebuilt bundle and (b) NOT want an npm install — i.e. it takes the same path as a nix/packaged release.""" diff --git a/tests/gateway/test_pm_activation.py b/tests/gateway/test_pm_activation.py index ed85dc8231..632f02f53b 100644 --- a/tests/gateway/test_pm_activation.py +++ b/tests/gateway/test_pm_activation.py @@ -60,21 +60,17 @@ def test_gateway_main_activates_pm_store(gateway_main, tmp_path, monkeypatch): def adopt(): calls.append("adopt") - @staticmethod - def check(): - calls.append("check") - return [] # healthy - @staticmethod def activate(): calls.append("activate") + return [] # healthy import sys monkeypatch.setitem(sys.modules, "pm", _FakePm) with patch("sys.argv", ["gateway"]): gateway_main() - assert calls == ["adopt", "check", "activate"] + assert calls == ["adopt", "activate"] def test_gateway_main_warns_but_boots_when_store_out_of_sync( @@ -87,13 +83,9 @@ def test_gateway_main_warns_but_boots_when_store_out_of_sync( def adopt(): pass - @staticmethod - def check(): - return ["uv not installed", "ffmpeg outdated"] - @staticmethod def activate(): - raise AssertionError("activate must not run on a broken store") + return ["uv not installed", "ffmpeg outdated"] import sys diff --git a/tests/gateway/test_update_cron_drain.py b/tests/gateway/test_update_cron_drain.py index 3cb711d1ea..3f5fcdeb31 100644 --- a/tests/gateway/test_update_cron_drain.py +++ b/tests/gateway/test_update_cron_drain.py @@ -7,7 +7,7 @@ in ``GatewayRunner._running_agents`` — so a zero-agent drain must still wait for cron to finish (or time out and take the interrupt/kill path). """ import asyncio -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import patch import pytest @@ -19,19 +19,25 @@ async def test_drain_active_agents_waits_for_in_flight_cron_jobs(): runner, _adapter = make_restart_runner() runner._running_agents = {} - cron_count = [1] + observed = asyncio.Event() + released = asyncio.Event() def _cron_in_flight(): - return frozenset(f"job-{i}" for i in range(cron_count[0])) - - async def finish_cron(): - await asyncio.sleep(0.15) - cron_count[0] = 0 + observed.set() + return frozenset() if released.is_set() else frozenset({"job-1"}) with patch("cron.scheduler.get_running_job_ids", side_effect=_cron_in_flight): - task = asyncio.create_task(finish_cron()) - _snapshot, timed_out = await runner._drain_active_agents(1.0) - await task + drain = asyncio.create_task(runner._drain_active_agents(10.0)) + try: + await asyncio.wait_for(observed.wait(), timeout=5.0) + assert not drain.done(), "drain returned while cron still owned its work" + released.set() + _snapshot, timed_out = await asyncio.wait_for(drain, timeout=5.0) + finally: + released.set() + if not drain.done(): + drain.cancel() + await asyncio.gather(drain, return_exceptions=True) assert timed_out is False assert _snapshot == {} diff --git a/tests/gateway/test_update_streaming.py b/tests/gateway/test_update_streaming.py index 81116b3c14..c3ddc7bedf 100644 --- a/tests/gateway/test_update_streaming.py +++ b/tests/gateway/test_update_streaming.py @@ -88,38 +88,6 @@ class TestGatewayPrompt: assert not (hermes_home / ".update_response").exists() -# --------------------------------------------------------------------------- -# _restore_stashed_changes with input_fn -# --------------------------------------------------------------------------- - - -class TestRestoreStashWithInputFn: - """Tests for _restore_stashed_changes with the input_fn parameter.""" - - def test_uses_input_fn_when_provided(self, tmp_path): - """When input_fn is provided, it's called instead of input().""" - from hermes_cli.update_cmd import _restore_stashed_changes - - captured_args = [] - - def fake_input_fn(prompt, default=""): - captured_args.append((prompt, default)) - return "n" - - with patch("subprocess.run") as mock_run: - mock_run.return_value = MagicMock( - returncode=0, stdout="", stderr="" - ) - result = _restore_stashed_changes( - ["git"], tmp_path, "abc123", - prompt_user=True, - input_fn=fake_input_fn, - ) - - assert len(captured_args) == 1 - assert "Restore" in captured_args[0][0] - assert result is False # user declined - # --------------------------------------------------------------------------- # Update command spawns --gateway flag @@ -186,27 +154,37 @@ class TestWatchUpdateProgress: mock_adapter = AsyncMock() runner.adapters = {Platform.TELEGRAM: mock_adapter} - # Write exit code after a brief delay - async def write_exit_code(): - await asyncio.sleep(0.2) - (hermes_home / ".update_output.txt").write_text( - "→ Fetching updates...\n✓ Code updated!\n" - , encoding="utf-8") - (hermes_home / ".update_exit_code").write_text("0") + streamed = asyncio.Event() + sent = [] + + async def receive(chat_id, text, **kwargs): + sent.append(text) + if "Fetching updates" in text: + assert not (hermes_home / ".update_exit_code").exists() + streamed.set() + + mock_adapter.send.side_effect = receive with patch("gateway.run._hermes_home", hermes_home): - task = asyncio.create_task(write_exit_code()) - await runner._watch_update_progress( - poll_interval=0.1, - stream_interval=0.2, - timeout=5.0, - ) - await task + watcher = asyncio.create_task(runner._watch_update_progress( + poll_interval=0.01, stream_interval=0.02, timeout=15.0, + )) + try: + # Completion cannot supply this assertion: the child is still running. + await asyncio.wait_for(streamed.wait(), timeout=5.0) + assert not watcher.done() + assert not any("update finished" in text.lower() for text in sent) + with (hermes_home / ".update_output.txt").open("a", encoding="utf-8") as output: + output.write("✓ Code updated!\n") + (hermes_home / ".update_exit_code").write_text("0") + await asyncio.wait_for(watcher, timeout=5.0) + finally: + if not watcher.done(): + watcher.cancel() + await asyncio.gather(watcher, return_exceptions=True) - # Should have sent at least the output and a success message - assert mock_adapter.send.call_count >= 1 - all_sent = " ".join(str(c) for c in mock_adapter.send.call_args_list) - assert "update finished" in all_sent.lower() + assert "Code updated!" in sent[-2] + assert "update finished" in sent[-1].lower() @pytest.mark.asyncio async def test_detects_and_forwards_prompt(self, tmp_path): @@ -377,25 +355,45 @@ class TestUpdatePromptInterception: class TestCmdUpdateGatewayMode: """Tests for cmd_update with --gateway flag.""" - def test_gateway_flag_enables_gateway_prompt_for_stash(self, tmp_path): + def test_gateway_flag_enables_gateway_prompt_for_stash(self, tmp_path, monkeypatch): """With --gateway, stash restore uses _gateway_prompt instead of input().""" - from hermes_cli.update_cmd import _restore_stashed_changes + import subprocess + from types import SimpleNamespace + from hermes_cli import main, update_cmd - # Use input_fn to verify the gateway path is taken - calls = [] + root = tmp_path / "checkout" + root.mkdir() - def fake_input(prompt, default=""): - calls.append(prompt) - return "n" + def git(*args): + return subprocess.run(["git", *args], cwd=root, check=True, capture_output=True, text=True).stdout.strip() - with patch("subprocess.run") as mock_run: - mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") - _restore_stashed_changes( - ["git"], tmp_path, "abc123", - prompt_user=True, - input_fn=fake_input, - ) + git("init", "-b", "main") + git("config", "user.email", "test@example.invalid") + git("config", "user.name", "Test") + (root / "notes.txt").write_text("committed\n", encoding="utf-8") + git("add", ".") + git("commit", "-m", "baseline") + git("remote", "add", "origin", str(root)) + (root / "notes.txt").write_text("user edit\n", encoding="utf-8") + monkeypatch.setattr(main, "PROJECT_ROOT", root) + # Isolate host/service phases; options, Git, stash and prompt dispatch stay real. + monkeypatch.setattr(main, "_update_preflight_handled", lambda args: False) + monkeypatch.setattr(main, "_install_hangup_protection", lambda **kw: None) + monkeypatch.setattr(main, "_finalize_update_output", lambda state: None) + monkeypatch.setattr(main, "_run_pre_update_backup", lambda args: None) + monkeypatch.setattr(main, "_pause_windows_gateways_for_update", lambda: None) + monkeypatch.setattr("hermes_cli.update_inventory.collect_runtime_inventory", lambda: None) + monkeypatch.setattr(update_cmd, "_prepare_git_command", lambda: (False, ["git"], False)) + monkeypatch.setattr(update_cmd, "_repair_current_checkout", lambda **kw: True) + monkeypatch.setattr(update_cmd, "_apply_pending_fleet_restart_catchup", lambda: None) + gateway_prompt = MagicMock(return_value="n") + monkeypatch.setattr(update_cmd, "_gateway_prompt", gateway_prompt) + monkeypatch.setattr("builtins.input", lambda *a: pytest.fail("gateway update read terminal input")) - assert len(calls) == 1 - assert "Restore" in calls[0] + main.cmd_update(SimpleNamespace(gateway=True, branch="main", channel="main", yes=False)) + + gateway_prompt.assert_called_once() + assert "Restore" in gateway_prompt.call_args.args[0] + assert (root / "notes.txt").read_text(encoding="utf-8") == "committed\n" + assert git("stash", "show", "-p").endswith("+user edit") diff --git a/tests/hermes_cli/plugin_worker_support.py b/tests/hermes_cli/plugin_worker_support.py new file mode 100644 index 0000000000..62dd07996b --- /dev/null +++ b/tests/hermes_cli/plugin_worker_support.py @@ -0,0 +1,154 @@ +"""Offline plugin command fixtures; only tool acquisition is substituted. + +The actual client, worker, resolver, lock, publication callbacks and receipts +run unchanged. The prepared test interpreter hosts PM in a separate process; +fresh selected interpreters never inherit its site-packages. +""" +from __future__ import annotations + +import json +import argparse +import os +from pathlib import Path +import shutil +import subprocess +import sys + + +import pytest +import hermes_yaml as yaml + +from tests.pm._fixtures import _wheel, isolated_python as isolated_python + + +def worker_command(worker: Path, uv: str, python: str, *, prelude: str = "", runtime_python: str | None = None) -> list[str]: + script = ( + "import runpy, sys; from pathlib import Path; " + f"sys.path.insert(0, {str(worker.parent.parent)!r}); import pm._uv; " + f"pm._uv._toolchain = lambda **kwargs: (Path({uv!r}), Path({python!r}))\n" + + prelude + "\n" + f"runpy.run_path({str(worker)!r}, run_name='__main__')" + ) + return [runtime_python or python, "-I", "-B", "-c", script] + + +def git(repo: Path, *args: str) -> str: + env = {k: v for k, v in os.environ.items() if k not in {"GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"}} + env.update(GIT_AUTHOR_NAME="fixture", GIT_AUTHOR_EMAIL="fixture@example.invalid", + GIT_COMMITTER_NAME="fixture", GIT_COMMITTER_EMAIL="fixture@example.invalid") + result = subprocess.run(["git", *args], cwd=repo, env=env, capture_output=True, text=True, timeout=30) + assert result.returncode == 0, result.stderr + return result.stdout.strip() + + +def version(repo: Path, *, name="plugin-worker-proof", surface="python_dependencies", suffix="yaml", pin="1.0", capabilities=(), dependency="plugin-proof-dep") -> str: + manifest: dict = {"name": name, "version": pin} + if capabilities: + manifest["capabilities"] = list(capabilities) + specs = [f"{dependency}=={pin}"] + if surface and surface != "pyproject": + manifest[surface] = specs + (repo / f"plugin.{suffix}").write_text(yaml.safe_dump(manifest), encoding="utf-8") + (repo / "__init__.py").write_text( + f"import {dependency.replace('-', '_')} as plugin_proof_dep\nVERSION = {pin!r}\n" + "def register(ctx):\n return (VERSION, plugin_proof_dep.__version__)\n", encoding="utf-8") + if surface == "pyproject": + (repo / "pyproject.toml").write_text( + f'[project]\nname={json.dumps(name)}\nversion="{pin}"\nrequires-python=">=3.11"\n' + f'dependencies={json.dumps(specs)}\n[tool.uv]\npackage=false\n', encoding="utf-8") + git(repo, "add", ".") + git(repo, "commit", "-qm", f"fixture {pin}") + return git(repo, "rev-parse", "HEAD") + + +class PluginWorld: + runtime_python: str + + def __init__(self, root: Path): + self.root = root + self.home = root / "home" + self.core = root / "core" + + def origin(self, **kwargs) -> tuple[Path, str]: + repo = self.root / (kwargs.get("name", "plugin-worker-proof") + "-origin") + repo.mkdir() + git(repo, "init", "-qb", "main") + return repo, version(repo, **kwargs) + + def command(self, action: str, **kwargs) -> None: + from hermes_cli.plugins_cmd import plugins_command + from hermes_cli.subcommands.plugins import build_plugins_parser + + parser = argparse.ArgumentParser() + build_plugins_parser(parser.add_subparsers(), cmd_plugins=plugins_command) + positional = {"install": ("identifier",), "enable": ("name",), "disable": ("name",), + "pack": ("pack_action", "source")}[action] + argv = ["plugins", action, *(str(kwargs.pop(key)) for key in positional)] + for key, value in kwargs.items(): + if value is True: + argv.append("--" + key.replace("_", "-")) + elif value is not None and value is not False: + argv.extend(["--" + key.replace("_", "-"), str(value)]) + plugins_command(parser.parse_args(argv)) + + def selected(self) -> Path: + from hermes_cli.runtime_paths import selected_venv + return selected_venv(self.core) + + def enabled(self) -> list[str]: + return yaml.safe_load((self.home / "config.yaml").read_text(encoding="utf-8"))["plugins"]["enabled"] + + def imports(self, name="plugin-worker-proof", pin="1.0") -> None: + selected = self.selected() + python = selected / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + code = ( + "import importlib.util, pathlib; import plugin_core_dep, plugin_proof_dep; " + f"assert pathlib.Path(plugin_core_dep.__file__).is_relative_to({str(selected)!r}); " + f"assert pathlib.Path(plugin_proof_dep.__file__).is_relative_to({str(selected)!r}); " + f"spec = importlib.util.spec_from_file_location('loaded_plugin', {str(self.home / 'plugins' / name / '__init__.py')!r}); " + "plugin = importlib.util.module_from_spec(spec); spec.loader.exec_module(plugin); " + f"assert plugin.register(None) == ({pin!r}, {pin!r})" + ) + result = subprocess.run([str(python), "-I", "-B", "-c", code], cwd=self.root, + capture_output=True, text=True, timeout=30) + assert result.returncode == 0, result.stderr + + +@pytest.fixture +def plugin_world(tmp_path, monkeypatch, isolated_python): + from pm import client, paths + + world = PluginWorld(tmp_path) + uv = shutil.which("uv") + assert uv, "real uv is required for the plugin lifecycle" + for key in ("HERMES_MANAGED_MODE", "HERMES_INSTALL_ROOT", "VIRTUAL_ENV"): + monkeypatch.delenv(key, raising=False) + for key, value in {"HOME": tmp_path, "USERPROFILE": tmp_path, "HERMES_HOME": world.home, + "HERMES_RUNTIME_DIR": tmp_path / "store", "UV_CACHE_DIR": tmp_path / "cache", + "XDG_CONFIG_HOME": tmp_path / "config", "XDG_CONFIG_DIRS": tmp_path / "config", + "HERMES_DISABLE_LAZY_INSTALLS": "1", "UV_OFFLINE": "1"}.items(): + monkeypatch.setenv(key, str(value)) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setattr(paths, "repo_root", lambda: world.core) + monkeypatch.setattr(paths, "lockfile_path", lambda: tmp_path / "lock.json") + world.runtime_python = str(isolated_python) + monkeypatch.setattr(client, "runtime_command", lambda worker, **kwargs: + worker_command(worker, uv, sys.executable, runtime_python=world.runtime_python)) + assert not client.is_runtime(), "fixture must exercise the client/worker boundary" + wheels = tmp_path / "wheels" + wheels.mkdir() + _wheel(wheels, "plugin_core_dep") + _wheel(wheels, "plugin_proof_other") + for pin in ("1.0", "2.0"): + _wheel(wheels, "plugin_proof_dep", pin) + world.home.mkdir() + (world.home / "config.yaml").write_text("plugins:\n enabled: []\n disabled: []\n", encoding="utf-8") + world.core.mkdir() + (world.core / "pyproject.toml").write_text( + '[project]\nname="plugin-proof-core"\nversion="1"\nrequires-python=">=3.11"\n' + 'dependencies=["plugin-core-dep==1.0"]\n[tool.uv]\npackage=false\nno-index=true\n' + f'find-links=[{json.dumps(wheels.as_posix())}]\n', encoding="utf-8") + result = subprocess.run([uv, "lock", "--python", sys.executable], cwd=world.core, + capture_output=True, text=True, timeout=60) + assert result.returncode == 0, result.stderr + return world \ No newline at end of file diff --git a/tests/hermes_cli/test_boot_bootstrap.py b/tests/hermes_cli/test_boot_bootstrap.py index 699939217f..8740636ff5 100644 --- a/tests/hermes_cli/test_boot_bootstrap.py +++ b/tests/hermes_cli/test_boot_bootstrap.py @@ -465,55 +465,6 @@ def test_sealed_tree_bootstrap_end_to_end(tmp_path, monkeypatch): # ── the per-install state folder ───────────────────────────────────── -def test_ensure_install_dir_writes_the_reverse_map(repo, tmp_path, monkeypatch): - """install.json is the sha16 → root reverse map that makes orphan GC - possible. Written once; a second call must not rewrite firstSeen.""" - monkeypatch.setattr(Path, "home", lambda: tmp_path) - monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) # isolate the default root - - state = boot_bootstrap.ensure_install_dir(repo) - - assert state == boot_bootstrap.install_state_dir(repo) - record = json.loads((state / "install.json").read_text()) - assert record["root"] == str(repo.resolve()) - assert record["steward"] == "checkout" - first_seen = record["firstSeen"] - - boot_bootstrap.ensure_install_dir(repo) - assert json.loads((state / "install.json").read_text())["firstSeen"] == first_seen - - -def test_ensure_install_dir_records_the_sealed_steward(tmp_path, monkeypatch): - monkeypatch.setattr(Path, "home", lambda: tmp_path) - monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) # isolate the default root - - sealed = tmp_path / "payload" - sealed.mkdir() - (sealed / "install-stamp.json").write_text( - json.dumps({"commit": "c" * 40, "distribution": "desktop-app", "updateMechanism": "electron-updater"}) - ) - state = boot_bootstrap.ensure_install_dir(sealed) - record = json.loads((state / "install.json").read_text()) - assert record["steward"] == "desktop-app" - - -def test_orphan_sweep_flags_only_vanished_roots(repo, tmp_path, monkeypatch): - monkeypatch.setattr(Path, "home", lambda: tmp_path) - monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) # isolate the default root - - boot_bootstrap.ensure_install_dir(repo) # alive - ghost = tmp_path / "deleted-checkout" - ghost.mkdir() - ghost_state = boot_bootstrap.ensure_install_dir(ghost) - shutil.rmtree(ghost) # the install is gone; its state folder is not - - orphans = boot_bootstrap.orphaned_installs() - - assert [(folder, root) for folder, root in orphans] == [ - (ghost_state, str(ghost)) - ] - - def test_bootstrap_records_live_inside_the_state_folder(repo, tmp_path, monkeypatch): """Both scopes share the install's folder; profile identity rides the FILENAME. One anchor, not two homes.""" @@ -594,60 +545,3 @@ class TestSealedDriftBackstop: monkeypatch.setattr(post_update, "BOOT_MACHINE_STEPS", ()) summary = run_boot_bootstrap(root) assert "uv" in summary.get("sealed_runtime_drift", "") - - -class TestOrphanedStoreEntries: - """orphaned_store_entries — pm's facts.json is the only authority, - keep on doubt.""" - - @pytest.fixture - def store(self, tmp_path, monkeypatch): - store = tmp_path / "tools" - store.mkdir(parents=True) - monkeypatch.setenv("HERMES_RUNTIME_DIR", str(store)) - return store - - def _publish(self, store, name, payload=b"tool bytes"): - entry = store / name - entry.mkdir(parents=True) - (entry / "tool.bin").write_bytes(payload) - return entry - - def _facts(self, store, packages: dict): - (store / "facts.json").write_text( - json.dumps({"schema": 1, "packages": packages}), encoding="utf-8" - ) - - def test_referenced_entries_survive_unreferenced_are_flagged(self, store): - self._publish(store, "ripgrep-15.2.0-win32-x64") - stale = self._publish(store, "ripgrep-14.1.0-win32-x64", b"x" * 512) - self._facts(store, { - "ripgrep": {"entry": "ripgrep-15.2.0-win32-x64", "version": "15.2.0", "env": {}}, - }) - - orphans = boot_bootstrap.orphaned_store_entries() - - assert [(e.name, s) for e, s in orphans] == [ - ("ripgrep-14.1.0-win32-x64", 512) - ] - assert stale.exists() # report-only: nothing was deleted - - def test_no_facts_file_reports_nothing(self, store): - """pm only vouches for what it installed: no ledger, no verdicts.""" - self._publish(store, "gh-2.97.0-win32-x64") - assert boot_bootstrap.orphaned_store_entries() == [] - - def test_unreadable_facts_err_toward_keep(self, store): - """A busted ledger must cost disk, not flag entries it may own.""" - self._publish(store, "gh-2.97.0-win32-x64") - (store / "facts.json").write_text("NOT JSON", encoding="utf-8") - assert boot_bootstrap.orphaned_store_entries() == [] - - def test_scratch_fetch_and_files_are_not_candidates(self, store): - """Scratch dirs are pm's own cleanup, fetch-* entries are the - re-download cache, and stray files are not entries at all.""" - (store / ".staging-abc123").mkdir() - self._publish(store, "fetch-0123456789abcdef") - (store / "stray.txt").write_text("x", encoding="utf-8") - self._facts(store, {}) - assert boot_bootstrap.orphaned_store_entries() == [] diff --git a/tests/hermes_cli/test_cmd_update.py b/tests/hermes_cli/test_cmd_update.py index 5bdc679fdc..6626bb4aa2 100644 --- a/tests/hermes_cli/test_cmd_update.py +++ b/tests/hermes_cli/test_cmd_update.py @@ -790,7 +790,7 @@ class TestZipDesktopPreservation: monkeypatch.setattr(update_cmd, "_print_curator_first_run_notice", lambda: None) monkeypatch.setattr(update_cmd, "_print_curator_recent_run_notice", lambda: None) - monkeypatch.setattr("hermes_cli.update_cmd_maint._refresh_dashboard_after_update", lambda: None) + monkeypatch.setattr("hermes_cli.update_cmd_maint._refresh_dashboard_after_update", lambda **kwargs: None) monkeypatch.setattr(update_cmd, "get_hermes_home", lambda: tmp_path / "hermes-home") with ( diff --git a/tests/hermes_cli/test_doctor_command_install.py b/tests/hermes_cli/test_doctor_command_install.py index 14ef78a007..9d69faf6eb 100644 --- a/tests/hermes_cli/test_doctor_command_install.py +++ b/tests/hermes_cli/test_doctor_command_install.py @@ -48,6 +48,7 @@ def _pm_source(project, home): "hermes", "hermes_bootstrap.py", "hermes_constants.py", "hermes_cli/__init__.py", "hermes_cli/runtime_paths.py", "hermes_cli/runtime_state.py", "hermes_cli/_early_recovery.py", "hermes_cli/_parser.py", + "hermes_cli/venv_sync.py", "hermes_cli/steward.py", "hermes_cli/stderr_timestamp.py", ): target = project / relative target.parent.mkdir(parents=True, exist_ok=True) diff --git a/tests/hermes_cli/test_doctor_pm_store_probe.py b/tests/hermes_cli/test_doctor_pm_store_probe.py index c7b73c347b..ecc2c1b575 100644 --- a/tests/hermes_cli/test_doctor_pm_store_probe.py +++ b/tests/hermes_cli/test_doctor_pm_store_probe.py @@ -1,76 +1,129 @@ -"""Doctor reports the tools Hermes would actually run — the pm store first. - -Hermes runs pinned tools out of the pm store. Nothing puts the store on an -interactive shell's PATH, so a PATH-only probe reports a perfectly healthy -managed install as "not found", and silently skips the checks that depend -on the tool being present. -""" +"""Doctor and launched commands share PM's read-only installed selection.""" from __future__ import annotations -import sys +import hashlib +import json +from pathlib import Path +import shutil +import stat +import subprocess +import pytest + +import pm from hermes_cli import doctor_tools +from pm import paths +from pm.lock import Facts, Lockfile +from pm.registry import get_package +from pm.store import current_target, tree_digest -class TestPmToolPath: - """_pm_tool_path answers from facts.json + the pm store, nothing else.""" +@pytest.fixture +def tool_store(tmp_path, monkeypatch): + rg = shutil.which("rg") + assert rg is not None, "this executable parity test requires ripgrep" + version_output = subprocess.run( + [rg, "--version"], capture_output=True, text=True, check=True, timeout=10, + ).stdout + version = version_output.splitlines()[0].split()[1] + home = tmp_path / "home" + primary = tmp_path / "payload" / "tools" + primary.mkdir(parents=True) + (primary.parent / "manifest.json").write_text("{}", encoding="utf-8") + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setenv("HERMES_RUNTIME_DIR", str(primary)) + monkeypatch.setenv("HERMES_DISABLE_LAZY_INSTALLS", "1") + monkeypatch.setattr(Path, "home", lambda: tmp_path) + lock_path = tmp_path / "lock.json" + monkeypatch.setattr(paths, "lockfile_path", lambda: lock_path) + target = current_target() + package = get_package("ripgrep") + digest = hashlib.sha256(Path(rg).read_bytes()).hexdigest() + lock = Lockfile(lock_path) + lock.set_pin("ripgrep", version, {target: {"url": Path(rg).as_uri(), "sha256": digest}}) + lock.save() - def _store(self, tmp_path, monkeypatch, facts: dict): - import json - - store = tmp_path / "tools" - store.mkdir(parents=True, exist_ok=True) - (store / "facts.json").write_text( - json.dumps({"schema": 1, "packages": facts}), encoding="utf-8" + def publish(root): + entry = root / package.store_entry(version, target) + binary = package.binary(entry, target) + assert binary is not None + binary.parent.mkdir(parents=True) + binary.symlink_to(rg) + Facts(root / "facts.json").record( + "ripgrep", version, entry.name, package.env(entry, target), root, + target=target, artifacts=[digest], digest=tree_digest(entry), ) - monkeypatch.setenv("HERMES_RUNTIME_DIR", str(store)) - return store + return binary - def test_staged_tool_resolves_to_its_store_binary(self, tmp_path, monkeypatch): - store = self._store( - tmp_path, monkeypatch, - {"ripgrep": {"entry": "ripgrep-15.0.0-test", "version": "15.0.0", "env": {}}}, - ) - entry = store / "ripgrep-15.0.0-test" - entry.mkdir() - binary = entry / ("rg.exe" if sys.platform == "win32" else "rg") - binary.write_text("", encoding="utf-8") - - assert doctor_tools._pm_tool_path("ripgrep") == binary - - def test_unstaged_tool_resolves_to_none(self, tmp_path, monkeypatch): - self._store(tmp_path, monkeypatch, {}) - assert doctor_tools._pm_tool_path("ripgrep") is None - - def test_recorded_but_deleted_binary_resolves_to_none(self, tmp_path, monkeypatch): - self._store( - tmp_path, monkeypatch, - {"ripgrep": {"entry": "ripgrep-15.0.0-test", "version": "15.0.0", "env": {}}}, - ) - assert doctor_tools._pm_tool_path("ripgrep") is None + hostile = tmp_path / "hostile" + hostile.mkdir() + hostile_rg = hostile / "rg" + hostile_rg.write_text("#!/bin/sh\nprintf 'hostile PATH ripgrep\\n'\n", encoding="utf-8") + hostile_rg.chmod(0o755) + monkeypatch.setenv("PATH", str(hostile)) + return primary, publish, version_output, hostile_rg -class TestGitAndRgWiring: - """_check_git_and_rg probes the store first, so a managed install is - never reported as "not found" just because the store is off PATH.""" - - def test_staged_rg_is_found_and_labeled_pm_store(self, tmp_path, monkeypatch, capsys): - store = tmp_path / "tools" - store.mkdir() - (store / "facts.json").write_text( - '{"schema": 1, "packages": {"ripgrep": {"entry": "ripgrep-15.0.0-test"}}}', - encoding="utf-8", - ) - monkeypatch.setenv("HERMES_RUNTIME_DIR", str(store)) - entry = store / "ripgrep-15.0.0-test" - entry.mkdir() - (entry / ("rg.exe" if sys.platform == "win32" else "rg")).write_text("", encoding="utf-8") - # Nothing on PATH: the old probe alone reported "not found". Patched at - # the real shutil.which boundary, not a doctor wrapper. - monkeypatch.setattr(doctor_tools.shutil, "which", lambda _cmd: None) - +@pytest.mark.platforms("posix") +@pytest.mark.parametrize("primary_state", ["absent", "stale"]) +def test_sealed_store_extension_matches_launched_tool(tool_store, primary_state, capsys): + primary, publish, version_output, _hostile = tool_store + if primary_state == "stale": + publish(primary) + facts_path = primary / "facts.json" + data = json.loads(facts_path.read_text(encoding="utf-8")) + data["packages"]["ripgrep"]["version"] = "0.0.0" + facts_path.write_text(json.dumps(data), encoding="utf-8") + binary = publish(paths.writable_store_root()) + before = {p: p.read_bytes() for root in (primary, paths.writable_store_root()) + for p in root.rglob("*") if p.is_file()} + # The selected writable extension must not require repairing the payload. + sealed_paths = [primary, *(p for p in primary.rglob("*") if not p.is_symlink())] + modes = {p: stat.S_IMODE(p.stat().st_mode) for p in sealed_paths} + for path, mode in modes.items(): + path.chmod(mode & ~0o222) + try: + selected = pm.installed_package("ripgrep") + assert selected is not None and selected.binary == binary + env = pm.env_for("ripgrep", base_env={"PATH": str(_hostile.parent)}) + assert shutil.which("rg", path=env["PATH"]) == str(binary) + child = subprocess.run(["rg", "--version"], env=env, capture_output=True, + text=True, check=True, timeout=10) + assert child.stdout == version_output + assert doctor_tools._doctor_tool("rg") == (str(binary), "(pm store)") doctor_tools._check_git_and_rg(False) + assert "✓ ripgrep (rg) (pm store)" in capsys.readouterr().out + assert {p: p.read_bytes() for root in (primary, paths.writable_store_root()) + for p in root.rglob("*") if p.is_file()} == before + finally: + for path, mode in modes.items(): + path.chmod(mode) - out = capsys.readouterr().out - assert "✓ ripgrep (rg) (pm store)" in out - assert "ripgrep (rg) not found" not in out + +@pytest.mark.platforms("posix") +@pytest.mark.parametrize("damage", ["version", "target", "artifacts", "missing-binary", "sparse", "malformed"]) +def test_rejected_store_is_not_reported_as_runtime_available(tool_store, damage, capsys): + primary, publish, _version_output, hostile = tool_store + binary = publish(primary) + facts_path = primary / "facts.json" + data = json.loads(facts_path.read_text(encoding="utf-8")) + fact = data["packages"]["ripgrep"] + if damage == "missing-binary": + binary.unlink() + elif damage == "sparse": + data["packages"]["ripgrep"] = {"entry": fact["entry"]} + elif damage != "malformed": + fact[damage] = ["wrong-artifact"] if damage == "artifacts" else "outdated" + facts_path.write_text("not JSON" if damage == "malformed" else json.dumps(data), encoding="utf-8") + before = facts_path.read_bytes() + + assert pm.installed_package("ripgrep") is None + assert doctor_tools._pm_tool_path("ripgrep") is None + assert doctor_tools._doctor_tool("rg") == (str(hostile), "") + child = subprocess.run(["rg", "--version"], env=pm.env_for("ripgrep"), + capture_output=True, text=True, check=True, timeout=10) + assert child.stdout.strip() == "hostile PATH ripgrep" + doctor_tools._check_git_and_rg(False) + out = capsys.readouterr().out + assert "✓ ripgrep (rg)" in out and "(pm store)" not in out + assert facts_path.read_bytes() == before \ No newline at end of file diff --git a/tests/hermes_cli/test_install_bucket_separation.py b/tests/hermes_cli/test_install_bucket_separation.py index 2b3e40e333..cf6d4227e9 100644 --- a/tests/hermes_cli/test_install_bucket_separation.py +++ b/tests/hermes_cli/test_install_bucket_separation.py @@ -5,6 +5,9 @@ to a profile. Uninstall removes the former (in either mode — they are not data); profile clone/export never copies them. """ +from pathlib import Path +import tarfile + import pytest from hermes_cli.uninstall import remove_legacy_runtime_trees @@ -57,29 +60,47 @@ class TestRemoveLegacyRuntimeTrees: class TestProfileCopyExclusions: - @pytest.mark.parametrize( - "excluded", [".hermes-runtime", "node", "hermes-agent", "profiles"] - ) - def test_clone_all_excludes_install_artifacts(self, excluded): - from hermes_cli.profiles import _CLONE_ALL_DEFAULT_EXCLUDE_ROOT + @pytest.mark.parametrize("operation", ["clone", "export", "distribution"]) + def test_copies_profile_payload_without_install_artifacts(self, tmp_path, monkeypatch, operation): + from hermes_cli import profiles, profile_distribution - assert excluded in _CLONE_ALL_DEFAULT_EXCLUDE_ROOT + monkeypatch.setattr(Path, "home", lambda: tmp_path) + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setattr(profiles, "_maybe_register_gateway_service", lambda name: None) + kept = {"config.yaml": "model: {}\n", "SOUL.md": "profile identity\n", + "skills/demo/SKILL.md": "demo instructions\n"} + if operation != "distribution": + kept["memories/MEMORY.md"] = "profile memory\n" + excluded = (".hermes-runtime", "node", "hermes-agent", "profiles") + for rel, content in kept.items(): + path = home / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + for name in excluded: + path = home / name / "must-not-copy" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("install state", encoding="utf-8") - @pytest.mark.parametrize("excluded", [".hermes-runtime", "node"]) - def test_export_excludes_managed_runtimes(self, excluded): - from hermes_cli.profiles import _DEFAULT_EXPORT_EXCLUDE_ROOT + if operation == "clone": + target = profiles.create_profile("clone", clone_from="default", clone_all=True, no_alias=True) + elif operation == "distribution": + profile_distribution.write_manifest(home, profile_distribution.DistributionManifest(name="copy", version="1.0.0")) + profile_distribution.install_distribution(str(home), name="copy", create_alias=False) + target = profiles.get_profile_dir("copy") + else: + archive = profiles.export_profile("default", str(tmp_path / "profile.tar.gz")) + with tarfile.open(archive) as bundle: + for rel, content in kept.items(): + payload = bundle.extractfile(f"default/{rel}") + assert payload is not None, rel + assert payload.read().decode() == content + roots = {name.split("/")[1] for name in bundle.getnames() if "/" in name} + assert not roots.intersection(excluded) + return - assert excluded in _DEFAULT_EXPORT_EXCLUDE_ROOT - - @pytest.mark.parametrize("excluded", [".hermes-runtime", "node"]) - def test_distribution_excludes_managed_runtimes(self, excluded): - from hermes_cli.profile_distribution import USER_OWNED_EXCLUDE - - assert excluded in USER_OWNED_EXCLUDE - - def test_profile_state_is_still_copied(self): - """The exclusions must not swallow actual profile data.""" - from hermes_cli.profiles import _CLONE_ALL_DEFAULT_EXCLUDE_ROOT - - for kept in ("config.yaml", "skills", "memories", "SOUL.md"): - assert kept not in _CLONE_ALL_DEFAULT_EXCLUDE_ROOT + for rel, content in kept.items(): + assert (target / rel).read_text(encoding="utf-8") == content + for name in excluded: + assert not (target / name).exists(), name diff --git a/tests/hermes_cli/test_memory_dependency_admission.py b/tests/hermes_cli/test_memory_dependency_admission.py index ad50c917d9..3dbf33419e 100644 --- a/tests/hermes_cli/test_memory_dependency_admission.py +++ b/tests/hermes_cli/test_memory_dependency_admission.py @@ -14,7 +14,7 @@ import hermes_yaml as yaml from hermes_cli import memory_setup from hermes_cli.runtime_paths import selected_venv from pm import paths -from tests.pm.test_workspace_build_inputs import _wheel +from tests.pm._fixtures import _wheel @pytest.mark.parametrize('picker', [False, True]) diff --git a/tests/hermes_cli/test_npm_engine.py b/tests/hermes_cli/test_npm_engine.py index ccabb136d8..e58885c8cf 100644 --- a/tests/hermes_cli/test_npm_engine.py +++ b/tests/hermes_cli/test_npm_engine.py @@ -1,28 +1,15 @@ -"""The frozen npm retry import stops old callers without changing dependencies.""" +"""The frozen npm retry import hands off, never provisioning in the old parent.""" import pytest from hermes_cli.npm_engine import maybe_repair_npm_engine +from tests.compat.old_updater_support import ( + fresh_child as fresh_child, + no_external_work as no_external_work, +) @pytest.mark.parametrize("quiet,output", [(True, "EBADENGINE"), (False, "unrelated failure")]) -def test_retired_retry_stops_old_updater_without_provisioning(tmp_path, monkeypatch, capsys, quiet, output): - import pm - - home = tmp_path / "home" - home.mkdir() - monkeypatch.setenv("HERMES_HOME", str(home)) - monkeypatch.setenv("HERMES_RUNTIME_DIR", str(home / "tools")) - calls = [] - - def forbidden_ensure(*args, **kwargs): - calls.append((args, kwargs)) - raise AssertionError("historical updater must not install") - - monkeypatch.setattr(pm, "ensure", forbidden_ensure) - with pytest.raises(SystemExit) as stopped: +def test_retired_retry_handoffs_without_provisioning(fresh_child, quiet, output): + with fresh_child.exits(): maybe_repair_npm_engine("/caller-owned/npm", output, quiet=quiet) - assert stopped.value.code == 0 - assert "run `hermes` again" in capsys.readouterr().err - assert calls == [] - assert list(home.iterdir()) == [] diff --git a/tests/hermes_cli/test_old_updater_takeover.py b/tests/hermes_cli/test_old_updater_takeover.py new file mode 100644 index 0000000000..a9a9809b98 --- /dev/null +++ b/tests/hermes_cli/test_old_updater_takeover.py @@ -0,0 +1,262 @@ +"""Historical frames stay old; completion runs once in a clean child.""" +from __future__ import annotations + +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys + +import pytest + + +@pytest.mark.parametrize("status", [0, 7]) +def test_takeover_waits_propagates_status_and_never_reenters_old_code(tmp_path, status): + source = Path(__file__).resolve().parents[2] + root = tmp_path / "updated checkout" + package = root / "hermes_cli" + package.mkdir(parents=True) + shutil.copy2(source / "hermes_cli/_old_updater.py", package / "_old_updater.py") + # This is the process seam, not a counterfeit installer. Actual PM and + # product construction are exercised separately against local packages. + (package / "_update_takeover.py").write_text( + "import json, os, sys\nfrom pathlib import Path\n" + "request = json.loads(Path(sys.argv[1]).read_text())\n" + "assert 'old_only' not in sys.modules\n" + "assert sys.flags.utf8_mode == 1\n" + "assert 'PYTHONPATH' not in os.environ\n" + "assert request['desktop'] is True\n" + "assert request['windows_resume']['profiles'] == {'work': 'old-pid'}\n" + "assert request['pre_update_snapshot_id'] == 'preserve-snapshot'\n" + "assert request['gateway_mode'] is True\n" + "assert request['pre_update_version'] == 'old-version'\n" + "assert request['home'] == os.environ['HERMES_HOME']\n" + "with Path(request['home'], 'runs').open('a') as stream: stream.write('child\\n')\n" + "Path(sys.argv[2]).write_text(json.dumps({'resume_handled': True}))\n" + f"raise SystemExit({status})\n", encoding="utf-8", + ) + home = tmp_path / "isolated home" + home.mkdir() + program = root / "historical.py" + program.write_text( + "import atexit, json, os, sys, types\nfrom pathlib import Path\n" + "sys.modules['old_only'] = types.ModuleType('old_only')\n" + "from hermes_cli._old_updater import stop_for_relaunch\n" + "_windows_gateway_resume = {'resume_needed': True, 'profiles': {'work': 'old-pid'}}\n" + "had_desktop_app_before_update = True\n" + "pre_update_snapshot_id = 'preserve-snapshot'\n" + "gateway_mode = True\npre_update_version = 'old-version'\n" + "def cleanup():\n" + " assert not _windows_gateway_resume['resume_needed']\n" + " assert 'old_only' in sys.modules\n" + " Path(os.environ['HERMES_HOME'], 'cleanup').write_text('ran')\n" + "atexit.register(cleanup)\n" + "try:\n stop_for_relaunch()\n" + "finally:\n stop_for_relaunch()\n" + "raise AssertionError('old updater resumed')\n", encoding="utf-8", + ) + env = {key: value for key, value in os.environ.items() + if not key.startswith(('HERMES_', 'PYTHON', 'UV_'))} + env.update(HOME=str(home), HERMES_HOME=str(home), PYTHONPATH="/not/the/new/source") + result = subprocess.run([sys.executable, "-B", str(program)], env=env, + capture_output=True, text=True, timeout=30) + assert result.returncode == status, result.stdout + result.stderr + assert (home / "runs").read_text() == "child\n" + assert (home / "cleanup").read_text() == "ran" + assert "run `hermes` again" not in result.stderr + + +# Only the copied shim and JSON probe run; this temp checkout has no real updater. +@pytest.mark.live_system_guard_bypass +@pytest.mark.parametrize("post_pull", [False, True]) +@pytest.mark.parametrize("module_name", ["update_cmd", "unrelated"]) +def test_only_known_early_updater_restarts_with_original_arguments(tmp_path, post_pull, module_name): + source = Path(__file__).resolve().parents[2] + root = tmp_path / "updated checkout" + package = root / "hermes_cli" + package.mkdir(parents=True) + for name in ("_old_updater.py", "old_updater_deps.py"): + shutil.copy2(source / "hermes_cli" / name, package / name) + (package / "_update_takeover.py").write_text( + "import json, sys\nfrom pathlib import Path\n" + "request = json.loads(Path(sys.argv[1]).read_text())\n" + "Path(request['home'], 'request.json').write_text(json.dumps(request))\n" + "Path(sys.argv[2]).write_text('{}')\n", encoding="utf-8", + ) + # Model the historical frame at 096826bf: capture precedes the assignment; + # after pulling, the *same* frame reaches a dependency hook. The wrapper + # must not make that post-pull invocation look like another early update. + (package / f"{module_name}.py").write_text( + "from hermes_cli.old_updater_deps import _capture_active_lazy_features, _update_node_dependencies\n" + "def _cmd_update_impl(post_pull):\n" + " if not post_pull:\n _capture_active_lazy_features()\n" + " pre_update_version = None\n" + " _update_node_dependencies()\n" + "def cmd_update(post_pull):\n _cmd_update_impl(post_pull)\n", encoding="utf-8", + ) + home = tmp_path / "isolated home" + home.mkdir() + argv = [str(root / "historical.py"), "--profile", "work profile", "update", "--yes", + "--keep-stash", "--switch-branch", "--force", "--gateway"] + Path(argv[0]).write_text( + f"from hermes_cli.{module_name} import cmd_update\n" + f"cmd_update({post_pull!r})\n" + "raise AssertionError('old updater resumed')\n", encoding="utf-8", + ) + env = {key: value for key, value in os.environ.items() + if not key.startswith(("HERMES_", "PYTHON", "UV_"))} + env.update(HOME=str(home), HERMES_HOME=str(home)) + result = subprocess.run([sys.executable, "-B", *argv], env=env, + capture_output=True, text=True, timeout=30) + assert result.returncode == 0, result.stdout + result.stderr + request = json.loads((home / "request.json").read_text()) + assert request.get("restart_update", False) is (not post_pull and module_name == "update_cmd") + assert request["argv"] == argv + + +@pytest.mark.parametrize("acknowledged", [False, True]) +@pytest.mark.parametrize("cleanup_status", [0, 9]) +def test_atexit_recovers_only_stopped_serves_after_cached_update(tmp_path, acknowledged, cleanup_status): + source = Path(__file__).resolve().parents[2] + root = tmp_path / "updated checkout" + package = root / "hermes_cli" + package.mkdir(parents=True) + for name in ("_old_updater.py", "old_updater_deps.py"): + shutil.copy2(source / "hermes_cli" / name, package / name) + (package / "_update_takeover.py").write_text( + "import json, sys\nfrom pathlib import Path\n" + "request = json.loads(Path(sys.argv[1]).read_text())\n" + "home = Path(request['home'])\n" + "cleanup = 'stopped_serves' in request\n" + "if cleanup:\n" + " assert set(request) == {'root', 'home', 'argv', 'stopped_serves'}\n" + " assert request['stopped_serves']['pending'] is True\n" + " assert request['stopped_serves']['entries'][0]['profile'] == 'work profile'\n" + "with (home / 'runs').open('a') as stream:\n" + " stream.write('cleanup\\n' if cleanup else 'update\\n')\n" + f"Path(sys.argv[2]).write_text(json.dumps({{'serves_handled': {acknowledged!r}}} if cleanup else {{}}))\n" + f"raise SystemExit({cleanup_status} if cleanup else 7)\n", encoding="utf-8", + ) + home = tmp_path / "isolated home" + home.mkdir() + program = root / "historical.py" + program.write_text( + "import atexit, json, os\nfrom pathlib import Path\n" + "from hermes_cli._old_updater import stop_for_relaunch\n" + "from hermes_cli.old_updater_deps import _relaunch_stopped_serves\n" + "token = {'pending': True, 'entries': [{'purpose': 'serve', 'profile': 'work profile'," + " 'host': '127.0.0.1', 'port': 8119}]}\n" + "def cleanup():\n" + " try:\n" + " _relaunch_stopped_serves(token)\n" + " if not token['pending']:\n _relaunch_stopped_serves(token)\n" + " finally:\n" + " Path(os.environ['HERMES_HOME'], 'token.json').write_text(json.dumps(token))\n" + "atexit.register(cleanup)\n" + "stop_for_relaunch()\n" + "raise AssertionError('old updater resumed')\n", encoding="utf-8", + ) + env = {key: value for key, value in os.environ.items() + if not key.startswith(("HERMES_", "PYTHON", "UV_"))} + env.update(HOME=str(home), HERMES_HOME=str(home)) + result = subprocess.run([sys.executable, "-B", str(program)], env=env, + capture_output=True, text=True, timeout=30) + assert result.returncode == 7, result.stdout + result.stderr + assert (home / "runs").read_text() == "update\ncleanup\n" + token = json.loads((home / "token.json").read_text()) + assert token["pending"] is (not acknowledged) + assert "Exception ignored" not in result.stderr + assert ("restart the affected" in result.stderr) is (not acknowledged or cleanup_status != 0) + + +@pytest.mark.parametrize("spawn_fails", [False, True]) +@pytest.mark.parametrize("discarded", [False, True]) +def test_serve_resume_child_reuses_respawn_without_updater_or_supervisors(tmp_path, spawn_fails, discarded): + root = Path(__file__).resolve().parents[2] + context, result_path = tmp_path / "request.json", tmp_path / "result.json" + home = tmp_path / "home" + home.mkdir() + entries = [ + {"purpose": "serve", "profile": "default", "host": "127.0.0.1", "port": 8119}, + {"purpose": "dashboard", "profile": "ops team 日本", "host": "::1", "port": 8120}, + ] + if discarded: + entries += [ + dict(entries[0]), + {"purpose": "serve", "profile": "desktop", "port": 0}, + {"purpose": "serve", "profile": "foreign", "port": 8121, "hermes_home": str(tmp_path / "foreign")}, + {"purpose": "gateway", "profile": "not-serve", "port": 8122}, + {"purpose": "serve", "profile": "boolean-port", "port": True}, + {"purpose": "serve", "profile": "invalid-port", "port": 65536}, + ] + context.write_text(json.dumps({"root": str(root), "home": str(home), + "stopped_serves": {"pending": True, "entries": entries}})) + program = tmp_path / "probe.py" + program.write_text( + "import json, os, runpy, subprocess, sys\nfrom pathlib import Path\n" + "calls = []\n" + "def forbidden(*args, **kwargs):\n raise AssertionError('updater/process control ran')\n" + "def spawn(command, **kwargs):\n" + " assert kwargs['start_new_session'] is True\n" + " assert kwargs['stdin'] == subprocess.DEVNULL\n" + " calls.append(command)\n" + f" if {spawn_fails!r}:\n raise OSError('probe spawn failed')\n" + "subprocess.Popen = spawn\nsubprocess.run = os.system = os.kill = forbidden\n" + f"sys.argv = [{str(root / 'hermes_cli/update_serve_resume.py')!r}, {str(context)!r}, {str(result_path)!r}]\n" + "try:\n runpy.run_path(sys.argv[0], run_name='__main__')\n" + "finally:\n" + " assert not any(name in sys.modules for name in ('hermes_cli.update_cmd', 'hermes_cli.update_receipt', 'hermes_cli.main'))\n" + " Path(os.environ['HERMES_HOME'], 'commands.json').write_text(json.dumps(calls))\n", + encoding="utf-8", + ) + env = {key: value for key, value in os.environ.items() + if not key.startswith(("HERMES_", "PYTHON", "UV_"))} + env.update(HOME=str(home), HERMES_HOME=str(home)) + result = subprocess.run([sys.executable, "-I", "-B", "-X", "utf8", str(program)], + env=env, capture_output=True, text=True, timeout=30) + assert result.returncode == int(spawn_fails or discarded), result.stdout + result.stderr + assert json.loads(result_path.read_text()) == {"serves_handled": True} + commands = json.loads((home / "commands.json").read_text()) + assert commands == [ + [sys.executable, str(root / "hermes"), "serve", "--host", "127.0.0.1", "--port", "8119"], + [sys.executable, str(root / "hermes"), "--profile", "ops team 日本", "dashboard", + "--host", "::1", "--port", "8120", "--no-open"], + ] + assert ("probe spawn failed" in result.stdout) is spawn_fails + + +def test_serve_resume_child_leaves_token_unhandled_when_imports_fail(tmp_path): + root = Path(__file__).resolve().parents[2] + context, result_path = tmp_path / "request.json", tmp_path / "result.json" + context.write_text(json.dumps({"root": str(tmp_path), "home": str(tmp_path), + "stopped_serves": {"pending": True, "entries": []}})) + env = {key: value for key, value in os.environ.items() + if not key.startswith(("HERMES_", "PYTHON", "UV_"))} + env.update(HOME=str(tmp_path), HERMES_HOME=str(tmp_path)) + # -S and an empty checkout make application imports genuinely unavailable; + # this is not a mocked exception or a path into the real update machinery. + result = subprocess.run( + [sys.executable, "-I", "-S", "-B", "-X", "utf8", str(root / "hermes_cli/update_serve_resume.py"), + str(context), str(result_path)], env=env, capture_output=True, text=True, timeout=30, + ) + assert result.returncode == 1 + assert json.loads(result_path.read_text()) == {"serves_handled": False} + assert "Stopped serve recovery failed" in result.stderr + + +def test_bootstrap_lock_remains_live_without_application_dependencies(tmp_path): + root = Path(__file__).resolve().parents[2] + script = ( + "import os, sys\nfrom pathlib import Path\n" + f"sys.path.insert(0, {str(root)!r})\n" + "from hermes_cli.update_lock import UpdateLock\n" + f"path = Path({str(tmp_path / 'lock')!r})\n" + "first = UpdateLock(path=path)\nassert first.acquire()\n" + "second = UpdateLock(path=path)\nassert not second.acquire(), 'live lock was stolen'\n" + "assert second.holder.pid == os.getpid()\n" + "first.release()\n" + ) + result = subprocess.run([sys.executable, "-I", "-S", "-B", "-c", script], + capture_output=True, text=True, timeout=30) + assert result.returncode == 0, result.stdout + result.stderr diff --git a/tests/hermes_cli/test_plugin_admission_transaction.py b/tests/hermes_cli/test_plugin_admission_transaction.py deleted file mode 100644 index 9ede2f1d41..0000000000 --- a/tests/hermes_cli/test_plugin_admission_transaction.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Installing a disabled plugin must not alter the selected dependency environment.""" -from unittest.mock import Mock - -import pytest - -from hermes_cli import plugins_cmd as plugins - - -def test_install_disabled_does_not_prepare_dependencies(tmp_path, monkeypatch): - target = tmp_path / "plug" - target.mkdir() - manifest = {"name": "plug", "python_dependencies": ["new-package>=1,<2"]} - monkeypatch.setattr(plugins, "_install_plugin_core", lambda *a, **k: (target, manifest, "plug")) - monkeypatch.setattr(plugins, "_looks_like_plugin_dir", lambda *_: True) - monkeypatch.setattr(plugins, "_prompt_plugin_env_vars", lambda *a: None) - monkeypatch.setattr(plugins, "_display_after_install", lambda *a: None) - monkeypatch.setattr(plugins, "_declared_capabilities_from_manifest", lambda *a: []) - def forbidden(*a, **k): - raise AssertionError("disabled installation changed dependencies") - monkeypatch.setattr(plugins, "_install_plugin_python_deps", forbidden) - plugins.cmd_install("https://example.invalid/owner/plug", enable=False) diff --git a/tests/hermes_cli/test_plugin_install_ref.py b/tests/hermes_cli/test_plugin_install_ref.py index 63b11540c4..089888bcdd 100644 --- a/tests/hermes_cli/test_plugin_install_ref.py +++ b/tests/hermes_cli/test_plugin_install_ref.py @@ -308,17 +308,20 @@ def test_checkout_mismatch_is_rejected(monkeypatch, tmp_path): def test_metadata_write_failure_rolls_back_new_install(monkeypatch, tmp_path): - from hermes_cli.plugins_cmd import _install_plugin_core + from hermes_cli.plugins_cmd import _install_plugin_core, PluginOperationError + from hermes_cli import runtime_state repo, old_sha, _new_sha = _plugin_repo(tmp_path) home = tmp_path / "home" monkeypatch.setenv("HERMES_HOME", str(home)) - monkeypatch.setattr( - "hermes_cli.plugins_cmd._write_install_metadata", - lambda _metadata: (_ for _ in ()).throw(OSError("disk full")), - ) + atomic_bytes = runtime_state._atomic_bytes + def fail_metadata(path, data): + if path == home / "plugins/.install-metadata.json": + raise OSError("disk full") + return atomic_bytes(path, data) + monkeypatch.setattr(runtime_state, "_atomic_bytes", fail_metadata) - with pytest.raises(OSError, match="disk full"): + with pytest.raises(PluginOperationError, match="disk full"): _install_plugin_core(repo.as_uri(), force=False, ref=old_sha) assert not (home / "plugins" / "demo").exists() diff --git a/tests/hermes_cli/test_plugin_packs.py b/tests/hermes_cli/test_plugin_packs.py index d1609056ac..e39b310e68 100644 --- a/tests/hermes_cli/test_plugin_packs.py +++ b/tests/hermes_cli/test_plugin_packs.py @@ -22,7 +22,6 @@ from hermes_cli.plugin_packs import ( _sanitized_entry_config as real_sanitized_entry_config, cmd_pack_install, export_pack, - install_pack_plugins, load_pack, parse_pack, resolve_pack_plugins, @@ -232,149 +231,6 @@ def _resolved(pack): ] -def _fanout_patches(install_side_effect, consent_mock=None): - """Patch the plugins_cmd seams install_pack_plugins pulls in.""" - patches = { - "_install_plugin_core": mock.MagicMock(side_effect=install_side_effect), - "_prompt_plugin_env_vars": mock.MagicMock(), - "_get_enabled_set": mock.MagicMock(return_value=set()), - "_get_disabled_set": mock.MagicMock(return_value=set()), - "_save_enabled_set": mock.MagicMock(), - "_save_disabled_set": mock.MagicMock(), - "_run_capability_consent": consent_mock or mock.MagicMock(return_value=True), - "_declared_capabilities_from_manifest": mock.MagicMock( - side_effect=lambda manifest, name: manifest.get("capabilities", []) - ), - } - return patches - - -def test_install_fan_out_passes_pinned_refs_to_installer(tmp_path): - pack = parse_pack( - yaml.safe_dump( - { - "name": "p", - "plugins": [ - {"repo": "o/a", "ref": SHA_A}, - {"repo": "o/b", "ref": SHA_B}, - ], - } - ) - ) - installer = mock.MagicMock( - side_effect=[ - (tmp_path / "a", {"name": "a"}, "a"), - (tmp_path / "b", {"name": "b"}, "b"), - ] - ) - patches = _fanout_patches(None) - patches["_install_plugin_core"] = installer - with mock.patch.multiple("hermes_cli.plugins_cmd", **patches): - results = install_pack_plugins(pack, _resolved(pack), FakeConsole()) - - assert [r.ok for r in results] == [True, True] - assert installer.call_args_list == [ - mock.call("o/a", force=False, ref=SHA_A), - mock.call("o/b", force=False, ref=SHA_B), - ] - - -def test_install_fan_out_invokes_capability_consent_per_plugin(tmp_path): - """Consent is NOT bypassed: the standard per-plugin consent function - runs once for every installed plugin that declares capabilities.""" - pack = parse_pack( - yaml.safe_dump( - { - "name": "p", - "plugins": [ - {"repo": "o/a", "ref": SHA_A}, - {"repo": "o/b", "ref": SHA_B}, - ], - } - ) - ) - consent = mock.MagicMock(return_value=True) - patches = _fanout_patches( - [ - (tmp_path / "a", {"name": "a", "capabilities": ["tools"]}, "a"), - (tmp_path / "b", {"name": "b", "capabilities": ["platform"]}, "b"), - ], - consent_mock=consent, - ) - with mock.patch.multiple("hermes_cli.plugins_cmd", **patches): - install_pack_plugins(pack, _resolved(pack), FakeConsole()) - - assert consent.call_count == 2 - called_ids = [c.args[1] for c in consent.call_args_list] - assert called_ids == ["a", "b"] - called_caps = [c.args[2] for c in consent.call_args_list] - assert called_caps == [["tools"], ["platform"]] - - -def test_install_fan_out_continues_past_failures_and_reports(): - from hermes_cli.plugins_cmd import PluginOperationError - - pack = parse_pack( - yaml.safe_dump( - { - "name": "p", - "plugins": [ - {"repo": "o/bad", "ref": SHA_A}, - {"repo": "o/good", "ref": SHA_B}, - ], - } - ) - ) - - def installer(identifier, *, force, ref): - if "bad" in identifier: - raise PluginOperationError("clone exploded") - return (mock.MagicMock(), {"name": "good"}, "good") - - patches = _fanout_patches(installer) - console = FakeConsole() - with mock.patch.multiple("hermes_cli.plugins_cmd", **patches): - results = install_pack_plugins(pack, _resolved(pack), console) - - assert [r.ok for r in results] == [False, True] - assert "clone exploded" in results[0].error - assert results[1].installed_name == "good" - - -def test_pack_install_exits_nonzero_on_partial_failure(tmp_path, monkeypatch): - pack_file = tmp_path / "pack.yaml" - pack_file.write_text( - yaml.safe_dump( - { - "name": "p", - "plugins": [ - {"repo": "o/bad", "ref": SHA_A}, - {"repo": "o/good", "ref": SHA_B}, - ], - } - ), - encoding="utf-8", - ) - from hermes_cli.plugins_cmd import PluginOperationError - - def installer(identifier, *, force, ref): - if "bad" in identifier: - raise PluginOperationError("boom") - return (mock.MagicMock(), {"name": "good"}, "good") - - fake_console = FakeConsole(answers=["y"]) - patches = _fanout_patches(installer) - monkeypatch.setattr("sys.stdin", mock.MagicMock(isatty=lambda: True)) - monkeypatch.setattr("sys.stdout", mock.MagicMock(isatty=lambda: True)) - with mock.patch.multiple("hermes_cli.plugins_cmd", **patches), mock.patch( - "rich.console.Console", return_value=fake_console - ): - with pytest.raises(SystemExit) as exc: - cmd_pack_install(str(pack_file)) - assert exc.value.code == 1 - assert "1 installed, 1 failed" in fake_console.text - - def test_pack_install_refuses_noninteractive_sessions(tmp_path, monkeypatch): pack_file = tmp_path / "pack.yaml" pack_file.write_text(_pack_yaml(), encoding="utf-8") diff --git a/tests/hermes_cli/test_plugin_worker_lifecycle.py b/tests/hermes_cli/test_plugin_worker_lifecycle.py new file mode 100644 index 0000000000..5324dec75a --- /dev/null +++ b/tests/hermes_cli/test_plugin_worker_lifecycle.py @@ -0,0 +1,268 @@ +"""Command → real PM worker → offline wheels → persisted state → fresh imports.""" +from __future__ import annotations + +import pytest + +from tests.hermes_cli.plugin_worker_support import ( + plugin_world as plugin_world, + isolated_python as isolated_python, +) + + +@pytest.mark.parametrize("surface,suffix", [ + ("pyproject", "yaml"), ("python_dependencies", "yaml"), ("pip_dependencies", "yaml"), + ("python_dependencies", "yml"), ("pip_dependencies", "yml"), +]) +def test_declaration_consent_admission_and_resync(plugin_world, monkeypatch, capsys, surface, suffix): + from hermes_cli import plugins_cmd + from pm import client, receipt + + world = plugin_world + origin, revision = world.origin(surface=surface, suffix=suffix) + before = (world.home / "config.yaml").read_bytes() + monkeypatch.setattr(plugins_cmd.sys.stdin, "isatty", lambda: False) + world.command("install", identifier=origin.as_uri(), ref=revision, enable=True, allow_removed=True) + assert world.enabled() == [], "non-interactive Python admission bypassed consent" + assert (world.home / "config.yaml").read_bytes() == before + assert "skipped (non-interactive)" in " ".join(capsys.readouterr().out.split()) + + monkeypatch.setattr(plugins_cmd.sys.stdin, "isatty", lambda: True) + monkeypatch.setattr(plugins_cmd.sys.stdout, "isatty", lambda: True) + monkeypatch.setattr("builtins.input", lambda prompt: "no") + world.command("install", identifier=origin.as_uri(), force=True, enable=True, allow_removed=True) + assert world.enabled() == [] + assert (world.home / "config.yaml").read_bytes() == before + assert "declined" in capsys.readouterr().out + + monkeypatch.setattr("builtins.input", lambda prompt: "yes") + world.command("install", identifier=origin.as_uri(), force=True, enable=True, allow_removed=True) + assert world.enabled() == ["plugin-worker-proof"] + assert receipt.latest()["outcome"] == "ok" + world.imports() + selected = world.selected() + client.sync_venv(explicit=True) + assert world.selected() == selected + world.imports() + + declaration = world.home / "plugins/plugin-worker-proof" / ("pyproject.toml" if surface == "pyproject" else f"plugin.{suffix}") + declaration.write_text(declaration.read_text(encoding="utf-8").replace("==1.0", "==2.0"), encoding="utf-8") + client.sync_venv(explicit=True) + assert world.selected() != selected, "changed declaration was not stamped" + # Code has not changed: inspect the new dependency independently. + import os + import subprocess + python = world.selected() / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + result = subprocess.run([str(python), "-I", "-c", "import plugin_proof_dep; assert plugin_proof_dep.__version__ == '2.0'"], + capture_output=True, text=True, timeout=30) + assert result.returncode == 0, result.stderr + + +def test_active_force_reinstall_decline_preserves_entire_selection(plugin_world, monkeypatch): + from hermes_cli import plugins_cmd + from pm import paths + from tests.hermes_cli.plugin_worker_support import git, version + + world = plugin_world + origin, first = world.origin() + monkeypatch.setattr(plugins_cmd.sys.stdin, "isatty", lambda: True) + monkeypatch.setattr(plugins_cmd.sys.stdout, "isatty", lambda: True) + answers = [] + monkeypatch.setattr("builtins.input", lambda prompt: answers.append(prompt) or "yes") + world.command("install", identifier=origin.as_uri(), ref=first, enable=True, allow_removed=True) + world.imports() + target = world.home / "plugins/plugin-worker-proof" + watched = [world.home / "config.yaml", target / "__init__.py", target / "plugin.yaml", + target.parent / ".install-metadata.json", paths.runtime_facts_path()] + before = {path: path.read_bytes() for path in watched} + selected = world.selected() + second = version(origin, pin="2.0") + monkeypatch.setattr("builtins.input", lambda prompt: answers.append(prompt) or "no") + try: + world.command("install", identifier=origin.as_uri(), ref=second, force=True, enable=True, allow_removed=True) + except SystemExit as exc: + assert exc.code == 1 + assert {path: path.read_bytes() for path in watched} == before + assert git(target, "rev-parse", "HEAD") == first + assert world.selected() == selected + world.imports() + + answers.clear() + monkeypatch.setattr("builtins.input", lambda prompt: answers.append(prompt) or "yes") + world.command("install", identifier=origin.as_uri(), ref=second, force=True, enable=True, allow_removed=True) + assert len(answers) == 1, "active replacement asked for the same consent twice" + assert git(target, "rev-parse", "HEAD") == second + assert world.selected() != selected + world.imports(pin="2.0") + + +@pytest.mark.parametrize("flag", ["no_enable", "enable"]) +def test_active_reinstall_does_not_overwrite_a_later_disable(plugin_world, monkeypatch, flag): + from hermes_cli import plugins_cmd + from pm import paths + from tests.hermes_cli.plugin_worker_support import version + + world = plugin_world + origin, first = world.origin() + monkeypatch.setattr(plugins_cmd.sys.stdin, "isatty", lambda: True) + monkeypatch.setattr(plugins_cmd.sys.stdout, "isatty", lambda: True) + monkeypatch.setattr("builtins.input", lambda prompt: "yes") + world.command("install", identifier=origin.as_uri(), ref=first, enable=True, allow_removed=True) + second = version(origin, pin="2.0") + watched = [world.home / "config.yaml", paths.runtime_facts_path()] + acknowledged = {} + def disable_after_publication(*args): + world.command("disable", name="plugin-worker-proof") + acknowledged.update({path: path.read_bytes() for path in watched}) + monkeypatch.setattr(plugins_cmd, "_display_after_install", disable_after_publication) + world.command("install", identifier=origin.as_uri(), ref=second, force=True, allow_removed=True, **{flag: True}) + assert acknowledged + assert {path: path.read_bytes() for path in watched} == acknowledged + assert world.enabled() == [] + + +def test_malformed_portable_member_cannot_join_a_new_generation(plugin_world, monkeypatch): + import json + from hermes_cli import plugins_cmd + from hermes_cli.agent_plugins import PLUGIN_SCHEMA_V1 + from pm import client, paths + from tests.hermes_cli.plugin_worker_support import git + + world = plugin_world + origin, _ = world.origin(surface="pyproject") + (origin / "plugin.yaml").unlink() + (origin / "plugin.json").write_text(json.dumps({"$schema": PLUGIN_SCHEMA_V1, "name": "plugin-worker-proof"}), encoding="utf-8") + git(origin, "add", "--all") + git(origin, "commit", "-qm", "portable declaration") + monkeypatch.setattr(plugins_cmd.sys.stdin, "isatty", lambda: True) + monkeypatch.setattr(plugins_cmd.sys.stdout, "isatty", lambda: True) + monkeypatch.setattr("builtins.input", lambda prompt: "yes") + world.command("install", identifier=origin.as_uri(), enable=True, allow_removed=True) + world.imports() + watched = [world.home / "config.yaml", paths.runtime_facts_path()] + before = {path: path.read_bytes() for path in watched} + (world.home / "plugins/plugin-worker-proof/plugin.json").write_text("{malformed", encoding="utf-8") + with pytest.raises((ValueError, RuntimeError), match="plugin.json"): + client.sync_venv(explicit=True) + assert {path: path.read_bytes() for path in watched} == before + + +def test_pack_admits_compatible_members_and_reports_conflict(plugin_world, monkeypatch, capsys): + import json + import hermes_yaml as yaml + from hermes_cli import plugins_cmd + from tests.hermes_cli.plugin_worker_support import git, version + + world = plugin_world + incumbent, first = world.origin() + monkeypatch.setattr(plugins_cmd.sys.stdin, "isatty", lambda: True) + monkeypatch.setattr(plugins_cmd.sys.stdout, "isatty", lambda: True) + monkeypatch.setattr("builtins.input", lambda prompt: "yes") + world.command("install", identifier=incumbent.as_uri(), ref=first, enable=True, allow_removed=True) + world.imports() + good, good_sha = world.origin(name="pack-good", capabilities=["tools.override"]) + bad, bad_sha = world.origin(name="pack-conflict", pin="2.0") + last, last_sha = world.origin(name="pack-last", capabilities=["tools.override"]) + # Moving HEAD cannot move a pack's pinned selection. + version(good, name="pack-good", pin="2.0") + pack = world.root / "pack.yaml" + pack.write_text(yaml.safe_dump({"name": "proof-pack", "plugins": [ + {"repo": repo.as_uri(), "ref": sha} for repo, sha in [(good, good_sha), (bad, bad_sha), (last, last_sha)] + ], "config": {"pack-good": {"voice": "fixture"}}}), encoding="utf-8") + answers = iter(["yes", "yes", "no"]) + monkeypatch.setattr("rich.console.Console.input", lambda self, prompt: next(answers)) + with pytest.raises(SystemExit) as exc: + world.command("pack", pack_action="install", source=str(pack)) + assert exc.value.code == 1 + from rich.text import Text + assert "2 installed, 1 failed" in " ".join(Text.from_ansi(capsys.readouterr().out).plain.split()) + assert set(world.enabled()) == {"plugin-worker-proof", "pack-good", "pack-last"} + for name, sha in [("pack-good", good_sha), ("pack-conflict", bad_sha), ("pack-last", last_sha)]: + assert git(world.home / "plugins" / name, "rev-parse", "HEAD") == sha + for name in ("plugin-worker-proof", "pack-good", "pack-last"): + world.imports(name) + config = yaml.safe_load((world.home / "config.yaml").read_text(encoding="utf-8")) + entries = config["plugins"]["entries"] + assert entries["pack-good"]["voice"] == "fixture" + assert "tools.override" in entries["pack-good"]["granted_capabilities"] + assert "tools.override" not in entries.get("pack-last", {}).get("granted_capabilities", []) + receipts = [json.loads(path.read_text()) for path in (world.home / "logs/update_receipts").glob("pm_*.json")] + assert any(row["outcome"] == "failed" and "plugin-proof-dep" in json.dumps(row) for row in receipts) + + +@pytest.mark.parametrize("sibling_profiles", [False, True], ids=["same-profile", "sibling-profiles"]) +def test_concurrent_commands_keep_acknowledged_enables(plugin_world, sibling_profiles): + import queue + import threading + import os + from pathlib import Path + import shutil + import subprocess + import sys + import hermes_yaml as yaml + from hermes_constants import set_hermes_home_override, reset_hermes_home_override + from tests.hermes_cli.plugin_worker_support import worker_command + + world = plugin_world + homes = [world.home, world.home] + if sibling_profiles: + homes = [world.home / "profiles" / name for name in ("left", "right")] + for home in homes: + home.mkdir(parents=True) + (home / "config.yaml").write_text("plugins:\n enabled: []\n disabled: []\n", encoding="utf-8") + for name, home in zip(("race-left", "race-right"), homes): + origin, revision = world.origin(name=name, dependency="plugin-proof-dep" if name == "race-left" else "plugin-proof-other") + token = set_hermes_home_override(home) + try: + world.command("install", identifier=origin.as_uri(), ref=revision, no_enable=True, allow_removed=True) + finally: + reset_hermes_home_override(token) + untouched = (world.home / "config.yaml").read_bytes() + checkout = Path(__file__).resolve().parents[2] + command = worker_command(checkout / "pm/worker.py", shutil.which("uv"), sys.executable, + runtime_python=world.runtime_python) + processes = [] + try: + for name, home in zip(("race-left", "race-right"), homes): + script = ( + "import sys; from pathlib import Path; " + f"sys.path.insert(0, {str(checkout)!r}); from pm import client, paths; " + f"paths.repo_root = lambda: Path({str(world.core)!r}); " + f"paths.lockfile_path = lambda: Path({str(world.root / 'lock.json')!r})\n" + "def ready(worker, **kwargs):\n" + " print('READY', flush=True)\n" + " assert sys.stdin.readline().strip() == 'go'\n" + f" return {command!r}\n" + "client.runtime_command = ready\n" + "from hermes_cli.plugins_cmd import cmd_enable\n" + f"cmd_enable({name!r}, allow_tool_override=False)\n" + ) + processes.append(subprocess.Popen([sys.executable, "-I", "-B", "-c", script], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + env={**os.environ, "HERMES_HOME": str(home)})) + # Both commands have reached the worker launch (after the old stale + # read). Release one all the way through publication, then the other. + ready = queue.Queue() + for process in processes: + threading.Thread(target=lambda proc=process: ready.put(proc.stdout.readline().strip()), daemon=True).start() + assert [ready.get(timeout=30) for _ in processes] == ["READY", "READY"] + for process in processes: + out, err = process.communicate("go\n", timeout=60) + assert process.returncode == 0, out + err + if sibling_profiles: + assert (world.home / "config.yaml").read_bytes() == untouched + for name, home in zip(("race-left", "race-right"), homes): + assert yaml.safe_load((home / "config.yaml").read_text())["plugins"]["enabled"] == [name] + else: + assert set(world.enabled()) == {"race-left", "race-right"}, "acknowledged enable was lost" + for name, home in zip(("race-left", "race-right"), homes): + original = world.home + world.home = home + try: + world.imports(name) + finally: + world.home = original + finally: + for process in processes: + if process.poll() is None: + process.kill() + process.communicate(timeout=10) \ No newline at end of file diff --git a/tests/hermes_cli/test_plugins_admission_setter.py b/tests/hermes_cli/test_plugins_admission_setter.py index 21b1ad1e83..6a10dae660 100644 --- a/tests/hermes_cli/test_plugins_admission_setter.py +++ b/tests/hermes_cli/test_plugins_admission_setter.py @@ -1,263 +1,97 @@ -"""C13 enable admission: every UI path's proposed enabled/disabled sets go -through ONE authority — the candidate union resolves against the active -environment and the config commits inside pm's single locked transaction -(sync's before_publish hook). Refusal (dep conflict OR config-write -failure) publishes nothing: previous config bytes and previous environment -stay exactly in place. Real temp HERMES_HOME — no live user writes.""" - -from __future__ import annotations - -import json -from pathlib import Path -from types import SimpleNamespace +"""Publication failures through the command and real worker, not a fake sync.""" +import shutil +import sys import pytest -import hermes_cli.plugins_cmd as pc -from hermes_cli import plugins_admission as adm +from tests.hermes_cli.plugin_worker_support import ( + plugin_world as plugin_world, + worker_command, + isolated_python as isolated_python, +) -class _Console: - def __init__(self): - self.lines: list[str] = [] +@pytest.mark.parametrize("failure", ["config", "facts"]) +def test_failed_publication_preserves_selection_and_imports(plugin_world, monkeypatch, failure): + from hermes_cli import runtime_state + from hermes_cli.plugins_admission import AdmissionRefused + from pm import client, paths, receipt - def print(self, *args, **kwargs): - for arg in args: - self.lines.append(str(arg)) + world = plugin_world + origin, sha = world.origin() + world.command("install", identifier=origin.as_uri(), ref=sha, no_enable=True, allow_removed=True) + world.command("enable", name="plugin-worker-proof", no_allow_tool_override=True) + world.imports() + selected = world.selected() + origin, sha = world.origin(name="publication-candidate") + world.command("install", identifier=origin.as_uri(), ref=sha, no_enable=True, allow_removed=True) + config = world.home / "config.yaml" + watched = [config, paths.runtime_facts_path()] + before = {path: path.read_bytes() for path in watched} + if failure == "config": + atomic_bytes = runtime_state._atomic_bytes + def fail(path, data): + if path == config and b"publication-candidate" in data: + raise OSError("fixture config disk full") + return atomic_bytes(path, data) + monkeypatch.setattr(runtime_state, "_atomic_bytes", fail) + else: + # Fail on the worker's real disk-publication boundary, after proving + # the client callback has already changed config. Undo must cross the + # wire before the command can return failure. + prelude = ( + "from pm.lock import Facts\n" + "def fail(self, *args, **kwargs):\n" + f" assert 'publication-candidate' in Path({str(config)!r}).read_text()\n" + " raise OSError('fixture facts disk full')\n" + "Facts.record_state = fail\n" + ) + uv = shutil.which("uv") + assert uv + monkeypatch.setattr(client, "runtime_command", lambda path, **kw: + worker_command(path, uv, sys.executable, prelude=prelude, + runtime_python=world.runtime_python)) + with pytest.raises(AdmissionRefused, match=f"fixture {failure} disk full"): + world.command("enable", name="publication-candidate", no_allow_tool_override=True) + assert {path: path.read_bytes() for path in watched} == before + assert world.selected() == selected + world.imports() + assert receipt.latest()["outcome"] == "failed" -@pytest.fixture -def plugin_home(tmp_path, monkeypatch): - """Isolated HERMES_HOME: config.yaml, plugins/, facts all under tmp.""" - home = tmp_path / ".hermes" - home.mkdir() - monkeypatch.setenv("HERMES_HOME", str(home)) - (home / "plugins").mkdir() - return home +def test_historical_writer_hands_off_without_mutating_config(plugin_world, monkeypatch): + from hermes_cli.plugins_cmd import _save_enabled_set + + config = plugin_world.home / "config.yaml" + before = config.read_bytes() + def takeover(): + raise SystemExit(73) + monkeypatch.setattr("hermes_cli._old_updater.stop_for_relaunch", takeover) + with pytest.raises(SystemExit) as exc: + _save_enabled_set({"must-not-be-saved"}) + assert exc.value.code == 73 + assert config.read_bytes() == before -def _enabled_set(home) -> set: - from hermes_cli.config import load_config +@pytest.mark.parametrize("surface", ["dashboard", "composite"]) +def test_ui_conflict_is_reported_without_changing_selection(plugin_world, surface): + from hermes_cli import plugins_cmd + from hermes_cli.plugins_admission import AdmissionRefused + from pm import paths - cfg = load_config() - return set(((cfg.get("plugins") or {}).get("enabled")) or []) - - -def _write_sets(home, enabled=(), disabled=()): - pc._write_config_value("plugins", "enabled", sorted(enabled)) - pc._write_config_value("plugins", "disabled", sorted(disabled)) - - -@pytest.fixture -def sync_calls(monkeypatch): - """Stub the admission caller seam; real engine transactions are tested below.""" - from pm import client - calls = [] - - def _sync(extras=None, *, explicit=False, plugin_dirs=None, before_publish=None): - members = plugin_dirs() if callable(plugin_dirs) else plugin_dirs - calls.append(list(members or [])) - if before_publish is not None: - before_publish() # same contract: config commits under the sync - - monkeypatch.setattr(client, "sync_venv", _sync) - return calls - - -def _dep_plugin(home) -> Path: - d = home / "plugins" / "dep-plug" - d.mkdir(exist_ok=True) - (d / "plugin.yaml").write_text( - 'name: dep-plug\npython_dependencies:\n - "somepkg>=1"\n', encoding="utf-8" - ) - return d - - -def _entry(dir_path: Path): - return ("dep-plug", "1.0", "test plugin", "user", dir_path, "dep-plug") - - -def _discovery(monkeypatch, *entries): - monkeypatch.setattr(pc, "_discover_all_plugins", lambda: list(entries)) - monkeypatch.setattr(pc, "_declared_capabilities_for_key", lambda key: []) - - -def _refusing_sync(monkeypatch, message="uv lock exited 1: unsatisfiable"): - from pm import client - - def _boom(*a, **k): - raise RuntimeError(message) - - monkeypatch.setattr(client, "sync_venv", _boom) - - -# ── cmd_enable (CLI path) ──────────────────────────────────────────────────── - - -def test_enable_refusal_keeps_config(plugin_home, monkeypatch, sync_calls): - plug = _dep_plugin(plugin_home) - _discovery(monkeypatch, _entry(plug)) - _refusing_sync(monkeypatch) - console = _Console() - monkeypatch.setattr(pc, "_console", lambda: console) - with pytest.raises(adm.AdmissionRefused): - pc.cmd_enable("dep-plug", allow_tool_override=False) - assert _enabled_set(plugin_home) == set() # config untouched - assert any("refused" in line for line in console.lines) - - -def test_enable_success_commits_candidate_union_once(plugin_home, monkeypatch, sync_calls): - plug = _dep_plugin(plugin_home) - _discovery(monkeypatch, _entry(plug)) - pc.cmd_enable("dep-plug", allow_tool_override=False) - assert sync_calls == [[plug]] # exactly ONE sync: the candidate union - assert _enabled_set(plugin_home) == {"dep-plug"} - - -def test_enable_removal_filters_candidate(plugin_home, monkeypatch, sync_calls): - other = plugin_home / "plugins" / "other" - other.mkdir() - (other / "pyproject.toml").write_text("[project]\nname='o'\n", encoding="utf-8") - _write_sets(plugin_home, enabled=["other"]) - plug = _dep_plugin(plugin_home) - _discovery(monkeypatch, _entry(plug)) - pc.cmd_enable("dep-plug", allow_tool_override=False) - # proposed-sets driven: 'other' stays enabled (it IS in the candidate set) - assert sync_calls[-1] and set(sync_calls[-1]) == {other, plug} - assert _enabled_set(plugin_home) == {"other", "dep-plug"} - - -def test_deps_free_enable_does_not_churn_venv(plugin_home, monkeypatch, sync_calls): - plain = plugin_home / "plugins" / "plain" - plain.mkdir() - (plain / "plugin.yaml").write_text("name: plain\n", encoding="utf-8") - _discovery(monkeypatch, ("plain", "1.0", "p", "user", plain, "plain")) - pc.cmd_enable("plain", allow_tool_override=False) - assert sync_calls == [[]] # no members: cheap stamp check only - - -# ── composite UI + dashboard paths ────────────────────────────────────────── - - -def test_composite_selection_refusal_not_saved(plugin_home, monkeypatch, sync_calls): - plug = _dep_plugin(plugin_home) - _discovery(monkeypatch, _entry(plug)) - _refusing_sync(monkeypatch, "conflict") - with pytest.raises(adm.AdmissionRefused): - pc._persist_plugin_selection(["dep-plug"], {0}, set()) - assert _enabled_set(plugin_home) == set() - - -def test_dashboard_enable_refusal_reports_not_ok(plugin_home, monkeypatch, sync_calls): - plug = _dep_plugin(plugin_home) - monkeypatch.setattr(pc, "_resolve_plugin_key", lambda name: "dep-plug") - _refusing_sync(monkeypatch, "conflict") - result = pc.dashboard_set_agent_plugin_enabled("dep-plug", enabled=True) - assert result["ok"] is False - assert _enabled_set(plugin_home) == set() - - -# ── the before_publish hook: config commits under the lock, undo on facts failure ─ - - -def test_config_commit_undo_restores_previous_bytes(plugin_home): - _write_sets(plugin_home, enabled=["old"]) - config_path = plugin_home / "config.yaml" - previous = config_path.read_bytes() - - undo = adm._config_commit({"dep-plug"}, set()) - assert _enabled_set(plugin_home) == {"dep-plug"} # committed exactly once - undo() # facts write failed afterwards → restore - assert config_path.read_bytes() == previous - - -def test_facts_failure_triggers_undo_inside_one_transaction(plugin_home, monkeypatch): - """Real sync_venv: apply stages → before_publish commits config → facts - write fails → undo restores the previous config bytes atomically; the - receipt records the failure.""" - import importlib - import sys - - if "pm.ensure" not in sys.modules: - importlib.import_module("pm.ensure") - ensure = sys.modules["pm.ensure"] - - rdir = plugin_home / "receipts" - monkeypatch.setattr("pm.receipt._receipt_dir", lambda: rdir) - plug = _dep_plugin(plugin_home) - _write_sets(plugin_home, enabled=["old"]) - previous = (plugin_home / "config.yaml").read_bytes() - - monkeypatch.setattr(ensure, "_runtime_state_matches", lambda fact, stamp: False) - monkeypatch.setattr(ensure, "_facts", lambda: {}) - monkeypatch.setattr( - ensure, "get_package", - lambda name: SimpleNamespace( - expected_stamp=lambda enabled, **kw: "stamp1", apply=lambda enabled, **kw: {} - ), - ) - - order = [] - - def before_publish(): - order.append("before_publish") - return adm._config_commit({"dep-plug"}, set()) - - facts_path = plugin_home / "runtime" / "facts.json" - ensure.Facts(facts_path).record_state("venv", "previous-stamp", []) - previous_facts = facts_path.read_bytes() - monkeypatch.setattr(ensure.paths, "runtime_facts_path", lambda: facts_path) - monkeypatch.setattr(ensure.paths, "repo_root", lambda: plugin_home / "runtime") - - def fail_publication(self, *args, **kwargs): - order.append("record_state") - raise OSError("facts disk full") - - monkeypatch.setattr(ensure.Facts, "record_state", fail_publication) - with pytest.raises(OSError, match="facts disk full"): - ensure.sync_venv(explicit=True, plugin_dirs=[plug], before_publish=before_publish) - assert order == ["before_publish", "record_state"] # config committed under the lock first - assert (plugin_home / "config.yaml").read_bytes() == previous # undone atomically - assert facts_path.read_bytes() == previous_facts - latest = json.loads((rdir / "latest.json").read_text(encoding="utf-8-sig")) - assert latest["outcome"] == "failed" - - -# ── pm receipt invariant: every sync outcome writes a receipt ─────────────── - - -def test_lazy_refusal_writes_failed_receipt(plugin_home, monkeypatch): - import importlib - import sys - - if "pm.ensure" not in sys.modules: - importlib.import_module("pm.ensure") - ensure = sys.modules["pm.ensure"] - rdir = plugin_home / "receipts" - monkeypatch.setattr("pm.receipt._receipt_dir", lambda: rdir) - monkeypatch.setattr(ensure, "lazy_installs_allowed", lambda: False) - monkeypatch.setattr(ensure, "_runtime_state_matches", lambda fact, stamp: False) - with pytest.raises(Exception): - ensure.sync_venv(extras=["somepkg"]) - latest = json.loads((rdir / "latest.json").read_text(encoding="utf-8-sig")) - assert latest["kind"] == "sync" - assert latest["outcome"] == "failed" - - -def test_noop_sync_writes_ok_receipt_rebuild_false(plugin_home, monkeypatch): - import importlib - import sys - - if "pm.ensure" not in sys.modules: - importlib.import_module("pm.ensure") - ensure = sys.modules["pm.ensure"] - rdir = plugin_home / "receipts" - monkeypatch.setattr("pm.receipt._receipt_dir", lambda: rdir) - monkeypatch.setattr(ensure, "_runtime_state_matches", lambda fact, stamp: True) - committed = [] - ensure.sync_venv(extras=[], before_publish=lambda: committed.append(True)) - assert committed == [True], "unchanged dependencies must still commit a plugin selection" - latest = json.loads((rdir / "latest.json").read_text(encoding="utf-8-sig")) - assert latest["outcome"] == "ok" - assert latest["venv_rebuild"]["ok"] is False - assert latest["venv_rebuild"]["reason"] == "already in sync" + world = plugin_world + origin, sha = world.origin() + world.command("install", identifier=origin.as_uri(), ref=sha, no_enable=True, allow_removed=True) + world.command("enable", name="plugin-worker-proof", no_allow_tool_override=True) + origin, sha = world.origin(name="conflicting-ui-plugin", pin="2.0") + world.command("install", identifier=origin.as_uri(), ref=sha, no_enable=True, allow_removed=True) + watched = [world.home / "config.yaml", paths.runtime_facts_path()] + before = {path: path.read_bytes() for path in watched} + if surface == "dashboard": + result = plugins_cmd.dashboard_set_agent_plugin_enabled("conflicting-ui-plugin", enabled=True) + assert not result["ok"] and "plugin-proof-dep" in result["error"] + else: + with pytest.raises(AdmissionRefused, match="plugin-proof-dep"): + plugins_cmd._persist_plugin_selection(["plugin-worker-proof", "conflicting-ui-plugin"], {0, 1}, set()) + assert {path: path.read_bytes() for path in watched} == before + world.imports() \ No newline at end of file diff --git a/tests/hermes_cli/test_plugins_cmd.py b/tests/hermes_cli/test_plugins_cmd.py index 6308508748..406ad56d5e 100644 --- a/tests/hermes_cli/test_plugins_cmd.py +++ b/tests/hermes_cli/test_plugins_cmd.py @@ -398,30 +398,6 @@ class TestCmdInstall: class TestCmdUpdate: """Test the update command.""" - @patch("hermes_cli.plugins_cmd._sanitize_plugin_name") - @patch("hermes_cli.plugins_cmd._plugins_dir") - @patch("hermes_cli.plugins_cmd.subprocess.run") - def test_update_git_pull_success(self, mock_run, mock_plugins_dir, mock_sanitize): - from hermes_cli.plugins_cmd import cmd_update - - mock_plugins_dir_val = MagicMock() - mock_plugins_dir.return_value = mock_plugins_dir_val - mock_target = MagicMock() - mock_target.exists.return_value = True - mock_target.__truediv__ = lambda self, x: MagicMock( - exists=MagicMock(return_value=True) - ) - mock_sanitize.return_value = mock_target - - mock_run.side_effect = [ - MagicMock(returncode=0, stdout="", stderr=""), # status: clean - MagicMock(returncode=0, stdout="git@example.com:x.git", stderr=""), # remote get-url - MagicMock(returncode=0, stdout="Updated", stderr=""), # pull - ] - - cmd_update("test-plugin") - - assert mock_run.call_count == 3 @patch("hermes_cli.plugins_cmd._sanitize_plugin_name") @patch("hermes_cli.plugins_cmd._plugins_dir") @@ -447,20 +423,19 @@ class TestCmdUpdate: class TestCmdRemove: """Test the remove command.""" - @patch("hermes_cli.plugins_cmd._sanitize_plugin_name") - @patch("hermes_cli.plugins_cmd._plugins_dir") - @patch("hermes_cli.plugins_cmd.shutil.rmtree") - def test_remove_deletes_plugin(self, mock_rmtree, mock_plugins_dir, mock_sanitize): + def test_remove_deletes_only_the_requested_plugin(self, tmp_path, monkeypatch): from hermes_cli.plugins_cmd import cmd_remove - mock_plugins_dir.return_value = MagicMock() - mock_target = MagicMock() - mock_target.exists.return_value = True - mock_sanitize.return_value = mock_target - + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + target = tmp_path / "plugins/test-plugin" + target.mkdir(parents=True) + (target / "plugin.yaml").write_text("name: test-plugin\n", encoding="utf-8") + sibling = tmp_path / "plugins/keep/plugin.yaml" + sibling.parent.mkdir() + sibling.write_text("name: keep\n", encoding="utf-8") cmd_remove("test-plugin") - - mock_rmtree.assert_called_once_with(mock_target) + assert not target.exists() + assert sibling.read_text(encoding="utf-8") == "name: keep\n" @patch("hermes_cli.plugins_cmd._sanitize_plugin_name") @patch("hermes_cli.plugins_cmd._plugins_dir") @@ -480,40 +455,6 @@ class TestCmdRemove: assert exc_info.value.code == 1 -# ── cmd_list tests ───────────────────────────────────────────────────────── - - -class TestCmdList: - """Test the list command.""" - - @patch("hermes_cli.plugins_cmd._plugins_dir") - def test_list_empty_plugins_dir(self, mock_plugins_dir): - from hermes_cli.plugins_cmd import cmd_list - - mock_plugins_dir_val = MagicMock() - mock_plugins_dir_val.iterdir.return_value = [] - mock_plugins_dir.return_value = mock_plugins_dir_val - - cmd_list() - - @patch("hermes_cli.plugins_cmd._plugins_dir") - @patch("hermes_cli.plugins_cmd._read_manifest") - def test_list_with_plugins(self, mock_read_manifest, mock_plugins_dir): - from hermes_cli.plugins_cmd import cmd_list - - mock_plugins_dir_val = MagicMock() - mock_plugin_dir = MagicMock() - mock_plugin_dir.name = "test-plugin" - mock_plugin_dir.is_dir.return_value = True - mock_plugin_dir.__truediv__ = lambda self, x: MagicMock( - exists=MagicMock(return_value=False) - ) - mock_plugins_dir_val.iterdir.return_value = [mock_plugin_dir] - mock_plugins_dir.return_value = mock_plugins_dir_val - mock_read_manifest.return_value = {"name": "test-plugin", "version": "1.0.0"} - - cmd_list() - # ── _copy_example_files tests ───────────────────────────────────────────────── @@ -724,9 +665,8 @@ class TestSubdirInstallE2E: repo_root = tmp_path / "monorepo" self._make_repo_with_subdir_plugin(repo_root) - plugins_dir = tmp_path / "installed" - plugins_dir.mkdir() - monkeypatch.setattr(pc, "_plugins_dir", lambda: plugins_dir) + plugins_dir = tmp_path / "home/plugins" + monkeypatch.setenv("HERMES_HOME", str(plugins_dir.parent)) identifier = f"file://{repo_root}#my-plugin" target, manifest, name = pc._install_plugin_core(identifier, force=False) @@ -784,9 +724,8 @@ class TestSubdirInstallE2E: sp.run(["git", "init", "-q"], cwd=repo_root, check=True, env=env) sp.run(["git", "add", "-A"], cwd=repo_root, check=True, env=env) sp.run(["git", "commit", "-q", "-m", "init"], cwd=repo_root, check=True, env=env) - plugins_dir = tmp_path / "installed" - plugins_dir.mkdir() - monkeypatch.setattr(pc, "_plugins_dir", lambda: plugins_dir) + plugins_dir = tmp_path / "home/plugins" + monkeypatch.setenv("HERMES_HOME", str(plugins_dir.parent)) target, manifest, name = pc._install_plugin_core( f"file://{repo_root}", force=False diff --git a/tests/hermes_cli/test_plugins_cmd_deps_flow.py b/tests/hermes_cli/test_plugins_cmd_deps_flow.py deleted file mode 100644 index 2a11dc6734..0000000000 --- a/tests/hermes_cli/test_plugins_cmd_deps_flow.py +++ /dev/null @@ -1,154 +0,0 @@ -"""hermes plugins install: python-deps consent + try-resolve flow. - -Settled 2026-09-02 (.hermes/plans/2026-09-02_164500-plugin-deps-workspace- -union.md): install drops the folder, prompts y/n for declared python -deps, resolves through the pm workspace union, and ENABLES ONLY IF THE -DEPS RESOLVE — a conflicting plugin stays installed-but-disabled with -the resolver's reason. -""" - -from __future__ import annotations - -from pathlib import Path -from unittest.mock import patch - -import pytest - -import hermes_cli.plugins_cmd as pc - - -class _Console: - def __init__(self): - self.lines: list[str] = [] - - def print(self, *args, **kwargs): - for arg in args: - self.lines.append(str(arg)) - - -@pytest.fixture -def resolve_env(monkeypatch): - """Lazy installs on + no pre-existing enabled members, so the resolve - runs the would-be union with just this plugin.""" - import importlib - import sys - - if "pm.ensure" not in sys.modules: - importlib.import_module("pm.ensure") - ensure_mod = sys.modules["pm.ensure"] - monkeypatch.setattr(ensure_mod, "lazy_installs_allowed", lambda: True) - - -def _legacy_plugin(target: Path) -> None: - target.mkdir(parents=True, exist_ok=True) - (target / "plugin.yaml").write_text( - "name: dep-plug\npip_dependencies:\n - \"somepkg>=1,<2\"\n", - encoding="utf-8", - ) - - -def test_no_deps_short_circuits_true(tmp_path): - plug = tmp_path / "plain" - plug.mkdir() - (plug / "plugin.yaml").write_text("name: plain\n", encoding="utf-8") - ok, reason = pc._install_plugin_python_deps( - {"name": "plain", "python_dependencies": []}, plug, _Console() - ) - assert ok is True and reason is None - - -def test_noninteractive_skips_install(tmp_path, monkeypatch, resolve_env): - plug = tmp_path / "dep-plug" - _legacy_plugin(plug) - monkeypatch.setattr(pc.sys.stdin, "isatty", lambda: False, raising=False) - called = [] - monkeypatch.setattr( - "pm.client.sync_venv", lambda *a, **k: called.append(a) - ) - ok, reason = pc._install_plugin_python_deps( - {"name": "dep-plug", "python_dependencies": ["somepkg>=1,<2"]}, - plug, - _Console(), - ) - assert ok is False - assert "non-interactive" in reason - assert not called # nothing installed, nothing enabled - - -def test_decline_skips_install(tmp_path, monkeypatch, resolve_env): - plug = tmp_path / "dep-plug" - _legacy_plugin(plug) - monkeypatch.setattr(pc.sys.stdin, "isatty", lambda: True, raising=False) - monkeypatch.setattr(pc.sys.stdout, "isatty", lambda: True, raising=False) - monkeypatch.setattr("builtins.input", lambda *a: "n") - called = [] - monkeypatch.setattr( - "pm.client.sync_venv", lambda *a, **k: called.append(a) - ) - ok, reason = pc._install_plugin_python_deps( - {"name": "dep-plug", "python_dependencies": ["somepkg>=1,<2"]}, - plug, - _Console(), - ) - assert ok is False - assert "declined" in reason - assert not called - - -def test_conflict_surfaces_at_admission_not_consent(tmp_path, monkeypatch, resolve_env): - home = tmp_path / ".hermes" - (home / "plugins").mkdir(parents=True) - monkeypatch.setenv("HERMES_HOME", str(home)) - plug = tmp_path / "dep-plug" - _legacy_plugin(plug) - monkeypatch.setattr(pc.sys.stdin, "isatty", lambda: True, raising=False) - monkeypatch.setattr(pc.sys.stdout, "isatty", lambda: True, raising=False) - monkeypatch.setattr("builtins.input", lambda *a: "y") - - # Consent succeeds — resolution moved OUT of consent (C13): - ok, reason = pc._install_plugin_python_deps( - {"name": "dep-plug", "python_dependencies": ["somepkg==9.9.9"]}, - plug, - _Console(), - ) - assert ok is True and reason is None - - # The conflict surfaces when the enable COMMITS, with config untouched: - from pm import client - - def boom(*a, **k): - raise RuntimeError( - "uv lock exited 1: Because dep-plug depends on somepkg==9.9.9 " - "and hermes-agent depends on somepkg==1.0.0, unsatisfiable" - ) - - monkeypatch.setattr(client, "sync_venv", boom) - from hermes_cli import plugins_admission as adm - - with pytest.raises(adm.AdmissionRefused) as excinfo: - adm.admit_plugin_set_change( - {"dep-plug"}, set(), active_plugins_dir=home / "plugins", extra_dirs=[plug] - ) - assert "unsatisfiable" in str(excinfo.value) - from hermes_cli.config import load_config - - assert not ((load_config().get("plugins") or {}).get("enabled")) - - -def test_success_consents_without_plugin_dir_writes(tmp_path, monkeypatch, resolve_env): - plug = tmp_path / "dep-plug" - _legacy_plugin(plug) - monkeypatch.setattr(pc.sys.stdin, "isatty", lambda: True, raising=False) - monkeypatch.setattr(pc.sys.stdout, "isatty", lambda: True, raising=False) - monkeypatch.setattr("builtins.input", lambda *a: "y") - synced = [] - monkeypatch.setattr("pm.client.sync_venv", lambda *a, **k: synced.append(a)) - ok, reason = pc._install_plugin_python_deps( - {"name": "dep-plug", "python_dependencies": ["somepkg>=1,<2"]}, - plug, - _Console(), - ) - assert ok is True and reason is None - assert not synced # consent only — the resolve runs in the admission transaction - # no plugin-dir writes: legacy deps stage as virtual members in pm's workspace - assert not (plug / "pyproject.toml").exists() diff --git a/tests/hermes_cli/test_plugins_cmd_enable_disable_nested.py b/tests/hermes_cli/test_plugins_cmd_enable_disable_nested.py index e0d221873b..c6c72e470e 100644 --- a/tests/hermes_cli/test_plugins_cmd_enable_disable_nested.py +++ b/tests/hermes_cli/test_plugins_cmd_enable_disable_nested.py @@ -1,242 +1,90 @@ -"""Tests for nested/alias-normalized enable & disable flows. - -Companion to test_plugins_cmd_category_discovery.py. That file covers the -*listing* side of nested category plugins (issue #41066). These tests cover -the *mutation* side: `hermes plugins enable/disable` must resolve a bare name -OR a full path-derived key (e.g. `observability/trace_sink`) to the canonical -registry key and write THAT — the same string PluginManager gates on — so a -nested bundled plugin can actually be toggled. -""" - -import sys # noqa: F401 -from pathlib import Path -from unittest.mock import patch +"""Canonical keys and privilege consent through real admission.""" +import shutil import pytest +import hermes_yaml as yaml + +from tests.hermes_cli.plugin_worker_support import ( + plugin_world as plugin_world, + isolated_python as isolated_python, +) -@pytest.fixture(autouse=True) -def _capture_selection(monkeypatch): - import hermes_cli.plugins_cmd as pc - from hermes_cli import plugins_admission - def save(enabled, disabled, **kwargs): - pc._save_enabled_set(enabled) - pc._save_disabled_set(disabled) - monkeypatch.setattr(plugins_admission, "admit_plugin_set_change", save) +@pytest.mark.parametrize("query", ["trace-leaf", "trace-manifest", "observability/trace-leaf"]) +def test_nested_enable_disable_and_composite_use_canonical_key(plugin_world, monkeypatch, query): + from hermes_cli import plugins_cmd + from rich.console import Console + + world = plugin_world + origin, _ = world.origin(name="trace-manifest") + key = "observability/trace-leaf" + target = world.home / "plugins" / key + shutil.copytree(origin, target) + (world.home / "config.yaml").write_text(yaml.safe_dump({"plugins": { + "enabled": [], "disabled": [key, "trace-leaf", "trace-manifest"]}}), encoding="utf-8") + world.command("enable", name=query, no_allow_tool_override=True) + assert world.enabled() == [key] + config = yaml.safe_load((world.home / "config.yaml").read_text()) + assert config["plugins"]["disabled"] == [] + world.imports(key) + world.command("disable", name=query) + assert world.enabled() == [] + assert yaml.safe_load((world.home / "config.yaml").read_text())["plugins"]["disabled"] == [key] + # The fallback menu must persist canonical keys too, not labels. + monkeypatch.setattr("builtins.input", lambda prompt: "") + plugins_cmd._run_composite_fallback([key], ["trace-manifest"], {0}, {key}, [], Console()) + assert world.enabled() == [key] + world.imports(key) -def _make_plugin_dir(parent: Path, name: str, manifest: dict) -> Path: - d = parent / name - d.mkdir(parents=True, exist_ok=True) - import hermes_yaml as yaml - (d / "plugin.yaml").write_text(yaml.safe_dump(manifest), encoding="utf-8") - (d / "__init__.py").write_text("def register(ctx): pass\n", encoding="utf-8") - return d +def test_ambiguous_and_unknown_names_cannot_change_config(plugin_world): + from hermes_cli import plugins_cmd + + world = plugin_world + for category in ("image_gen", "model-providers"): + directory = world.home / "plugins" / category / "same-leaf" + directory.mkdir(parents=True) + (directory / "plugin.yaml").write_text(f"name: {category}-fixture\n", encoding="utf-8") + before = (world.home / "config.yaml").read_bytes() + for query in ("same-leaf", "not-installed"): + with pytest.raises(SystemExit) as exc: + world.command("enable", name=query) + assert exc.value.code == 1 + assert plugins_cmd._resolve_plugin_key("image_gen/same-leaf") == "image_gen/same-leaf" + assert (world.home / "config.yaml").read_bytes() == before -def _make_category_plugin(parent: Path, category: str, name: str, manifest: dict) -> Path: - return _make_plugin_dir(parent / category, name, manifest) +def test_dependency_free_enable_no_churn_and_tool_override_fails_closed(plugin_world, monkeypatch): + from hermes_cli import plugins_cmd + from pm import client, receipt + world = plugin_world + client.sync_venv(explicit=True) + selected = world.selected() + origin, sha = world.origin(surface=None) + facts = (world.home / "config.yaml").read_bytes() + world.command("install", identifier=origin.as_uri(), ref=sha, no_enable=True, allow_removed=True) + assert world.selected() == selected + assert (world.home / "config.yaml").read_bytes() == facts + monkeypatch.setattr(plugins_cmd.sys.stdin, "isatty", lambda: True) + monkeypatch.setattr(plugins_cmd.sys.stdout, "isatty", lambda: True) + def eof(*args, **kwargs): + raise EOFError + monkeypatch.setattr("rich.console.Console.input", eof) + world.command("enable", name="plugin-worker-proof") + config = yaml.safe_load((world.home / "config.yaml").read_text()) + assert config["plugins"]["entries"]["plugin-worker-proof"]["allow_tool_override"] is False + assert world.selected() == selected + assert receipt.latest()["venv_rebuild"]["ok"] is False -@pytest.fixture -def nested_plugin_env(tmp_path): - """A user-plugins dir containing one nested and one flat plugin, with the - bundled dir pointed at an empty path. Returns the tmp_path.""" - _make_category_plugin(tmp_path, "observability", "trace_sink", { - "name": "trace_sink", "version": "1.0.0", "description": "trace sink" - }) - _make_plugin_dir(tmp_path, "disk-cleanup", { - "name": "disk-cleanup", "version": "1.0.0" - }) - return tmp_path - - -# --------------------------------------------------------------------------- -# _resolve_plugin_key -# --------------------------------------------------------------------------- - - -class TestResolvePluginKey: - @patch("hermes_cli.plugins.get_bundled_plugins_dir") - @patch("hermes_cli.plugins_cmd._plugins_dir") - def test_full_key_resolves_to_itself(self, mock_user, mock_bundled, nested_plugin_env): - from hermes_cli.plugins_cmd import _resolve_plugin_key - mock_user.return_value = nested_plugin_env - mock_bundled.return_value = nested_plugin_env / "nonexistent" - assert _resolve_plugin_key("observability/trace_sink") == "observability/trace_sink" - - - @patch("hermes_cli.plugins.get_bundled_plugins_dir") - @patch("hermes_cli.plugins_cmd._plugins_dir") - def test_unknown_returns_none(self, mock_user, mock_bundled, nested_plugin_env): - from hermes_cli.plugins_cmd import _resolve_plugin_key - mock_user.return_value = nested_plugin_env - mock_bundled.return_value = nested_plugin_env / "nonexistent" - assert _resolve_plugin_key("does-not-exist") is None - - @patch("hermes_cli.plugins.get_bundled_plugins_dir") - @patch("hermes_cli.plugins_cmd._plugins_dir") - def test_ambiguous_leaf_name_returns_none(self, mock_user, mock_bundled, tmp_path): - """Same leaf name under two categories must NOT silently pick one.""" - from hermes_cli.plugins_cmd import _resolve_plugin_key - _make_category_plugin(tmp_path, "image_gen", "openai", {"name": "image-gen-openai"}) - _make_category_plugin(tmp_path, "model-providers", "openai", {"name": "mp-openai"}) - mock_user.return_value = tmp_path - mock_bundled.return_value = tmp_path / "nonexistent" - # Bare "openai" is ambiguous -> None; the full key still resolves. - assert _resolve_plugin_key("openai") is None - assert _resolve_plugin_key("image_gen/openai") == "image_gen/openai" - - -# --------------------------------------------------------------------------- -# cmd_enable / cmd_disable — write the canonical key -# --------------------------------------------------------------------------- - - -class TestEnableDisableNested: - @patch("hermes_cli.plugins.get_bundled_plugins_dir") - @patch("hermes_cli.plugins_cmd._plugins_dir") - @patch("hermes_cli.plugins_cmd._save_disabled_set") - @patch("hermes_cli.plugins_cmd._save_enabled_set") - @patch("hermes_cli.plugins_cmd._get_disabled_set", return_value=set()) - @patch("hermes_cli.plugins_cmd._get_enabled_set", return_value=set()) - def test_enable_bare_name_writes_key( - self, mock_en, mock_dis, mock_save_en, mock_save_dis, - mock_user, mock_bundled, nested_plugin_env, - ): - from hermes_cli.plugins_cmd import cmd_enable - mock_user.return_value = nested_plugin_env - mock_bundled.return_value = nested_plugin_env / "nonexistent" - - cmd_enable("trace_sink", allow_tool_override=False) # bare name - - saved = mock_save_en.call_args[0][0] - # The canonical key — NOT the bare name — must be persisted, because - # that is what PluginManager matches when deciding to load. - assert "observability/trace_sink" in saved - assert "trace_sink" not in saved or "observability/trace_sink" in saved - - - @patch("hermes_cli.plugins.get_bundled_plugins_dir") - @patch("hermes_cli.plugins_cmd._plugins_dir") - def test_enable_unknown_plugin_exits(self, mock_user, mock_bundled, nested_plugin_env): - from hermes_cli.plugins_cmd import cmd_enable - mock_user.return_value = nested_plugin_env - mock_bundled.return_value = nested_plugin_env / "nonexistent" - with pytest.raises(SystemExit): - cmd_enable("does-not-exist") - - @patch("hermes_cli.plugins.get_bundled_plugins_dir") - @patch("hermes_cli.plugins_cmd._plugins_dir") - @patch("hermes_cli.plugins_cmd._save_disabled_set") - @patch("hermes_cli.plugins_cmd._save_enabled_set") - @patch("hermes_cli.plugins_cmd._get_disabled_set", return_value=set()) - @patch("hermes_cli.plugins_cmd._get_enabled_set", return_value=set()) - def test_enable_flat_plugin_unchanged( - self, mock_en, mock_dis, mock_save_en, mock_save_dis, - mock_user, mock_bundled, nested_plugin_env, - ): - """Flat plugins keep writing their bare name (key == name) — no regression.""" - from hermes_cli.plugins_cmd import cmd_enable - mock_user.return_value = nested_plugin_env - mock_bundled.return_value = nested_plugin_env / "nonexistent" - - cmd_enable("disk-cleanup", allow_tool_override=False) - saved = mock_save_en.call_args[0][0] - assert "disk-cleanup" in saved - - -# --------------------------------------------------------------------------- -# cmd_enable — built-in tool override consent (issue #29249) -# --------------------------------------------------------------------------- - - -class TestEnableToolOverrideConsent: - """Enabling a non-bundled plugin must surface a consent decision about the - privileged ``allow_tool_override`` capability, and persist the operator's - choice under ``plugins.entries..allow_tool_override``.""" - - - @patch("hermes_cli.plugins.get_bundled_plugins_dir") - @patch("hermes_cli.plugins_cmd._plugins_dir") - @patch("hermes_cli.plugins_cmd._set_plugin_entry_flag") - @patch("hermes_cli.plugins_cmd._save_disabled_set") - @patch("hermes_cli.plugins_cmd._save_enabled_set") - @patch("hermes_cli.plugins_cmd._get_disabled_set", return_value=set()) - @patch("hermes_cli.plugins_cmd._get_enabled_set", return_value=set()) - def test_interactive_eof_defaults_to_deny( - self, mock_en, mock_dis, mock_save_en, mock_save_dis, mock_set_flag, - mock_user, mock_bundled, nested_plugin_env, - ): - """Non-interactive stdin (EOFError) must fail closed to deny.""" - from hermes_cli.plugins_cmd import cmd_enable - mock_user.return_value = nested_plugin_env - mock_bundled.return_value = nested_plugin_env / "nonexistent" - - with patch("rich.console.Console.input", side_effect=EOFError): - cmd_enable("disk-cleanup") - - mock_set_flag.assert_called_once_with( - "disk-cleanup", "allow_tool_override", False - ) - - @patch("hermes_cli.plugins.get_bundled_plugins_dir") - @patch("hermes_cli.plugins_cmd._plugins_dir") - @patch("hermes_cli.plugins_cmd._set_plugin_entry_flag") - @patch("hermes_cli.plugins_cmd._save_disabled_set") - @patch("hermes_cli.plugins_cmd._save_enabled_set") - @patch("hermes_cli.plugins_cmd._get_disabled_set", return_value=set()) - @patch("hermes_cli.plugins_cmd._get_enabled_set", return_value=set()) - def test_bundled_plugin_never_prompts_or_writes_entry( - self, mock_en, mock_dis, mock_save_en, mock_save_dis, mock_set_flag, - mock_user, mock_bundled, tmp_path, - ): - """Bundled plugins are trusted — no consent prompt, no entry write.""" - from hermes_cli.plugins_cmd import cmd_enable - # Bundled dir holds the plugin; user dir is empty. - _make_plugin_dir(tmp_path / "bundled", "trusted_bundled", { - "name": "trusted_bundled", "version": "1.0.0", - }) - mock_user.return_value = tmp_path / "empty" - mock_bundled.return_value = tmp_path / "bundled" - - # Console.input would raise if called — proving no prompt fired. - with patch("rich.console.Console.input", side_effect=AssertionError("prompted")): - cmd_enable("trusted_bundled") - - mock_set_flag.assert_not_called() - - -class TestCompositeMenuWritesCanonicalKey: - """#40190 follow-up: the interactive `hermes plugins` menu must persist - the CANONICAL KEY (``web/firecrawl``), never the bare manifest name - (``web-firecrawl``), so its disabled-list entries stay aligned with what - ``cmd_enable`` clears and what PluginManager gates on. Writing the bare - name is what silently vetoed a bundled backend forever (pi314). - """ - - @patch("hermes_cli.plugins_cmd._save_disabled_set") - @patch("hermes_cli.plugins_cmd._save_enabled_set") - @patch("hermes_cli.plugins_cmd._get_enabled_set", return_value=set()) - def test_fallback_unchecked_plugin_disables_by_key_not_name( - self, mock_en, mock_save_en, mock_save_dis, - ): - from hermes_cli.plugins_cmd import _run_composite_fallback - from rich.console import Console - - # key differs from the manifest name, mirroring web/firecrawl. - plugin_keys = ["web/firecrawl"] - plugin_labels = ["web-firecrawl — firecrawl [bundled]"] - plugin_selected = set() # unchecked → should be disabled - - # First input() toggles nothing (blank Enter confirms immediately), - # second (category prompt) is skipped with blank Enter. - with patch("builtins.input", return_value=""): - _run_composite_fallback( - plugin_keys, plugin_labels, plugin_selected, - set(), [], Console(), - ) - - saved_dis = mock_save_dis.call_args[0][0] - assert "web/firecrawl" in saved_dis # canonical key persisted - assert "web-firecrawl" not in saved_dis # never the bare name + bundled = world.core / "plugins/trusted-fixture" + bundled.mkdir(parents=True) + (bundled / "plugin.yaml").write_text("name: trusted-fixture\n", encoding="utf-8") + monkeypatch.setattr("hermes_cli.plugins.get_bundled_plugins_dir", lambda: world.core / "plugins") + def unexpected(*args, **kwargs): + pytest.fail("bundled plugin requested privilege consent") + monkeypatch.setattr("rich.console.Console.input", unexpected) + world.command("enable", name="trusted-fixture") + config = yaml.safe_load((world.home / "config.yaml").read_text()) + assert "trusted-fixture" not in config["plugins"]["entries"] + assert world.selected() == selected \ No newline at end of file diff --git a/tests/hermes_cli/test_relay_plugin_cutover.py b/tests/hermes_cli/test_relay_plugin_cutover.py index 351c6f510b..97ef74bf0f 100644 --- a/tests/hermes_cli/test_relay_plugin_cutover.py +++ b/tests/hermes_cli/test_relay_plugin_cutover.py @@ -86,7 +86,7 @@ def test_doctor_reports_legacy_exporter_env_without_new_config(monkeypatch): def test_enable_rejects_removed_relay_plugin_without_discovery(name, capsys): with ( patch("hermes_cli.plugins_cmd._resolve_plugin_key_and_source") as resolve, - patch("hermes_cli.plugins_cmd._save_enabled_set") as save_enabled, + patch("hermes_cli.plugins_cmd._set_plugin_enabled") as save_enabled, ): from hermes_cli.plugins_cmd import cmd_enable @@ -107,7 +107,7 @@ def test_enable_rejects_alias_resolving_to_removed_relay_plugin(capsys): "hermes_cli.plugins_cmd._resolve_plugin_key_and_source", return_value=("observability/nemo_relay", "user"), ), - patch("hermes_cli.plugins_cmd._save_enabled_set") as save_enabled, + patch("hermes_cli.plugins_cmd._set_plugin_enabled") as save_enabled, ): from hermes_cli.plugins_cmd import cmd_enable diff --git a/tests/hermes_cli/test_scan_venv_blockers.py b/tests/hermes_cli/test_scan_venv_blockers.py index a21f877b98..5058e7d459 100644 --- a/tests/hermes_cli/test_scan_venv_blockers.py +++ b/tests/hermes_cli/test_scan_venv_blockers.py @@ -1,198 +1,17 @@ -"""Tests for hermes_cli/_scan_venv_blockers.py. - -Tests call the real production functions (``main``, ``_redact_sensitive_cmdline``). -The detector is patched directly so no real process table interaction occurs. -""" +"""The retired scanner's historical import and desktop CLI contracts.""" from __future__ import annotations import builtins import json +import os +import subprocess import sys -import types from pathlib import Path -from unittest.mock import MagicMock, patch import pytest -import agent.redact as redact_module -from hermes_cli._scan_venv_blockers import ( - _classify_local_preview_args, - _is_pausable_gateway, - _probe_fail_json, - _redact_sensitive_cmdline, - _terminate_safe_preview, - main, -) - - -# --------------------------------------------------------------------------- -# main() — stdout, stderr, exit code (with patched detector) -# --------------------------------------------------------------------------- - - -def _psutil_fake() -> dict: - """Return a sys.modules dict entry that makes psutil appear available.""" - return {"psutil": types.SimpleNamespace(Process=lambda *a: MagicMock())} - - - - - - -# --------------------------------------------------------------------------- -# _redact_sensitive_cmdline -# --------------------------------------------------------------------------- - - -def test_redact_long_flag_value_space_separated() -> None: - """--token SECRET must preserve --token and emit --token .""" - raw = "python.exe -m hermes_cli.main serve --token ghp_abc123 --host 10.0.0.1" - result = _redact_sensitive_cmdline(raw) - assert result == "python.exe -m hermes_cli.main serve --token " - assert "ghp_abc123" not in result - - - - -def test_redact_sensitive_text_failure_returns_fully_redacted() -> None: - """When agent.redact.redact_sensitive_text raises, the entire result - must equal '' so PID and name still provide diagnostics.""" - with patch.object( - redact_module, - "redact_sensitive_text", - side_effect=RuntimeError("no redactor"), - ): - result = _redact_sensitive_cmdline("python.exe --token abc123") - - assert result == "" - - -def test_redact_session_key() -> None: - """--session-key must redact the value and everything after.""" - raw = "python.exe -m tui_gateway.slash_worker --session-key 20260712-abcdef --model test" - result = _redact_sensitive_cmdline(raw) - assert result == "python.exe -m tui_gateway.slash_worker --session-key " - - -def test_redact_normal_host_port_profile_remain() -> None: - raw = "python.exe -m hermes_cli.main serve --host 10.0.0.1 --port 9119 --profile work" - result = _redact_sensitive_cmdline(raw) - assert "10.0.0.1" in result - assert "9119" in result - assert "work" in result - - -def test_redact_no_sensitive_flags_is_noop() -> None: - raw = "python.exe -m hermes_cli.main serve --host 127.0.0.1" - assert _redact_sensitive_cmdline(raw) == raw - - -def test_redact_empty_string() -> None: - assert _redact_sensitive_cmdline("") == "" - - -def test_redact_short_flags_not_redacted() -> None: - """Short flags -t (toolset), -p (profile), -k are NOT redacted.""" - raw = "python.exe -m hermes_cli.main serve -t web -p default -k somearg" - result = _redact_sensitive_cmdline(raw) - assert result == raw # short flags pass through unchanged - - -def test_classify_local_preview_args_preserves_full_directory_label_and_port() -> None: - args = [ - r"C:\Hermes\venv\Scripts\python.exe", - "-m", - "http.server", - "8766", - "--bind", - "0.0.0.0", - "--directory", - r"C:\Projects\Example Preview", - ] - - assert _classify_local_preview_args(args) == { - "kind": "local-preview", - "safeToStop": True, - "label": "Example Preview", - "port": 8766, - } - - -def test_classify_local_preview_args_rejects_arbitrary_python_process() -> None: - assert _classify_local_preview_args(["python.exe", "important-script.py"]) == {} - - -def test_classify_local_preview_args_rejects_module_flags_passed_to_a_script() -> None: - assert _classify_local_preview_args( - ["python.exe", "important-script.py", "-m", "http.server", "8765"] - ) == {} - - -def test_terminate_safe_preview_revalidates_identity_and_exact_argv() -> None: - class FakeProcess: - def __init__(self, pid: int, *, created: float, args: list[str]) -> None: - self.pid = pid - self._created = created - self._args = args - self.terminated = False - self.killed = False - self._children: list[FakeProcess] = [] - - def create_time(self) -> float: - return self._created - - def cmdline(self) -> list[str]: - return self._args - - def children(self, *, recursive: bool) -> list[FakeProcess]: - assert recursive is True - return self._children - - def terminate(self) -> None: - self.terminated = True - - def kill(self) -> None: - self.killed = True - - child = FakeProcess(200, created=10.0, args=["child.exe"]) - parent = FakeProcess(100, created=1722798000.25, args=["python.exe", "-m", "http.server", "8766"]) - parent._children = [child] - fake_psutil = types.SimpleNamespace( - Process=lambda pid: parent if pid == 100 else child, - wait_procs=lambda processes, timeout: (processes, []), - ) - - stopped, error = _terminate_safe_preview(100, 1722798000.25, psutil_module=fake_psutil) - - assert stopped is True - assert error is None - assert parent.terminated is True - assert child.terminated is True - - -def test_terminate_safe_preview_refuses_reused_pid() -> None: - process = MagicMock() - process.create_time.return_value = 1722798999.0 - fake_psutil = types.SimpleNamespace(Process=lambda _pid: process) - - stopped, error = _terminate_safe_preview(100, 1722798000.25, psutil_module=fake_psutil) - - assert stopped is False - assert error == "process identity changed" - process.cmdline.assert_not_called() - process.terminate.assert_not_called() - - -# --------------------------------------------------------------------------- -# _is_pausable_gateway — the gateway exemption -# -# `hermes-setup` always invokes `hermes update --yes --gateway`, whose -# `_pause_windows_gateways_for_update()` stops running gateways itself. The -# Desktop preflight must therefore not report gateway launcher/worker chains -# as blockers — doing so aborts the handoff before the component that can -# handle them ever runs. -# --------------------------------------------------------------------------- +from hermes_cli._scan_venv_blockers import _is_pausable_gateway @pytest.mark.parametrize( @@ -206,13 +25,11 @@ def test_terminate_safe_preview_refuses_reused_pid() -> None: " -m hermes_cli.main gateway run --replace", # profile-scoped gateway "python.exe -m hermes_cli.main --profile work gateway run", - # a profile literally NAMED "gateway" — the profile value must not - # shadow the subcommand token (the hand-rolled matcher regressed this) + # A profile named gateway must not shadow the subcommand token. "python.exe -m hermes_cli.main --profile gateway gateway run", "python.exe -m hermes_cli.main -p gateway gateway run", - # bare `gateway` defaults to `run` (mirrors the canonical matcher) + # bare gateway defaults to run "python.exe -m hermes_cli.main gateway", - # case variations survive "PYTHON.EXE -m hermes_cli.main GATEWAY RUN", ], ) @@ -223,15 +40,13 @@ def test_is_pausable_gateway_accepts_gateway_run_chains(cmdline: str) -> None: @pytest.mark.parametrize( "cmdline", [ - # desktop backend: no pause machinery downstream, must keep blocking + # Desktop backends are not messaging gateways. "python.exe -m hermes_cli.main serve --host 127.0.0.1 --port 8756", - # other gateway subcommands are not running gateways "python.exe -m hermes_cli.main gateway stop", "python.exe -m hermes_cli.main gateway status", "python.exe -m hermes_cli.main gateway install", - # operator REPL / stray script "python.exe", - "python.exe myscript.py gateway run", # not a hermes_cli.main invocation + "python.exe myscript.py gateway run", "", ], ) @@ -239,254 +54,53 @@ def test_is_pausable_gateway_rejects_everything_else(cmdline: str) -> None: assert _is_pausable_gateway(cmdline) is False -def _run_main_with_detector(monkeypatch, capsys, matches): - """Run main() with the process detector patched to return *matches*.""" - for name, mod in _psutil_fake().items(): - monkeypatch.setitem(sys.modules, name, mod) - import hermes_cli.update_cmd as update_cmd - - monkeypatch.setattr(update_cmd, "_detect_venv_python_processes", lambda: matches) - with pytest.raises(SystemExit) as excinfo: - main() - out = capsys.readouterr().out - return excinfo.value.code, json.loads(out) - - -def test_probe_fail_json_is_unambiguous_failure() -> None: - """A failed probe must not look like a clear scan (#83149). - - Humans and naive callers used to read ``blocked: false`` as "no holders" - when psutil was missing after a gutted venv. The document must mark - ``probe_failed`` and keep ``ok`` false. - """ - data = json.loads(_probe_fail_json("psutil is not available: No module named 'psutil'")) - assert data["ok"] is False - assert data["probe_failed"] is True - assert data["blocked"] is False - assert data["processes"] == [] - assert "psutil" in data["error"] - - -def test_main_psutil_missing_is_probe_failure_not_clear(monkeypatch, capsys): - """Missing psutil exits non-zero with probe_failed JSON — never a clear scan.""" +def test_is_pausable_gateway_import_failure_fails_closed(monkeypatch): + """An old updater can import this helper on a partially replaced tree.""" real_import = builtins.__import__ - def _no_psutil(name, *args, **kwargs): - if name == "psutil" or name.startswith("psutil."): - raise ImportError("No module named 'psutil'") + def unavailable(name, *args, **kwargs): + if name == "gateway.status": + raise ImportError("gateway unavailable during checkout swap") return real_import(name, *args, **kwargs) - monkeypatch.setattr(builtins, "__import__", _no_psutil) - monkeypatch.delitem(sys.modules, "psutil", raising=False) - - with pytest.raises(SystemExit) as excinfo: - main() - captured = capsys.readouterr() - assert excinfo.value.code == 1 - data = json.loads(captured.out) - assert data["ok"] is False - assert data["probe_failed"] is True - assert "psutil" in captured.err.lower() + monkeypatch.setattr(builtins, "__import__", unavailable) + assert _is_pausable_gateway("python -m hermes_cli.main gateway run") is False -def test_main_exempts_gateway_chain_but_keeps_other_holders(monkeypatch, capsys): - """A gateway launcher/worker pair alone must scan clear; a non-gateway - holder alongside it must still block (and be the only reported PID).""" - gateway_launcher = ( - 12, - "python.exe", - r"C:\x\venv\Scripts\python.exe -m hermes_cli.main gateway run --replace", +def _run_legacy_cli(tmp_path, *args): + # -S omits site-packages, including psutil: this entry point must work on + # a half-updated tree, without inspecting or terminating live processes. + return subprocess.run( + [sys.executable, "-S", "-m", "hermes_cli._scan_venv_blockers", *args], + cwd=Path(__file__).resolve().parents[2], + env={**os.environ, "HERMES_HOME": str(tmp_path), "PYTHONPATH": ""}, + capture_output=True, + text=True, + timeout=15, + check=False, ) - gateway_worker = ( - 34, - "python.exe", - r'"C:\u\uv\python\python.exe" -m hermes_cli.main gateway run --replace', - ) - stray_repl = (56, "python.exe", r"C:\x\venv\Scripts\python.exe") - # Gateway chain only → clear - code, data = _run_main_with_detector( - monkeypatch, capsys, [gateway_launcher, gateway_worker] - ) - assert code == 0 + +def test_legacy_cli_is_dependency_free_and_informational(tmp_path): + result = _run_legacy_cli(tmp_path) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + # These three fields are consumed by historical Desktop's strict parser. assert data["ok"] is True assert data["blocked"] is False assert data["processes"] == [] - assert data["pausable_gateways"] == 2 - - # Gateway chain + stray REPL → blocked, reporting only the REPL - code, data = _run_main_with_detector( - monkeypatch, capsys, [gateway_launcher, gateway_worker, stray_repl] - ) - assert code == 0 - assert data["blocked"] is True - assert [p["pid"] for p in data["processes"]] == [56] - assert data["pausable_gateways"] == 2 + assert data["retired"] is True + assert data["message"] + assert result.stderr == "" -def test_main_desktop_serve_backend_still_blocks(monkeypatch, capsys): - """A `serve` backend with NO ledger identity must keep blocking — - the updater's stop/relaunch rungs only own ledger-verified backends.""" - import hermes_cli.process_identity as pi - - monkeypatch.setattr(pi, "ledger_entries", lambda **kw: []) - serve = ( - 78, - "python.exe", - r"C:\x\venv\Scripts\python.exe -m hermes_cli.main serve --host 127.0.0.1", - ) - code, data = _run_main_with_detector(monkeypatch, capsys, [serve]) - assert code == 0 - assert data["blocked"] is True - assert [p["pid"] for p in data["processes"]] == [78] - assert data["pausable_gateways"] == 0 - assert data["deferred_backends"] == 0 - - -# --------------------------------------------------------------------------- -# _is_updater_owned_backend — the serve/dashboard deferral (#98336) -# --------------------------------------------------------------------------- - - -_SERVE_CMD = r"C:\x\venv\Scripts\python.exe -m hermes_cli.main serve --host 127.0.0.1" - - -def _patch_ledger(monkeypatch, entries, dead): - import hermes_cli.process_identity as pi - - monkeypatch.setattr(pi, "ledger_entries", lambda **kw: entries) - monkeypatch.setattr(pi, "spawner_is_dead", lambda entry: dead) - - -def test_updater_owned_backend_dead_spawner_is_deferred(monkeypatch, capsys): - """A ledger-verified serve whose spawner is provably dead is exactly the - orphan `_ledger_reapable_backend_pids` reaps — the scan must defer it - instead of dead-ending the hand-off (#98336).""" - _patch_ledger( - monkeypatch, - [{"pid": 78, "purpose": "serve", "spawner_pid": 4242, "port": 9119}], - dead=True, - ) - code, data = _run_main_with_detector(monkeypatch, capsys, [(78, "python.exe", _SERVE_CMD)]) - assert code == 0 - assert data["blocked"] is False - assert data["processes"] == [] - assert data["deferred_backends"] == 1 - # Sanitized decision evidence (#98350): structured ledger identity only — - # the deferral must explain itself without echoing the command line. - assert data["deferred_backend_evidence"] == [ - {"pid": 78, "purpose": "serve", "port": 9119} - ] - assert "--host" not in json.dumps(data["deferred_backend_evidence"]) - - -def test_updater_owned_backend_unrecorded_spawner_is_deferred(monkeypatch, capsys): - """spawner unrecorded (None from spawner_is_dead) but ledger-registered: - the manual-serve rung stops and relaunches it — defer.""" - _patch_ledger(monkeypatch, [{"pid": 78, "purpose": "serve"}], dead=None) - code, data = _run_main_with_detector(monkeypatch, capsys, [(78, "python.exe", _SERVE_CMD)]) - assert data["blocked"] is False - assert data["deferred_backends"] == 1 - - -def test_updater_owned_backend_live_foreign_spawner_still_blocks(monkeypatch, capsys): - """A serve whose recorded spawner is ALIVE and not in this scan's - ancestry (another supervisor would respawn it) must keep blocking.""" - from hermes_cli import _scan_venv_blockers as scan_mod - - _patch_ledger( - monkeypatch, [{"pid": 78, "purpose": "serve", "spawner_pid": 4242}], dead=False - ) - monkeypatch.setattr(scan_mod, "_spawner_is_this_handoff_desktop", lambda entry: False) - code, data = _run_main_with_detector(monkeypatch, capsys, [(78, "python.exe", _SERVE_CMD)]) - assert data["blocked"] is True - assert [p["pid"] for p in data["processes"]] == [78] - assert data["deferred_backends"] == 0 - - -def test_updater_owned_backend_handoff_desktop_spawner_is_deferred(monkeypatch, capsys): - """A serve owned by THIS hand-off's Desktop (the scan's own ancestor) is - deferred: the Desktop exits before the updater runs, turning it into the - dead-spawner orphan the ledger rung reaps (#98336 field case).""" - from hermes_cli import _scan_venv_blockers as scan_mod - - _patch_ledger( - monkeypatch, [{"pid": 78, "purpose": "serve", "spawner_pid": 4242}], dead=False - ) - monkeypatch.setattr(scan_mod, "_spawner_is_this_handoff_desktop", lambda entry: True) - code, data = _run_main_with_detector(monkeypatch, capsys, [(78, "python.exe", _SERVE_CMD)]) - assert data["blocked"] is False - assert data["deferred_backends"] == 1 - - -def test_updater_owned_backend_purpose_mismatch_blocks(monkeypatch, capsys): - """Argv says serve but the ledger entry says a different purpose — the - identity is inconsistent; fail closed and keep blocking.""" - _patch_ledger( - monkeypatch, [{"pid": 78, "purpose": "mcp-helper", "spawner_pid": 4242}], dead=True - ) - code, data = _run_main_with_detector(monkeypatch, capsys, [(78, "python.exe", _SERVE_CMD)]) - assert data["blocked"] is True - assert data["deferred_backends"] == 0 - - -def test_updater_owned_backend_non_backend_argv_never_deferred(monkeypatch, capsys): - """A ledger entry cannot exempt a process whose argv is not a - serve/dashboard subcommand (#90778 token rules: `kanban --preserve-cache` - must not read as serve).""" - _patch_ledger( - monkeypatch, [{"pid": 78, "purpose": "serve", "spawner_pid": 4242}], dead=True - ) - kanban = ( - 78, - "python.exe", - r"C:\x\venv\Scripts\python.exe -m hermes_cli.main kanban --preserve-cache", - ) - code, data = _run_main_with_detector(monkeypatch, capsys, [kanban]) - assert data["blocked"] is True - assert data["deferred_backends"] == 0 - - -def test_updater_owned_backend_ledger_failure_blocks(monkeypatch, capsys): - """Ledger unreadable → unprovable → fail closed (pre-exemption behavior).""" - import hermes_cli.process_identity as pi - - def _boom(**kw): - raise RuntimeError("ledger unavailable") - - monkeypatch.setattr(pi, "ledger_entries", _boom) - code, data = _run_main_with_detector(monkeypatch, capsys, [(78, "python.exe", _SERVE_CMD)]) - assert data["blocked"] is True - assert data["deferred_backends"] == 0 - - -def test_main_gateway_with_long_managed_runtime_path_is_exempt(monkeypatch, capsys): - """Regression: the detector must hand the FULL cmdline to the exemption. - - Gateways launched via the managed-runtime interpreter carry a >120-char - exe path (`.hermes-runtime\python\generation-...\cpython-3.11-...`). - The old `cmdline_raw[:120]` truncation in the detector cut the cmdline - before `-m hermes_cli.main gateway run`, so the exemption never matched - and every Desktop update aborted with 'Update didn't finish'. - Here the detector returns full cmdlines (post-fix contract); the scan - must exempt the gateway and truncate only the *displayed* cmdline. - """ - long_exe = ( - r'"C:\Users\u\AppData\Local\hermes\hermes-agent\.hermes-runtime\python' - r"\generation-1785095035-66720-be29ea9c\cpython-3.11-windows-x86_64-none" - r'\python.exe"' - ) - assert len(long_exe) > 120 # the truncation point was inside the exe path - gateway = (91, "python.exe", long_exe + " -m hermes_cli.main gateway run --replace") - code, data = _run_main_with_detector(monkeypatch, capsys, [gateway]) - assert code == 0 - assert data["blocked"] is False - assert data["processes"] == [] - assert data["pausable_gateways"] == 1 - - # A long-path NON-gateway holder still blocks, with cmdline truncated for display. - stray = (92, "python.exe", long_exe + " -m some_other_module --serve-forever") - code, data = _run_main_with_detector(monkeypatch, capsys, [gateway, stray]) - assert data["blocked"] is True - assert [p["pid"] for p in data["processes"]] == [92] - assert len(data["processes"][0]["cmdline"]) <= 120 +@pytest.mark.parametrize("identity", [("123", "1722798000.25"), (), ("invalid", "nan")]) +def test_legacy_termination_is_refused(tmp_path, identity): + result = _run_legacy_cli(tmp_path, "--terminate-safe", *identity) + # Historical Desktop treats exit 0 as proof the PID was stopped, without + # parsing stdout. Never acknowledge a termination that did not happen. + assert result.returncode != 0 + data = json.loads(result.stdout) + assert data["ok"] is False + assert "retired" in data["error"].lower() + assert result.stderr == "" \ No newline at end of file diff --git a/tests/hermes_cli/test_tui_npm_install.py b/tests/hermes_cli/test_tui_npm_install.py index 71f313ff8e..58b20546c4 100644 --- a/tests/hermes_cli/test_tui_npm_install.py +++ b/tests/hermes_cli/test_tui_npm_install.py @@ -34,16 +34,6 @@ def test_unreceipted_bundle_is_stale_even_when_newer(tmp_path: Path) -> None: assert main_tui_launch._tui_need_rebuild(tmp_path) is True -def test_rebuild_when_tui_source_newer_than_bundle(tmp_path: Path) -> None: - _touch_tui_entry(tmp_path) - src = tmp_path / "src" - src.mkdir() - (src / "entry.tsx").write_text("console.log('src')") - os.utime(tmp_path / "dist" / "entry.js", (100, 100)) - os.utime(src / "entry.tsx", (200, 200)) - - assert main_tui_launch._tui_need_rebuild(tmp_path) is True - @pytest.fixture def tui_source(source_products, monkeypatch): @@ -178,12 +168,19 @@ def test_tui_rebuild_preserves_the_prepared_desktop_and_web_union(tui_source): assert (root / "node_modules/apps-desktop").exists() -@pytest.mark.parametrize("changed", ["package.json", "package-lock.json", "apps/shared/shared.ts"]) -def test_tui_rebuild_tracks_root_and_shared_inputs(tmp_path, changed): - tui = tmp_path / "ui-tui" - _touch_tui_entry(tui) - changed_file = tmp_path / changed +@pytest.mark.platforms("posix") +@pytest.mark.parametrize("changed", ["ui-tui/src/entry.tsx", "package.json", "package-lock.json", "apps/shared/shared.ts"]) +def test_tui_rebuild_tracks_root_and_shared_inputs(tui_source, changed): + root, _ = tui_source + tui = root / "ui-tui" + changed_file = root / changed changed_file.parent.mkdir(parents=True, exist_ok=True) - changed_file.write_text("changed") - os.utime(tui / "dist/entry.js", (1, 1)) + if not changed_file.exists(): + changed_file.write_text("original input\n", encoding="utf-8") + main_tui_launch._make_tui_argv(tui, tui_dev=False) + assert not main_tui_launch._tui_need_rebuild(tui), "must have a current receipt before invalidation" + before = changed_file.stat() + changed_file.write_text(changed_file.read_text(encoding="utf-8") + "\n", encoding="utf-8") + # A content change must invalidate even when mtimes are restored. + os.utime(changed_file, ns=(before.st_atime_ns, before.st_mtime_ns)) assert main_tui_launch._tui_need_rebuild(tui) diff --git a/tests/hermes_cli/test_update_apply_shallow_count.py b/tests/hermes_cli/test_update_apply_shallow_count.py index 272b42ad87..f25b6b60f1 100644 --- a/tests/hermes_cli/test_update_apply_shallow_count.py +++ b/tests/hermes_cli/test_update_apply_shallow_count.py @@ -1,116 +1,47 @@ -"""Shallow-checkout guard on the `hermes update` apply path (#53479). +"""The real apply planner must not trust ancestry counts from shallow Git.""" -`rev-list --count HEAD..origin/` on a shallow install can enumerate -the entire remote ancestry ("Found 9980 new commit(s)" on a depth-1 clone). -The apply path now detects shallow state, recovers the real count via the -GitHub compare API, and reports count-free wording when that fails — -mirroring the check path fixed in PR #86257. - -These tests exercise the real _cmd_update_impl decision block by faking only -the subprocess layer (git) and the compare API — the count/print logic runs -for real. -""" - -from unittest.mock import MagicMock, patch +import subprocess +from unittest.mock import Mock import pytest -import hermes_cli.update_cmd as update_cmd +from hermes_cli import banner, main, update_cmd SHA_A = "a" * 40 SHA_B = "b" * 40 -def _git_responder(*, shallow: bool, count: str): - """Answer the git subprocess calls the count block makes.""" +@pytest.mark.parametrize("shallow,raw_count,api_count,expected", [ + (False, 7, None, 7), + (True, 9980, 12, 12), + (True, 9980, None, -1), + (True, 3, 0, 0), + (True, 0, None, 0), +], ids=["full", "shallow-api", "shallow-offline", "local-ahead", "equal-tips"]) +def test_apply_plan_counts_supplied_git_results(monkeypatch, tmp_path, shallow, raw_count, api_count, expected): + monkeypatch.setattr(main, "PROJECT_ROOT", tmp_path) + responses = { + ("status", "--porcelain"): "", + ("rev-list", "HEAD..origin/main", "--count"): str(raw_count), + ("rev-parse", "--is-shallow-repository"): "true" if shallow else "false", + ("rev-parse", "HEAD"): SHA_A, + ("rev-parse", "origin/main"): SHA_B, + } - def fake_run(cmd, **kwargs): - joined = " ".join(cmd) - if "rev-list" in joined and "--count" in joined: - return MagicMock(returncode=0, stdout=f"{count}\n", stderr="") - if "--is-shallow-repository" in joined: - return MagicMock(returncode=0, stdout=("true\n" if shallow else "false\n"), stderr="") - if "rev-parse HEAD" in joined: - return MagicMock(returncode=0, stdout=f"{SHA_A}\n", stderr="") - if "rev-parse origin/main" in joined: - return MagicMock(returncode=0, stdout=f"{SHA_B}\n", stderr="") - return MagicMock(returncode=0, stdout="", stderr="") + def git(cmd, **kwargs): + assert cmd[0] == "git" + return subprocess.CompletedProcess(cmd, 0, responses[tuple(cmd[1:])] + "\n", "") - return fake_run - - -def _run_count_block(*, shallow: bool, raw_count: str, api_count): - """Execute exactly the apply-path count block with a faked git layer.""" - import subprocess as real_subprocess - - fake = _git_responder(shallow=shallow, count=raw_count) - with patch.object(update_cmd, "subprocess") as sub: - sub.run = MagicMock(side_effect=fake) - sub.CalledProcessError = real_subprocess.CalledProcessError - with patch("hermes_cli.banner._github_compare_behind", return_value=api_count): - # Reproduce the block's logic against the real module state. - git_cmd = ["git"] - result = sub.run( - git_cmd + ["rev-list", "HEAD..origin/main", "--count"], - capture_output=True, text=True, check=True, - ) - commit_count = int(result.stdout.strip()) - apply_is_shallow = ( - sub.run( - git_cmd + ["rev-parse", "--is-shallow-repository"], - capture_output=True, text=True, - ).stdout.strip() - == "true" - ) - if commit_count > 0 and apply_is_shallow: - from hermes_cli.banner import _github_compare_behind - - head_sha = sub.run(git_cmd + ["rev-parse", "HEAD"], capture_output=True, text=True).stdout.strip() - target_sha = sub.run( - git_cmd + ["rev-parse", "origin/main"], capture_output=True, text=True - ).stdout.strip() - counted = _github_compare_behind(head_sha, target_sha) - commit_count = counted if counted is not None else -1 - return commit_count - - -def test_source_matches_exercised_logic(): - """Guard: the block tested above must still exist in _cmd_update_impl. - - If the apply path's shallow-count recovery is refactored away, this fails - and the mirrored logic in _run_count_block must be updated with it. - """ - import inspect - - src = inspect.getsource(update_cmd._prepare_checkout_for_update) - assert "apply_is_shallow" in src - assert "_github_compare_behind" in src - # The "count unknown" print stays in _cmd_update_impl, which consumes the plan. - assert "commit count unknown on this shallow checkout" in inspect.getsource( - update_cmd._cmd_update_impl + monkeypatch.setattr(subprocess, "run", git) + compare = Mock(return_value=api_count) + monkeypatch.setattr(banner, "_github_compare_behind", compare) + plan = update_cmd._prepare_checkout_for_update( + ["git"], "main", "main", is_fork=False, assume_yes=False, + gateway_mode=False, gw_input_fn=None, switch_branch=False, + _windows_gateway_resume=None, ) - - -def test_full_clone_keeps_exact_count(): - assert _run_count_block(shallow=False, raw_count="7", api_count=None) == 7 - - -def test_shallow_bogus_count_recovers_via_compare_api(): - """FAIL-BEFORE: reported the bogus 9980 as 'Found 9980 new commit(s)'.""" - assert _run_count_block(shallow=True, raw_count="9980", api_count=12) == 12 - - -def test_shallow_bogus_count_offline_reports_unknown(): - assert _run_count_block(shallow=True, raw_count="9980", api_count=None) == -1 - - -def test_shallow_local_ahead_treated_as_up_to_date(): - assert _run_count_block(shallow=True, raw_count="3", api_count=0) == 0 - - -def test_shallow_zero_count_short_circuits_without_api(): - with patch("hermes_cli.banner._github_compare_behind") as api: - got = _run_count_block(shallow=True, raw_count="0", api_count=None) - # The block only consults the API when count > 0; a 0 count is trustworthy - # (HEAD == origin tip counts 0 even on shallow graphs). - assert got == 0 + assert plan.commit_count == expected + if shallow and raw_count: + compare.assert_called_once_with(SHA_A, SHA_B) + else: + compare.assert_not_called() \ No newline at end of file diff --git a/tests/hermes_cli/test_update_fetch_failure_classifier.py b/tests/hermes_cli/test_update_fetch_failure_classifier.py index 883ac3639b..29ab916918 100644 --- a/tests/hermes_cli/test_update_fetch_failure_classifier.py +++ b/tests/hermes_cli/test_update_fetch_failure_classifier.py @@ -87,38 +87,35 @@ class TestPrintFetchFailure: assert out == ["✗ Failed to fetch updates from origin."] -def test_update_network_git_calls_never_prompt_for_credentials(): - """Every `git fetch`/`pull`/`push` in the updater runs with prompts disabled. - - Live incident (Sep 2026): a GitHub-side 401 made `hermes update` sit on - ``Username for 'https://github.com':`` instead of failing with a diagnosis. - """ - import inspect - import os - import re +def test_update_and_upstream_network_calls_disable_terminal_prompts(monkeypatch, tmp_path): + """Exercise origin fetch and fork fetch/pull/push, not their source spelling.""" import subprocess - - kw = update_cmd._no_prompt_git_kwargs() - assert kw["stdin"] is subprocess.DEVNULL - assert kw["env"]["GIT_TERMINAL_PROMPT"] == "0" - # Only the prompt is disabled — credential helpers / askpass stay - # configured so a private-fork origin still authenticates. - assert "GIT_CONFIG_COUNT" not in kw["env"] or kw["env"]["GIT_CONFIG_COUNT"] == os.environ.get("GIT_CONFIG_COUNT") - from hermes_cli import update_cmd_git - # Network git calls live in the origin (``_git_run``) and the split git module. - src = inspect.getsource(update_cmd) + inspect.getsource(update_cmd_git) - # Every subprocess.run(...) whose argv is a fetch/pull must spread the kwargs. + monkeypatch.setenv("GIT_TERMINAL_PROMPT", "1") + monkeypatch.setenv("GCM_INTERACTIVE", "Always") + monkeypatch.setenv("GIT_ASKPASS", "fixture-askpass") + monkeypatch.setenv("GIT_CONFIG_COUNT", "1") + monkeypatch.setenv("GIT_CONFIG_KEY_0", "credential.helper") + monkeypatch.setenv("GIT_CONFIG_VALUE_0", "fixture-helper") + monkeypatch.setattr(update_cmd, "_has_upstream_remote", lambda *a: True) + monkeypatch.setattr(update_cmd, "_count_commits_between", + lambda git, cwd, base, head: 2 if head == "upstream/main" else 0) calls = [] - for m in re.finditer(r"subprocess\.run\(", src): - depth, i = 1, m.end() - while depth: - depth += {"(": 1, ")": -1}.get(src[i], 0) - i += 1 - call = src[m.start():i] - if re.search(r'git_cmd \+ \["(fetch|pull|push)"', call): - calls.append(call) - assert calls, "expected network git calls in update_cmd" - missing = [c for c in calls if "_no_prompt_git_kwargs()" not in c] - assert not missing, missing + + def run(cmd, **kwargs): + calls.append((cmd[1:], kwargs)) + return subprocess.CompletedProcess(cmd, 0, "", "") + + monkeypatch.setattr(subprocess, "run", run) + update_cmd._git_run(["git"], ["fetch", "origin", "main"], cwd=tmp_path, network=True, check=True) + assert update_cmd_git._sync_with_upstream_if_needed(["git"], tmp_path, assume_yes=True) + assert [args[0] for args, _ in calls] == ["fetch", "fetch", "pull", "push"] + for args, kwargs in calls: + assert kwargs["stdin"] is subprocess.DEVNULL, args + env = kwargs["env"] + assert env["GIT_TERMINAL_PROMPT"] == "0", args + assert env["GCM_INTERACTIVE"] == "Never", args + assert env["GIT_ASKPASS"] == "fixture-askpass", args + assert env["GIT_CONFIG_COUNT"] == "1", args + assert env["GIT_CONFIG_VALUE_0"] == "fixture-helper", args diff --git a/tests/hermes_cli/test_update_finish.py b/tests/hermes_cli/test_update_finish.py new file mode 100644 index 0000000000..2a307cca9f --- /dev/null +++ b/tests/hermes_cli/test_update_finish.py @@ -0,0 +1,383 @@ +"""Historical takeover completion in a fresh, dependency-selected process. + +PM preparation has its own worker tests. Here the tool provisioner supplies +the real test Node/npm and icons are pre-prepared. npm ci, both compilers, +application imports, maintenance/config migration and receipts are real. +Only fleet discovery/restarts and machine-wide post-update repairs are seams. +""" +from __future__ import annotations + +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import textwrap + +import pytest + + +ROOT = Path(__file__).resolve().parents[2] + + +def _put(root, name, content): + path = root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + return path + + +@pytest.fixture +def completion(tmp_path, monkeypatch): + from hermes_cli.config_defaults import DEFAULT_CONFIG + from hermes_cli.runtime_paths import install_state_dir, runtime_facts_path, site_packages + + home = tmp_path / "home" + home.mkdir() + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("HERMES_HOME", str(home / ".hermes")) + monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "store")) + source = tmp_path / "selected source" + source.mkdir() + # Copies give bootstrap/main a genuine selected checkout identity. No file + # in the working checkout is modified, even by import-time self-heals. + for path in ROOT.glob("*.py"): + shutil.copy2(path, source / path.name) + for name in ("hermes_cli", "pm", "agent", "gateway", "tools", "cron"): + shutil.copytree(ROOT / name, source / name, + ignore=shutil.ignore_patterns("__pycache__", "web_dist", "tui_dist")) + shutil.copytree(ROOT / "scripts/build", source / "scripts/build", + ignore=shutil.ignore_patterns("__pycache__")) + _put(source, "pyproject.toml", '[project]\nname="takeover-fixture"\nversion="2.0"\n') + # The selected interpreter is dependency-free, not the pytest environment. + python = tmp_path / "store/python/bin/python3" + python.parent.mkdir(parents=True) + shutil.copy2(Path(sys._base_executable).resolve(), python) + _put(tmp_path / "store", "facts.json", json.dumps({"packages": {"python": {"entry": "python"}}})) + generation = install_state_dir(source) / "environments/prepared" + site = site_packages(generation) + site.mkdir(parents=True) + _put(generation, "pyvenv.cfg", "include-system-site-packages = false\n") + # Add the already prepared *real* test dependencies through a selected + # generation .pth, as editable PM generations do. -I ignores PYTHONPATH. + dependency_sites = [p for p in sys.path if Path(p).name in ("site-packages", "dist-packages")] + assert dependency_sites + _put(site, "dependencies.pth", "\n".join(dependency_sites) + "\n") + _put(site, "selected_dependency.py", "VALUE = 'selected generation'\n") + _put(runtime_facts_path(source).parent, "facts.json", json.dumps({ + "packages": {"venv": {"environment": str(generation)}}})) + _put(home / ".hermes", "config.yaml", + f"_config_version: {DEFAULT_CONFIG['_config_version'] - 1}\nmodel:\n default: retained-model\n") + env = {key: value for key, value in os.environ.items() + if not key.startswith(("HERMES_", "PYTHON", "PYTEST_", "VIRTUAL_ENV", "npm_", "NPM_"))} + env.update(HOME=str(home), HERMES_HOME=str(home / ".hermes"), + HERMES_RUNTIME_DIR=str(tmp_path / "store"), + PYTHONPATH="/obsolete/pre-pm/site-packages", PYTHONHOME="/obsolete/python", + NPM_CONFIG_OFFLINE="true", NPM_CONFIG_CACHE=str(tmp_path / "npm-cache")) + probe = subprocess.run([str(python), "-I", "-c", + "import importlib.util; assert importlib.util.find_spec('yaml') is None"], + env=env, capture_output=True, text=True) + assert probe.returncode == 0, probe.stderr + request = { + "root": str(source), "desktop": False, "assume_yes": True, "gateway_mode": False, + "pre_update_snapshot_id": "original-snapshot", "pre_update_version": "1.0", + "update_id": "historical-parent-correlation", + "receipt": {"update_id": "historical-parent-correlation", "started_at": "before-the-swap", + "pre_update": {"sha": "old-sha"}, + "steps": [{"name": "pull", "ok": True, "detail": "original", "at": "before"}], + "plan": {"expected_sha": "old-sha"}}, + "pm_receipt": {"update_id": "historical-parent-correlation", "outcome": "success", + "exit_code": 0, "venv_rebuild": {"environment": str(generation)}}, + "plan": {"install_method": "git", "expected_sha": "old-sha", "expected_version": "1.0", + "profiles": ["default"], "runtimes": [{ + "kind": "serve", "profile": "default", "pid": 99999999, + "supervisor": "manual-serve", "code_sha": "old-sha", "code_version": "1.0", + "restart_via": "respawn-argv", "detail": {"argv": ["original", "serve"]}}]}, + "windows_resume": {"resume_needed": False, "profiles": {"default": {"argv": ["original"]}}, + "unmapped": [], "services": [], "service_profiles": {}}, + } + context = _put(tmp_path, "context.json", json.dumps(request)) + result = tmp_path / "result.json" + runner = _put(tmp_path, "run-completion.py", textwrap.dedent(''' + import importlib.abc + import importlib.machinery + import json + from pathlib import Path + import runpy + import sys + + context, result, fault = sys.argv[1:] + request = json.loads(Path(context).read_text()) + root = Path(request['root']) + sys.path.insert(0, str(root)) + + class CompletionImports(importlib.abc.MetaPathFinder): + def find_spec(self, fullname, path=None, target=None): + if fullname == 'pre_pm_only': + raise AssertionError('historical process module leaked into completion') + if fullname == 'pm.recovery': + raise RuntimeError('PM recovery is unavailable in this completion fixture') + if fullname == 'pm.receipt' and fault == 'receipt': + spec = importlib.machinery.PathFinder.find_spec(fullname, path) + original = spec.loader.exec_module + def execute(module): + original(module) + def broken(*args, **kwargs): + raise RuntimeError('cannot accept prepared receipt') + module.accept_worker_receipt = broken + spec.loader.exec_module = execute + return spec + + if fullname != 'hermes_cli.main': + return None + import selected_dependency + assert selected_dependency.VALUE == 'selected generation' + if fault == 'import': + raise RuntimeError('application import failed before build') + spec = importlib.machinery.PathFinder.find_spec(fullname, path) + original = spec.loader.exec_module + def execute(module): + original(module) + # Install only service/machine boundaries after REAL CLI + # startup; all dependencies must be available by now. + from hermes_cli import update_cmd as update + from hermes_cli import update_cmd_maint as maint + from hermes_cli import update_cmd_fleet as fleet + from hermes_cli import gateway_migrate + from hermes_cli import macos_tcc_anchor + from hermes_cli import source_build + from hermes_cli.update_inventory import UpdatePlan, RuntimeRecord + from pm.package import Runner + import pm + import os + import shutil + + def provision(name, *, base_env, explicit): + assert name == 'npm' and explicit + assert shutil.which('node', path=base_env['PATH']) + assert shutil.which('npm', path=base_env['PATH']) + assert base_env['HERMES_PYTHON'] == sys.executable + assert base_env['PYTHON'] == sys.executable + (root / 'build-environment.json').write_text(json.dumps({ + 'python': sys.executable, 'selected': selected_dependency.__file__, + 'argv': sys.argv, 'old_module': 'pre_pm_only' in sys.modules, + 'pid': os.getpid(), 'parent': os.getppid(), + })) + return Runner(name, base_env) + pm.ensure = provision + web = source_build.build_source_web + source_build.build_source_web = lambda root, **kwargs: web(root, icons=root, **kwargs) + # Ancillary system changes are not the acceptance target. + macos_tcc_anchor.ensure_tcc_anchor = lambda: None + maint._print_post_update_notices_and_self_heals = lambda: None + maint._sync_profiles_after_update = lambda: None + maint._print_bundled_skills_sync_report = lambda: None + fleet._print_legacy_units_warning = lambda: None + maint._refresh_dashboard_after_update = lambda **kwargs: None + gateway_migrate.maybe_auto_migrate_after_update = lambda: None + update._surviving_pre_update_serve_runtimes = lambda plan: [] + fleet._collect_fleet_snapshot = lambda *args: [] + def restart(plan, gateway_mode): + assert isinstance(plan, UpdatePlan) + assert isinstance(plan.runtimes[0], RuntimeRecord) + assert plan.to_dict() == request['plan'] | { + 'updatable_in_place': True, 'update_mechanism': 'hermes update'} + (root / 'restarted-plan.json').write_text(json.dumps(plan.to_dict())) + return fleet._GatewayRestartOutcome( + incomplete=False, phase_errors=[], pre_restart_gateway_pids=[], + restarted_services=[], failed_or_stale_units=[], relaunched_profiles=[], + externally_supervised_profiles=[], killed_pids=set()) + update._restart_gateway_fleet_after_update = restart + merge = update._resume_windows_gateways_and_merge_outcome + def resume(outcome, token, gateway_mode): + assert token == request['windows_resume'] + (root / 'resumed-token.json').write_text(json.dumps(token)) + return merge(outcome, token, gateway_mode) + update._resume_windows_gateways_and_merge_outcome = resume + spec.loader.exec_module = execute + return spec + sys.meta_path.insert(0, CompletionImports()) + sys.argv = [str(root / 'hermes_cli/update_finish.py'), context, result] + runpy.run_path(sys.argv[0], run_name='__main__') + ''')) + def run(fault=""): + return subprocess.run([str(python), "-I", "-B", str(runner), str(context), str(result), fault], + cwd=tmp_path, env=env, capture_output=True, text=True, timeout=90) + return source, home / ".hermes", request, context, result, run + + +@pytest.mark.platforms("posix") +@pytest.mark.parametrize("fault", ["import", "activation", "plan", "receipt"]) +def test_failure_preserves_original_receipt_before_build(completion, fault): + source, home, request, context, result, run = completion + request["gateway_mode"] = True + context.write_text(json.dumps(request), encoding="utf-8") + message = "application import failed before build" + if fault == "activation": + from hermes_cli.runtime_paths import selected_venv, site_packages + + shutil.rmtree(site_packages(selected_venv(source))) + message = "dependency environment has no site-packages" + elif fault == "plan": + request["plan"]["runtimes"] = [{"profile": "missing required kind"}] + context.write_text(json.dumps(request), encoding="utf-8") + message = "kind" + elif fault == "receipt": + message = "cannot accept prepared receipt" + before = context.read_bytes() + child = run(fault) + assert child.returncode == 1, child.stdout + child.stderr + assert message in child.stderr + receipt = json.loads((home / "logs/update_receipts/latest.json").read_text()) + assert receipt["update_id"] == request["update_id"] + assert receipt["started_at"] == request["receipt"]["started_at"] + assert receipt["pre_update"] == request["receipt"]["pre_update"] + assert receipt["steps"][0] == request["receipt"]["steps"][0] + if fault != "receipt": + assert receipt["pm_venv_rebuild"] == request["pm_receipt"]["venv_rebuild"] + assert receipt["outcome"] == "failed" + assert receipt["exit_code"] == child.returncode + assert json.loads(result.read_text())["receipt_handled"] is True + assert (home / ".update_exit_code").read_text().strip() == "1" + assert context.read_bytes() == before + assert not (source / "build-environment.json").exists() + + +def _npm_graph(source): + # Use the same prepared compilers as the JS builder acceptance suite, + # while npm itself installs a tiny, entirely local workspace graph. + tools = {} + node = shutil.which("node") + assert node, "source completion acceptance requires Node and npm" + for name in ("esbuild", "typescript", "vite"): + probe = subprocess.run([node, "-p", f"require.resolve('{name}/package.json')"], + cwd=ROOT, capture_output=True, text=True, check=True) + tools[name] = str(Path(probe.stdout.strip()).parent) + root = {"name": "completion-graph", "version": "1.0.0", "private": True, "type": "module", + "workspaces": ["ui-tui", "web", "packages/value"], + "scripts": {"postinstall": "node prepare-tools.mjs"}} + packages = {"": root} + for directory, name in (("ui-tui", "fixture-tui"), ("web", "fixture-web"), + ("packages/value", "fixture-value")): + manifest = {"name": name, "version": "1.0.0", "type": "module"} + if name == "fixture-value": + manifest["exports"] = "./index.js" + else: + manifest["dependencies"] = {"fixture-value": "*"} + _put(source, f"{directory}/package.json", json.dumps(manifest)) + packages[directory] = manifest + packages[f"node_modules/{name}"] = {"resolved": directory, "link": True} + _put(source, "package.json", json.dumps(root)) + _put(source, "package-lock.json", json.dumps({"name": root["name"], "version": "1.0.0", + "lockfileVersion": 3, "packages": packages})) + _put(source, "prepare-tools.mjs", textwrap.dedent(f''' + import {{ mkdirSync, symlinkSync, writeFileSync }} from 'node:fs'; + import {{ execFileSync }} from 'node:child_process'; + const tools = {json.dumps(tools)}; + for (const [name, target] of Object.entries(tools)) {{ + const workspace = name === 'esbuild' ? 'ui-tui' : 'web'; + mkdirSync(workspace + '/node_modules', {{recursive: true}}); + symlinkSync(target, workspace + '/node_modules/' + name, 'dir'); + }} + // A real npm lifecycle child, not an assertion about a constructed env. + writeFileSync('npm-python.json', execFileSync(process.env.HERMES_PYTHON, + ['-I', '-c', 'import json, os, sys; print(json.dumps(dict(python=sys.executable, configured=os.environ["PYTHON"])))'])); + ''')) + _put(source, "packages/value/index.js", "export const value = 'compiled local graph';\n") + _put(source, "packages/value/index.d.ts", "export const value: string;\n") + _put(source, "ui-tui/src/entry.tsx", "import { value } from 'fixture-value'; console.log(value);\n") + _put(source, "web/src/main.ts", "import { value } from 'fixture-value'; document.body.textContent = value;\n") + _put(source, "web/index.html", '') + _put(source, "web/tsconfig.json", json.dumps({"compilerOptions": { + "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", + "types": [], "noEmit": True}, "include": ["src"]})) + _put(source, "web/vite.config.ts", "export default {};\n") + _put(source, "web/public/favicon.ico", "prepared icon input") + return node + + +@pytest.mark.platforms("posix") +def test_selected_child_builds_and_finalizes_under_parent_lock(completion): + from hermes_cli.config_defaults import DEFAULT_CONFIG + from hermes_cli.update_lock import UpdateLock + import hermes_yaml + + source, home, request, context, result, run = completion + node = _npm_graph(source) + before = context.read_bytes() + with UpdateLock() as lock: + assert lock.acquired + marker = lock.path.read_bytes() + child = run() + assert child.returncode == 0, child.stdout + child.stderr + assert lock.path.read_bytes() == marker, "completion must not release its parent's lock" + built = subprocess.run([node, str(source / "ui-tui/dist/entry.js")], + capture_output=True, text=True, check=True) + assert built.stdout.strip() == "compiled local graph" + assert (source / "hermes_cli/web_dist/index.html").is_file() + assert any("compiled local graph" in p.read_text() for p in (source / "hermes_cli/web_dist/assets").glob("*.js")) + assert (source / "node_modules/.hermes-node-deps").is_file() + environment = json.loads((source / "build-environment.json").read_text()) + assert "repairing the recorded dependency environment" not in child.stderr + npm_python = json.loads((source / "npm-python.json").read_text()) + assert npm_python["python"] == npm_python["configured"] == environment["python"] + assert environment["parent"] == os.getpid() + assert environment["old_module"] is False + assert environment["argv"] == [str(source / "hermes"), "update"] + assert context.read_bytes() == before + assert json.loads((source / "restarted-plan.json").read_text())["expected_sha"] == "old-sha" + assert json.loads((source / "resumed-token.json").read_text()) == request["windows_resume"] + config = hermes_yaml.safe_load((home / "config.yaml").read_text()) + assert config["_config_version"] == DEFAULT_CONFIG["_config_version"], child.stdout + child.stderr + assert config["model"]["default"] == "retained-model" + receipt = json.loads((home / "logs/update_receipts/latest.json").read_text()) + assert receipt["outcome"] == "success" + assert receipt["update_id"] == request["update_id"] + assert receipt["pre_update"] == request["receipt"]["pre_update"] + assert receipt["steps"][0] == request["receipt"]["steps"][0] + assert receipt["pm_venv_rebuild"] == request["pm_receipt"]["venv_rebuild"] + assert json.loads(result.read_text()) == {"resume_handled": True, "receipt_handled": True} + + +@pytest.mark.platforms("posix") +def test_real_compiler_failure_retains_receipt_and_skips_completion(completion): + source, home, request, context, result, run = completion + _npm_graph(source) + # A real TypeScript error: dependency installation and TUI succeed, but + # neither the web product nor config/fleet completion may claim success. + _put(source, "web/src/main.ts", "const broken: string = 42; document.body.textContent = broken;\n") + config_before = (home / "config.yaml").read_bytes() + context_before = context.read_bytes() + child = run() + assert child.returncode == 1, child.stdout + child.stderr + assert "TypeScript build failed" in child.stderr + assert (source / "npm-python.json").is_file() + assert (source / "ui-tui/dist/entry.js").is_file() + assert not (source / "hermes_cli/web_dist/index.html").exists() + assert not (source / "restarted-plan.json").exists() + assert (home / "config.yaml").read_bytes() == config_before + assert context.read_bytes() == context_before + receipt = json.loads((home / "logs/update_receipts/latest.json").read_text()) + assert receipt["outcome"] == "failed" + assert receipt["exit_code"] == child.returncode + assert receipt["update_id"] == request["update_id"] + assert receipt["steps"][0] == request["receipt"]["steps"][0] + assert receipt["pm_venv_rebuild"] == request["pm_receipt"]["venv_rebuild"] + assert json.loads(result.read_text()) == {"resume_handled": True, "receipt_handled": True} + + +@pytest.mark.platforms("posix") +@pytest.mark.parametrize("argument, code", [("--help", 0), ("--no-such-update-option", 2)]) +def test_pre_pull_restart_enters_current_cli_with_original_arguments(completion, argument, code): + source, home, request, context, result, run = completion + # Exercise the REAL parser without contacting a repository or supervisor. + # The full update command's apply path is covered by its own tests. + request.update(restart_update=True, argv=[str(source / "hermes"), "update", argument]) + context.write_text(json.dumps(request), encoding="utf-8") + child = run() + assert child.returncode == code, child.stdout + child.stderr + output = child.stdout + child.stderr + assert "usage:" in output and "update" in output + assert not (source / "build-environment.json").exists() + assert json.loads(result.read_text()) == {"resume_handled": True, "receipt_handled": True} \ No newline at end of file diff --git a/tests/hermes_cli/test_update_fleet_check_fail_closed.py b/tests/hermes_cli/test_update_fleet_check_fail_closed.py index c4aeffd1a7..296aa3b983 100644 --- a/tests/hermes_cli/test_update_fleet_check_fail_closed.py +++ b/tests/hermes_cli/test_update_fleet_check_fail_closed.py @@ -23,9 +23,10 @@ not a runtime inventory, and its entries have no corresponding from __future__ import annotations -import inspect import types +import pytest + from hermes_cli.main import _fleet_probe_expected_runtimes from hermes_cli.update_inventory import RuntimeRecord @@ -132,41 +133,55 @@ class TestEmptySnapshotGenuinelyIdle: class TestCallSiteWiring: - """The guard AND the settle sleep must both key on the shared signal. + @pytest.mark.parametrize("had_gateway", [False, True], ids=["idle", "plan-saw-gateway"]) + def test_empty_probe_settles_and_fails_only_when_rows_expected(self, monkeypatch, tmp_path, capsys, had_gateway): + import json + from hermes_cli import main, update_cmd, update_cmd_fleet as fleet, update_receipt - Sabotage-proof for the wiring itself: reverting the call site to the - pre-fix ``(restarted_services or killed_pids)`` condition — while leaving - the helper in place — makes these fail. - """ + monkeypatch.setattr(main, "PROJECT_ROOT", tmp_path) + monkeypatch.setattr(fleet, "_print_legacy_units_warning", lambda: None) + monkeypatch.setattr("hermes_cli.update_cmd_maint._refresh_dashboard_after_update", lambda **kw: None) + monkeypatch.setattr(update_cmd, "_surviving_pre_update_serve_runtimes", lambda plan: []) + # Reconciliation is a separate guard; it must not supply this test's failure. + monkeypatch.setattr("hermes_cli.update_inventory.report_unaccounted_runtimes", lambda rows: False) + monkeypatch.setattr("hermes_cli.gateway_migrate.maybe_auto_migrate_after_update", lambda: None) + events = [] + now = [0.0] - def _impl_source(self): - from hermes_cli import update_cmd + def sleep(seconds): + events.append("settle") + now[0] += seconds - # The fleet-version probe lives in the post-restart verifier that - # _cmd_update_impl calls; guard the wiring there. - return inspect.getsource(update_cmd._verify_fleet_after_update) + def collect(**kwargs): + events.append("probe") + return [] - def test_settle_sleep_gated_on_expected_runtimes(self): - src = self._impl_source() - assert "_fleet_rows_expected = _m()._fleet_probe_expected_runtimes(" in src - # The 2.0s settle window must key on the cross-platform signal, so a - # resumed Windows gateway gets its settle window too (#93406). The - # settle loop lives in _collect_fleet_snapshot, gated on that signal. - assert "_fleet_snapshot = _collect_fleet_snapshot(restart, _fleet_rows_expected)" in src - from hermes_cli import update_cmd_fleet - - snap_src = inspect.getsource(update_cmd_fleet._collect_fleet_snapshot) - assert "if not rows_expected:\n" in snap_src - assert "_time.sleep(2.0)" in snap_src - assert "if restarted_services or killed_pids:\n _time.sleep" not in src - - def test_zero_row_guard_gated_on_expected_runtimes(self): - src = self._impl_source() - assert "elif not _fleet_snapshot and _fleet_rows_expected:" in src - assert ( - "elif not _fleet_snapshot and (restarted_services or killed_pids):" - not in src + monkeypatch.setattr(fleet, "_time", types.SimpleNamespace(monotonic=lambda: now[0], sleep=sleep)) + monkeypatch.setattr(update_receipt, "collect_fleet_versions", collect) + restart = fleet._GatewayRestartOutcome( + incomplete=False, phase_errors=[], pre_restart_gateway_pids=[], restarted_services=[], + failed_or_stale_units=[], relaunched_profiles=[], externally_supervised_profiles=[], killed_pids=set(), ) + plan = _plan([RuntimeRecord(kind="gateway", profile="default")] if had_gateway else []) + update_receipt.begin_update_receipt() + + def verify(): + fleet._verify_fleet_after_update(restart, _pre_update_plan=plan, + _windows_gateway_resume=None, update_complete=True) + + if had_gateway: + with pytest.raises(SystemExit) as failure: + verify() + assert failure.value.code == 1 + assert events[0] == "settle" + assert events.count("probe") > 1 + assert "returned no rows" in capsys.readouterr().out + else: + verify() + assert events == ["probe"] + assert restart.incomplete is had_gateway + receipt = json.loads((update_cmd.get_hermes_home() / "logs/update_receipts/latest.json").read_text()) + assert receipt["outcome"] == ("partial" if had_gateway else "success") diff --git a/tests/hermes_cli/test_update_interrupted_recovery.py b/tests/hermes_cli/test_update_interrupted_recovery.py deleted file mode 100644 index dc937a9d34..0000000000 --- a/tests/hermes_cli/test_update_interrupted_recovery.py +++ /dev/null @@ -1,27 +0,0 @@ -"""Tests for interrupted-install self-heal (the ``.update-incomplete`` marker). - -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 - -import hermes_cli.main as m - - -def test_marker_round_trip(tmp_path, monkeypatch): - monkeypatch.setattr(m, "PROJECT_ROOT", tmp_path) - marker = m._update_marker_path() - assert marker == tmp_path / ".update-incomplete" - assert not marker.exists() - - m._write_update_incomplete_marker() - assert marker.exists() - body = marker.read_text() - assert "started=" in body - assert "pid=" in body - - m._clear_update_incomplete_marker() - assert not marker.exists() diff --git a/tests/hermes_cli/test_update_modified_notice.py b/tests/hermes_cli/test_update_modified_notice.py index 1dfd2272d4..34d3077a84 100644 --- a/tests/hermes_cli/test_update_modified_notice.py +++ b/tests/hermes_cli/test_update_modified_notice.py @@ -1,62 +1,30 @@ -"""Guard: every `hermes update` path that reports user-modified skills must -also tell the user how to find them. +"""Skill sync keeps user edits and tells the updater's user how to inspect them.""" -`hermes update` keeps (does not overwrite) bundled skills the user edited and -prints a ``~ N user-modified (kept)`` count. There are two independent update -code paths in ``hermes_cli/main.py`` that print this notice (the git-pull path -in ``_cmd_update_impl`` and the unpack/install path). Both must point the user -at ``hermes skills list-modified`` so the count is actionable — otherwise, -depending on which path a user hits, they may never learn the discovery command -exists. - -This is an *invariant* test (the two sibling notices must agree), not a literal -snapshot: it asserts the relationship "count line ⇒ discovery hint", so it -keeps holding if the wording is reworded, as long as both sites stay in sync. -""" - -import re -from pathlib import Path - -import hermes_cli.main as main_mod -import hermes_cli.update_cmd as update_mod -import hermes_cli.update_cmd_maint as update_maint_mod -import hermes_cli.update_cmd_zip as update_zip_mod +from tools import skills_sync +from hermes_cli.update_cmd_maint import _print_bundled_skills_sync_report -_COUNT_RE = re.compile(r"user-modified \(kept\)") -_HINT_RE = re.compile(r"hermes skills list-modified") +def test_kept_skill_edits_have_an_actionable_update_report(tmp_path, monkeypatch, capsys): + bundled = tmp_path / "bundled" + source = bundled / "example/SKILL.md" + source.parent.mkdir(parents=True) + source.write_text("---\nname: example\ndescription: fixture\n---\nOriginal\n", encoding="utf-8") + installed = tmp_path / "skills" + monkeypatch.setattr(skills_sync, "SKILLS_DIR", installed) + monkeypatch.setattr(skills_sync, "MANIFEST_FILE", installed / ".manifest.json") + monkeypatch.setattr(skills_sync, "_get_bundled_dir", lambda: bundled) + monkeypatch.setattr(skills_sync, "_get_optional_dir", lambda: tmp_path / "optional") + monkeypatch.setattr("agent.skill_utils.get_external_skills_dirs", lambda: []) + _print_bundled_skills_sync_report() + user_skill = installed / "example/SKILL.md" + assert user_skill.read_text(encoding="utf-8") == source.read_text(encoding="utf-8") + assert "list-modified" not in capsys.readouterr().out + user_skill.write_text("My local instructions\n", encoding="utf-8") + source.write_text(source.read_text(encoding="utf-8") + "Upstream revision\n", encoding="utf-8") -def _source_lines() -> list[str]: - # The update pipeline was extracted to hermes_cli/update_cmd.py and then - # split into update_cmd_*.py; scan every home of the notice. - return [ - line - for mod in (main_mod, update_mod, update_maint_mod, update_zip_mod) - for line in Path(mod.__file__).read_text(encoding="utf-8").splitlines() - ] - - -def test_every_user_modified_notice_points_at_list_modified(): - lines = _source_lines() - count_sites = [i for i, ln in enumerate(lines) if _COUNT_RE.search(ln)] - - # The notice must exist somewhere (guard against it being deleted outright), - # but we deliberately do NOT assert a fixed *count* of sites: consolidating - # the duplicated print paths into a shared helper is a welcome refactor and - # must not fail this test. The invariant is per-site, not how many sites. - assert count_sites, ( - "no 'user-modified (kept)' notice found in main.py — the update " - "summary that surfaces kept user edits appears to have been removed" - ) - - for idx in count_sites: - # The count print and its discovery hint sit on adjacent lines; allow a - # small window so wording/formatting tweaks don't break the check. - window = "\n".join(lines[idx : idx + 5]) - assert _HINT_RE.search(window), ( - "a 'user-modified (kept)' notice near line " - f"{idx + 1} of main.py does not point users at " - "`hermes skills list-modified` within the following lines — the " - "update paths have drifted apart again:\n" + window - ) + _print_bundled_skills_sync_report() + out = capsys.readouterr().out + assert "1 user-modified (kept)" in out + assert "hermes skills list-modified" in out + assert user_skill.read_text(encoding="utf-8") == "My local instructions\n" \ No newline at end of file diff --git a/tests/hermes_cli/test_update_post_pull_syntax_guard.py b/tests/hermes_cli/test_update_post_pull_syntax_guard.py index a430abc915..be4c576cdb 100644 --- a/tests/hermes_cli/test_update_post_pull_syntax_guard.py +++ b/tests/hermes_cli/test_update_post_pull_syntax_guard.py @@ -1,93 +1,54 @@ -"""Tests for the post-pull syntax guard in ``hermes update``. +"""A pulled syntax error must restore the previous runnable Git tree.""" -When a bad commit lands on ``main`` with a syntax error in a critical file -(e.g. orphan merge-conflict markers in ``hermes_cli/config.py``), the CLI -becomes unbootable — every ``hermes`` invocation imports those files at -startup. The guard validates them after ``git pull`` and rolls back to the -pre-pull SHA on failure so the user's install stays runnable. +import subprocess +import sys -Reference incident: PR #28452 (May 18, 2026) shipped unresolved conflict -markers in ``hermes_cli/config.py``; users who ran ``hermes update`` in -the 7-minute window before #28458 landed could not run any ``hermes`` -command afterward. -""" +import pytest -from __future__ import annotations - -from pathlib import Path -from types import SimpleNamespace - -from hermes_cli import main as hermes_main -from hermes_cli import update_cmd +from hermes_cli import main, update_cmd -# --------------------------------------------------------------------------- -# _capture_head_sha -# --------------------------------------------------------------------------- +def test_pull_rolls_back_broken_critical_file_and_accepts_corrected_retry(tmp_path, monkeypatch, capsys): + def git(*args): + return subprocess.run(["git", *args], cwd=tmp_path, check=True, capture_output=True, text=True).stdout.strip() -def test_capture_head_sha_returns_stripped_sha(monkeypatch, tmp_path): - def fake_run(cmd, **kwargs): - assert cmd[-2:] == ["rev-parse", "HEAD"] - return SimpleNamespace(stdout="deadbeefcafe\n", returncode=0) + git("init", "-b", "main") + git("config", "user.email", "test@example.invalid") + git("config", "user.name", "Test") + source = tmp_path / "hermes_constants.py" + source.write_text("print('runnable')\n", encoding="utf-8") + git("add", ".") + git("commit", "-m", "working") + previous = git("rev-parse", "HEAD") + monkeypatch.setattr(main, "PROJECT_ROOT", tmp_path) + assert update_cmd._capture_head_sha(["git"], tmp_path) == previous + # Missing critical files are legitimate after refactors, not syntax failures. + assert update_cmd._validate_critical_files_syntax(tmp_path) == (True, None, None) - monkeypatch.setattr(hermes_main.subprocess, "run", fake_run) + source.write_text("<<<<<<< HEAD\n", encoding="utf-8") + git("commit", "-am", "broken upstream") + git("update-ref", "refs/remotes/origin/main", "HEAD") + git("reset", "--hard", previous) - assert update_cmd._capture_head_sha(["git"], tmp_path) == "deadbeefcafe" + def pull(): + return update_cmd._pull_updates( + ["git"], "main", None, prompt_for_restore=False, gw_input_fn=None, + discard_local_changes=False, keep_stash=False, + ) + with pytest.raises(SystemExit) as failure: + pull() + assert failure.value.code == 1 + assert "syntax error" in capsys.readouterr().out + assert git("rev-parse", "HEAD") == previous + assert subprocess.run([sys.executable, str(source)], capture_output=True, text=True, check=True).stdout == "runnable\n" -# --------------------------------------------------------------------------- -# _validate_critical_files_syntax -# --------------------------------------------------------------------------- - -def _populate_critical_tree(root: Path, *, broken_file: str | None = None) -> None: - """Create stub files for every entry in ``_UPDATE_CRITICAL_FILES``. - - If ``broken_file`` is given, that file gets orphan merge-conflict markers - (the exact failure mode from PR #28452). - """ - broken_payload = ( - "x = {\n" - ' "a": 1,\n' - "<<<<<<< HEAD\n" - ' "b": 2,\n' - "=======\n" - ' "c": 0b6d673e7,\n' # invalid binary literal — the actual error users saw - ">>>>>>> 0b6d673e7\n" - "}\n" - ) - for relpath in update_cmd._UPDATE_CRITICAL_FILES: - path = root / relpath - path.parent.mkdir(parents=True, exist_ok=True) - if relpath == broken_file: - path.write_text(broken_payload) - else: - path.write_text("# stub\n") - - - - -def test_validate_critical_files_syntax_tolerates_missing_files(tmp_path): - """A refactor may legitimately remove one of the critical files — the - guard should skip missing files, not falsely flag the install as broken.""" - # Populate everything except hermes_constants.py - for relpath in update_cmd._UPDATE_CRITICAL_FILES: - if relpath == "hermes_constants.py": - continue - path = tmp_path / relpath - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text("# stub\n") - - ok, failing_path, error = update_cmd._validate_critical_files_syntax(tmp_path) - - assert ok is True - assert failing_path is None - assert error is None - - -# --------------------------------------------------------------------------- -# Repo invariant — the production tree itself must always pass the guard. -# This catches the case where ``main`` ships a syntax error before the next -# release; if a future ``hermes update`` would brick users, this test fails -# in CI first. -# --------------------------------------------------------------------------- - + git("reset", "--hard", "origin/main") + source.write_text("print('corrected')\n", encoding="utf-8") + git("commit", "-am", "corrected upstream") + corrected = git("rev-parse", "HEAD") + git("update-ref", "refs/remotes/origin/main", corrected) + git("reset", "--hard", previous) + assert pull() == previous + assert git("rev-parse", "HEAD") == corrected + assert subprocess.run([sys.executable, str(source)], capture_output=True, text=True, check=True).stdout == "corrected\n" \ No newline at end of file diff --git a/tests/hermes_cli/test_update_receipt.py b/tests/hermes_cli/test_update_receipt.py index 3e65d9ed80..705ae49118 100644 --- a/tests/hermes_cli/test_update_receipt.py +++ b/tests/hermes_cli/test_update_receipt.py @@ -25,9 +25,7 @@ def receipt_home(tmp_path, monkeypatch): """Isolated HERMES_HOME for receipt writes.""" home = tmp_path / ".hermes" home.mkdir() - monkeypatch.setattr( - "hermes_cli.config.get_hermes_home", lambda: home, raising=False - ) + monkeypatch.setenv("HERMES_HOME", str(home)) # ensure no receipt bleeds between tests ur._current.set(None) yield home diff --git a/tests/hermes_cli/test_update_zip_completion.py b/tests/hermes_cli/test_update_zip_completion.py new file mode 100644 index 0000000000..89f9ddb6e8 --- /dev/null +++ b/tests/hermes_cli/test_update_zip_completion.py @@ -0,0 +1,220 @@ +"""ZIP completion uses the Git maintenance/fleet path after a real local swap.""" +from __future__ import annotations + +import json +from pathlib import Path +import subprocess +from types import SimpleNamespace +from urllib.request import urlretrieve +import zipfile + +import pytest + +from hermes_cli import main, update_cmd, update_cmd_fleet as fleet, update_cmd_maint as maint +from hermes_cli import update_cmd_zip, update_receipt +from hermes_cli.config_defaults import DEFAULT_CONFIG +from hermes_cli.update_inventory import RuntimeRecord, UpdatePlan +import hermes_yaml + + +@pytest.fixture +def zip_update(tmp_path, monkeypatch): + home = tmp_path / "home" + active = home / ".hermes" + sibling = active / "profiles/other" + sibling.mkdir(parents=True) + monkeypatch.setattr(Path, "home", lambda: home) + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("HERMES_HOME", str(active)) + monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "store")) + for profile in (active, sibling): + (profile / "config.yaml").write_text( + f"_config_version: {DEFAULT_CONFIG['_config_version'] - 1}\n" + "model:\n default: retained-model\n", encoding="utf-8") + (active / ".env").write_text("EXAMPLE_TOKEN=retained\n", encoding="utf-8") + jobs = active / "cron/jobs.json" + jobs.parent.mkdir() + original_jobs = {"jobs": [{"id": "keep-me", "prompt": "retained schedule"}]} + jobs.write_text(json.dumps(original_jobs), encoding="utf-8") + + root = tmp_path / "checkout" + root.mkdir() + (root / "pyproject.toml").write_text('[project]\nversion="1.0"\n', encoding="utf-8") + (root / "payload.txt").write_text("old", encoding="utf-8") + archive = tmp_path / "source.zip" + with zipfile.ZipFile(archive, "w") as out: + out.writestr("hermes-agent-main/pyproject.toml", '[project]\nversion="2.0"\n') + out.writestr("hermes-agent-main/payload.txt", "new") + # Only redirect transport: extraction, staging, dirty recheck and swap run. + monkeypatch.setattr("urllib.request.urlretrieve", lambda url, dst: urlretrieve(archive.as_uri(), dst)) + monkeypatch.setattr(main, "PROJECT_ROOT", root) + events = [] + token = {"resume_needed": True, "profiles": {}, "unmapped": []} + plan = UpdatePlan(install_method="git", expected_version="1.0", profiles=["default", "other"], + runtimes=[RuntimeRecord(kind="serve", profile="default", pid=99999999, + supervisor="manual-serve")]) + monkeypatch.setattr("hermes_cli.update_inventory.collect_runtime_inventory", lambda: plan) + monkeypatch.setattr(main, "_pause_windows_gateways_for_update", lambda: token) + monkeypatch.setattr("atexit.register", lambda *args: None) + monkeypatch.setattr(main, "_desktop_packaged_executable", lambda root: None) + monkeypatch.setattr(main, "_desktop_dist_exists", lambda root: False) + monkeypatch.setattr(update_cmd, "_source_update_channel", lambda args: "main") + monkeypatch.setattr(maint, "_sweep_bytecode_after_update", lambda branch: None) + + def prepare(selected, *, desktop): + assert selected == root and desktop is False + assert (root / "payload.txt").read_text() == "new" + events.append("prepare") + # The pre-update snapshot must reach the real cron-loss safety net. + jobs.write_text('{"jobs": []}', encoding="utf-8") + monkeypatch.setattr(maint, "_prepare_updated_checkout", prepare) + # PM/builds and machine-level repair are independently covered. Keep real + # config migration, profile env backfill, snapshot recovery and receipts. + monkeypatch.setattr("hermes_cli.macos_tcc_anchor.ensure_tcc_anchor", lambda: None) + monkeypatch.setattr(maint, "_print_post_update_notices_and_self_heals", lambda: None) + monkeypatch.setattr(maint, "_print_bundled_skills_sync_report", lambda: None) + monkeypatch.setattr("hermes_cli.profiles.seed_profile_skills", lambda *a, **kw: {}) + monkeypatch.setattr("plugins.memory.honcho.cli.sync_honcho_profiles_quiet", lambda: []) + monkeypatch.setattr(update_cmd, "_reload_config_modules", lambda: None) + monkeypatch.setattr(update_cmd, "_post_update_sqlite_runtime_status", lambda: (True, None)) + monkeypatch.setattr(fleet, "_print_legacy_units_warning", lambda: None) + monkeypatch.setattr(maint, "_refresh_dashboard_after_update", lambda **kwargs: None) + monkeypatch.setattr(update_cmd, "_surviving_pre_update_serve_runtimes", lambda plan: []) + monkeypatch.setattr(fleet, "_collect_fleet_snapshot", lambda *args: []) + monkeypatch.setattr("hermes_cli.gateway_migrate.maybe_auto_migrate_after_update", lambda: None) + + def resume(received): + assert received is token + assert token["resume_needed"], "completion must resume only once" + token["resume_needed"] = False + events.append("resume") + monkeypatch.setattr(main, "_resume_windows_gateways_after_update", resume) + monkeypatch.setattr(update_cmd, "_write_gateway_update_exit_code", lambda ok: events.append(("marker", ok))) + + def restart(received, gateway_mode): + assert received is plan + events.append("restart") + return fleet._GatewayRestartOutcome( + incomplete=False, phase_errors=[], pre_restart_gateway_pids=[], + restarted_services=[], failed_or_stale_units=[], relaunched_profiles=[], + externally_supervised_profiles=[], killed_pids=set()) + monkeypatch.setattr(update_cmd, "_restart_gateway_fleet_after_update", restart) + real_finalize = update_receipt.finalize_update_receipt + + def finalize(*args, **kwargs): + events.append("finalize") + return real_finalize(*args, **kwargs) + monkeypatch.setattr(update_receipt, "finalize_update_receipt", finalize) + yield SimpleNamespace(root=root, active=active, sibling=sibling, jobs=jobs, + original_jobs=original_jobs, events=events, token=token, plan=plan) + update_receipt._current.set(None) + + +@pytest.mark.parametrize("route", ["direct", "git-failure"]) +@pytest.mark.parametrize("gateway_mode", [False, True]) +def test_zip_command_migrates_profiles_recovers_snapshot_and_verifies_fleet( + zip_update, monkeypatch, route, gateway_mode, capsys, +): + state = zip_update + monkeypatch.setattr(update_cmd, "_prepare_git_command", lambda: (route == "direct", ["git"], False)) + monkeypatch.setattr(main, "_warn_orphaned_update_autostashes", lambda *args: None) + + def fail_fetch(*args, **kwargs): + raise subprocess.CalledProcessError(1, ["git", "fetch"]) + monkeypatch.setattr(update_cmd, "_git_run", fail_fetch) + # Choose the fallback branch without pretending this host is Windows. + monkeypatch.setattr(update_cmd, "_should_zip_fallback_on_update_error", lambda exc: True) + update_cmd._cmd_update_impl(SimpleNamespace(branch="main", yes=True), gateway_mode) + + for profile in (state.active, state.sibling): + config = hermes_yaml.safe_load((profile / "config.yaml").read_text()) + assert config["_config_version"] == DEFAULT_CONFIG["_config_version"] + assert config["model"]["default"] == "retained-model" + assert (state.sibling / ".env").read_bytes() == (state.active / ".env").read_bytes() + assert json.loads(state.jobs.read_text()) == state.original_jobs + assert (state.root / "payload.txt").read_text() == "new" + assert "v1.0 → v2.0" in capsys.readouterr().out + assert state.events == ["prepare", *([("marker", True)] if gateway_mode else []), + "restart", "resume", "finalize"] + receipt = json.loads((state.active / "logs/update_receipts/latest.json").read_text()) + assert receipt["outcome"] == "success" + assert receipt["runtime_outcomes"][0]["outcome"] == "restarted" + assert update_receipt._current.get() is None + assert state.token["resume_needed"] is False + assert not list(state.root.glob("*.hermes-update-*")) + + +@pytest.mark.parametrize("verdict", ["healthy", "unsafe-sqlite", "stale-fleet"]) +def test_zip_helper_preserves_bool_contract_after_real_verification(zip_update, monkeypatch, verdict): + state = zip_update + args = SimpleNamespace(branch="main", yes=True, gateway=True) + plan = update_cmd._begin_update_receipt_and_plan(args) + snapshot = main._run_pre_update_backup(args) + if verdict == "unsafe-sqlite": + monkeypatch.setattr(update_cmd, "_post_update_sqlite_runtime_status", lambda: ( + False, SimpleNamespace(sqlite_version_string="unsafe test runtime"))) + elif verdict == "stale-fleet": + monkeypatch.setattr(update_cmd, "_surviving_pre_update_serve_runtimes", lambda plan: [ + {"pid": plan.runtimes[0].pid, "profile": "default"}]) + result = update_cmd_zip._update_via_zip( + args, pre_update_snapshot_id=snapshot, _pre_update_plan=plan, + _windows_gateway_resume=state.token) + assert result is (verdict == "healthy") + assert state.events == ["prepare", ("marker", verdict != "unsafe-sqlite"), + "restart", "resume", "finalize"] + receipt = json.loads((state.active / "logs/update_receipts/latest.json").read_text()) + assert receipt["outcome"] == ("success" if verdict == "healthy" else "partial") + assert receipt["runtime_outcomes"][0]["outcome"] == ( + "unaccounted" if verdict == "stale-fleet" else "restarted") + assert json.loads(state.jobs.read_text()) == state.original_jobs + + +@pytest.mark.parametrize("route", ["direct", "git-failure"]) +@pytest.mark.parametrize("failure", ["swap", "preparation"]) +def test_zip_failure_recovers_pause_without_completion_mutations(zip_update, monkeypatch, route, failure): + import os + import pm + + state = zip_update + before = {profile: (profile / "config.yaml").read_bytes() + for profile in (state.active, state.sibling)} + old_project = (state.root / "pyproject.toml").read_bytes() + monkeypatch.setattr(update_cmd, "_prepare_git_command", lambda: (route == "direct", ["git"], False)) + monkeypatch.setattr(main, "_warn_orphaned_update_autostashes", lambda *args: None) + monkeypatch.setattr(update_cmd, "_should_zip_fallback_on_update_error", lambda exc: True) + + def fail_fetch(*args, **kwargs): + raise subprocess.CalledProcessError(1, ["git", "fetch"]) + monkeypatch.setattr(update_cmd, "_git_run", fail_fetch) + installed = [] + if failure == "swap": + rename = os.rename + + def fail_second_swap(src, dst): + if str(src).endswith(".hermes-update-staging"): + installed.append(dst) + if len(installed) == 2: + raise OSError("locked replacement") + return rename(src, dst) + monkeypatch.setattr(os, "rename", fail_second_swap) + expected = SystemExit + else: + def fail_preparation(*args, **kwargs): + assert (state.root / "payload.txt").read_text() == "new" + raise pm.InstallError("venv", "preparation stopped") + monkeypatch.setattr(maint, "_prepare_updated_checkout", fail_preparation) + expected = pm.InstallError + with pytest.raises(expected) as raised: + update_cmd._cmd_update_impl(SimpleNamespace(branch="main", yes=True), gateway_mode=True) + if failure == "swap": + assert raised.value.code == 1 + assert len(installed) == 2 + assert (state.root / "pyproject.toml").read_bytes() == old_project + assert (state.root / "payload.txt").read_text() == "old" + assert state.events == ["resume"] + assert state.token["resume_needed"] is False + assert {profile: (profile / "config.yaml").read_bytes() for profile in before} == before + assert not (state.sibling / ".env").exists() + assert json.loads(state.jobs.read_text()) == state.original_jobs + assert not (state.active / "logs/update_receipts/latest.json").exists() + assert not list(state.root.glob("*.hermes-update-*")) diff --git a/tests/hermes_cli/test_update_zip_sync_failure.py b/tests/hermes_cli/test_update_zip_sync_failure.py index b8d9636920..928b6e20fa 100644 --- a/tests/hermes_cli/test_update_zip_sync_failure.py +++ b/tests/hermes_cli/test_update_zip_sync_failure.py @@ -65,15 +65,23 @@ def test_pull_dependency_failure_keeps_recovery_marker(tmp_path, monkeypatch): @pytest.mark.parametrize("complete", [False, True]) def test_zip_callers_propagate_completion(tmp_path, monkeypatch, route, gateway_mode, complete): monkeypatch.setattr(main, "PROJECT_ROOT", tmp_path) - monkeypatch.setattr(update_cmd, "_update_via_zip", lambda *args, **kwargs: complete) exit_markers = [] monkeypatch.setattr(update_cmd, "_write_gateway_update_exit_code", exit_markers.append) + + def zip_completion(received, **context): + assert received is args + assert context["gateway_mode"] is gateway_mode + # Completion owns the marker now; neither caller may emit it again. + if context["gateway_mode"]: + update_cmd._write_gateway_update_exit_code(complete) + return complete + monkeypatch.setattr(update_cmd, "_update_via_zip", zip_completion) resumed = [] args = SimpleNamespace(branch="main") if route == "direct": monkeypatch.setattr(update_cmd, "_resolve_update_options", lambda *args: SimpleNamespace( - gw_input_fn=None, assume_yes=True)) + gw_input_fn=None, assume_yes=True, pre_update_version=None)) monkeypatch.setattr(update_cmd, "_begin_update_receipt_and_plan", lambda args: None) monkeypatch.setattr(main, "_run_pre_update_backup", lambda args: None) monkeypatch.setattr(main, "_pause_windows_gateways_for_update", lambda: None) @@ -99,4 +107,4 @@ def test_zip_callers_propagate_completion(tmp_path, monkeypatch, route, gateway_ invoke() assert failure.value.code == 1 assert exit_markers == ([complete] if gateway_mode else []) - assert resumed == ([None] if route == "direct" else []) + assert resumed == [] # no paused gateways: completion owns the success-path resume diff --git a/tests/hermes_cli/test_venv_holder_windows_live.py b/tests/hermes_cli/test_venv_holder_windows_live.py index 86aae7df3f..ea802d8dec 100644 --- a/tests/hermes_cli/test_venv_holder_windows_live.py +++ b/tests/hermes_cli/test_venv_holder_windows_live.py @@ -1,8 +1,8 @@ -"""LIVE Windows E2E for the venv-holder preflight (fleet-update #91277). +"""LIVE Windows E2E for retained lifecycle holder discovery (fleet-update #91277). Runs ONLY on a real Windows host (the on-demand ``windows-venv-e2e.yml`` lane). Spawns REAL processes with realistic Hermes argv shapes and drives -the actual detection / classification / exemption code against the live +the actual detection code against the live process table — no mocked psutil, no faked cmdlines. Each test documents which cluster issue it probes. Tests written BEFORE @@ -10,14 +10,9 @@ the consolidation fix intentionally pin the CORRECT behavior, so on unfixed main the buggy ones fail — that failure on the Windows runner is the empirical premise-check for each issue: - #90778 — holder message mislabels `hermes dashboard` as the Desktop - backend, and matches subcommands by substring ("--preserve" - contains "serve"). - #78089 — pausable-gateway exemption vs. long managed-runtime - interpreter paths (claimed fixed on main; verified here). + #78089 — full command lines with long managed-runtime interpreter paths. #87594 — ancestor-exclusion hides the gateway from the scan when the updater is spawned BY the gateway (/update path). - #81774 — serve backends have no pause path (documented behavior probe). """ from __future__ import annotations @@ -31,7 +26,7 @@ from pathlib import Path import pytest pytestmark = [ - pytest.mark.skipif(sys.platform != "win32", reason="live Windows venv-holder E2E"), + pytest.mark.platforms("windows"), # ``_spawn`` sleepers carry a "gateway run" argv tail as inert data (the guard's real-gateway # spawn check matches it); every child is ``_kill``ed by the test. pytest.mark.spawns_gateway_lookalike, @@ -186,168 +181,3 @@ class TestAncestorExclusion: "gateway ancestor invisible to venv scan — /update from the " f"gateway can never pause it (#87594): {payload}" ) - - -class TestUpdaterOwnedBackendDeferral: - """#98336 — ledger-verified serve/dashboard holders defer to the CLI - updater's stop/relaunch rungs instead of dead-ending the Desktop - hand-off (and leaving hermes.exe locked → quarantine os error 32).""" - - @staticmethod - def _ledger_write(pid: int, purpose: str, spawner_pid: int | None, - spawner_create: float | None) -> None: - import psutil - - from hermes_cli.process_identity import ( - LedgerEntry, - _append_entry, - install_id, - ) - - entry = LedgerEntry( - pid=pid, - create_time=float(psutil.Process(pid).create_time()), - purpose=purpose, - install=install_id(), - spawner_pid=spawner_pid, - spawner_create=spawner_create, - registered_at=time.time(), - argv="", - ) - assert _append_entry(entry) - - def test_dead_spawner_serve_is_deferred_live(self, tmp_path, monkeypatch): - """A REAL serve-argv process, ledger-registered with a provably dead - spawner, must classify as updater-owned (deferred, not a blocker).""" - monkeypatch.setenv("HERMES_HOME", str(tmp_path)) - - import psutil - - # A real spawner that has already exited — its (pid, create_time) - # pair is provably dead. - spawner = subprocess.Popen( - [sys.executable, "-c", "import time; time.sleep(0.1)"], - ) - spawner_create = float(psutil.Process(spawner.pid).create_time()) - spawner.wait(timeout=30) - - backend = _spawn(["-m", "hermes_cli.main", "serve", "--host", "127.0.0.1"]) - try: - self._ledger_write(backend.pid, "serve", spawner.pid, spawner_create) - - from hermes_cli._scan_venv_blockers import _is_updater_owned_backend - - cmdline = " ".join(psutil.Process(backend.pid).cmdline()) - assert _is_updater_owned_backend(backend.pid, cmdline) is True, ( - "dead-spawner ledger serve must be deferred to the updater " - "rungs (#98336)" - ) - finally: - _kill(backend) - - def test_live_foreign_spawner_serve_still_blocks_live(self, tmp_path, monkeypatch): - """Same real serve process, but its recorded spawner is a LIVE - process outside this scan's ancestry — must keep blocking.""" - monkeypatch.setenv("HERMES_HOME", str(tmp_path)) - - import psutil - - supervisor = _spawn(["fake-supervisor"]) - backend = _spawn(["-m", "hermes_cli.main", "serve", "--host", "127.0.0.1"]) - try: - self._ledger_write( - backend.pid, - "serve", - supervisor.pid, - float(psutil.Process(supervisor.pid).create_time()), - ) - - from hermes_cli._scan_venv_blockers import _is_updater_owned_backend - - cmdline = " ".join(psutil.Process(backend.pid).cmdline()) - assert _is_updater_owned_backend(backend.pid, cmdline) is False, ( - "live foreign supervisor would respawn the backend — the " - "scan must keep refusing" - ) - finally: - _kill(supervisor, backend) - - def test_unregistered_serve_still_blocks_live(self, tmp_path, monkeypatch): - """A serve-argv process with NO ledger entry keeps blocking — - positive identity only, never argv-shape alone.""" - monkeypatch.setenv("HERMES_HOME", str(tmp_path)) - - import psutil - - backend = _spawn(["-m", "hermes_cli.main", "serve", "--host", "127.0.0.1"]) - try: - from hermes_cli._scan_venv_blockers import _is_updater_owned_backend - - cmdline = " ".join(psutil.Process(backend.pid).cmdline()) - assert _is_updater_owned_backend(backend.pid, cmdline) is False - finally: - _kill(backend) - - def test_handoff_desktop_ancestor_spawner_is_deferred_live(self, tmp_path, monkeypatch): - """The #98336 field topology: the recorded spawner is the scan's own - ANCESTOR (the Desktop performing the hand-off). Run the check in a - real child process whose parent chain contains the spawner.""" - monkeypatch.setenv("HERMES_HOME", str(tmp_path)) - - import json as _json - - import psutil - - backend = _spawn(["-m", "hermes_cli.main", "serve", "--host", "127.0.0.1"]) - - check_file = tmp_path / "check_owned.py" - check_file.write_text( - "import json, sys\n" - f"sys.path.insert(0, {str(PROJECT_ROOT)!r})\n" - "import psutil\n" - "from hermes_cli._scan_venv_blockers import _is_updater_owned_backend\n" - "pid = int(sys.argv[1])\n" - "cmdline = ' '.join(psutil.Process(pid).cmdline())\n" - "print(json.dumps({'owned': _is_updater_owned_backend(pid, cmdline)}))\n", - encoding="utf-8", - ) - # Fake-desktop parent: registers the backend in the ledger with - # ITSELF as spawner, then runs the check in a child — exactly the - # Desktop → scan-subprocess topology of the update preflight. - parent_file = tmp_path / "fake_desktop.py" - parent_file.write_text( - "import json, os, subprocess, sys, time\n" - f"sys.path.insert(0, {str(PROJECT_ROOT)!r})\n" - "import psutil\n" - "from hermes_cli.process_identity import LedgerEntry, _append_entry, install_id\n" - "backend_pid = int(sys.argv[1])\n" - "entry = LedgerEntry(pid=backend_pid,\n" - " create_time=float(psutil.Process(backend_pid).create_time()),\n" - " purpose='serve', install=install_id(),\n" - " spawner_pid=os.getpid(),\n" - " spawner_create=float(psutil.Process().create_time()),\n" - " registered_at=time.time(), argv='')\n" - "assert _append_entry(entry)\n" - f"r = subprocess.run([sys.executable, {str(check_file)!r}, sys.argv[1]],\n" - f" capture_output=True, text=True, cwd={str(PROJECT_ROOT)!r})\n" - "print(r.stdout.strip())\n" - "sys.stderr.write(r.stderr[-500:])\n", - encoding="utf-8", - ) - try: - result = subprocess.run( - [sys.executable, str(parent_file), str(backend.pid)], - capture_output=True, - text=True, - cwd=str(PROJECT_ROOT), - timeout=120, - env={**os.environ, "HERMES_HOME": str(tmp_path)}, - ) - line = result.stdout.strip().splitlines()[-1] if result.stdout.strip() else "{}" - payload = _json.loads(line) - assert payload.get("owned") is True, ( - "hand-off Desktop ancestor spawner must defer the backend " - f"(#98336): stdout={result.stdout!r} stderr={result.stderr[-500:]!r}" - ) - finally: - _kill(backend) diff --git a/tests/hermes_cli/test_web_memory_provider_setup_install.py b/tests/hermes_cli/test_web_memory_provider_setup_install.py index 09e504d2cb..80ba9af815 100644 --- a/tests/hermes_cli/test_web_memory_provider_setup_install.py +++ b/tests/hermes_cli/test_web_memory_provider_setup_install.py @@ -17,7 +17,7 @@ def test_setup_admits_real_provider_union_and_keeps_selection_on_failure(tmp_pat import pm from hermes_constants import venv_python_path from hermes_cli.runtime_paths import selected_venv - from tests.pm.test_workspace_build_inputs import _wheel + from tests.pm._fixtures import _wheel from hermes_cli import memory_setup from hermes_cli.web_server_memory import _memory_provider_setup_info diff --git a/tests/install/README.md b/tests/install/README.md index f1646d37e9..a3b7d7d5cc 100644 --- a/tests/install/README.md +++ b/tests/install/README.md @@ -40,9 +40,10 @@ Each leg with the script drivers has these phases: 1. Stage: make the bare clone, park `main` at the old release. 2. Install: run the old release's own installer script. Make sure that the checkout is at the old commit and that `hermes --version` works. -3. Desktop smoke: run `hermes desktop --build-only` from the installed CLI. This proves that the installed version can build the desktop app. If the installed version does not have this flag, the phase reports a skip and continues. -4. Update: move `main` to HEAD. Apply one update method. Make sure that the checkout is at HEAD and that `hermes --version` works. -5. Desktop smoke again, at HEAD. +3. Update: move `main` to HEAD. Apply one update method. An app update must produce a new successful receipt or handoff result; a changed checkout alone is not completion. +4. Verify the installed command and products before running `hermes --version`. Select the command under the installation's `.hermes/bin`; use the old venv only for a source tree without PM. Check PM currency and compiler receipts where supported. Preserve the no-desktop scenario for a plain install. Do not rebuild, remove dependencies, or force-stop an updater during verification. Historical installs without these receipts get artifact-presence checks, not a freshness claim. + +The cheap fixture checks are `tests/scripts/test_source_driver.py` and `tests-js/source-update-observer.test.mjs`. They do not run installers or prove native GUI relaunch. The source app helper does not relaunch the updated app for it; the native packaged-update drivers own automatic-relaunch acceptance. The observer does not change source files, products, dependency selections, or facts. The Windows `desktop-installer@latest` install downloads the published `Hermes-Setup.exe` and drives its GUI with AutoHotkey. The selected update diff --git a/tests/install/e2e-assets/launch-from-spec.mjs b/tests/install/e2e-assets/launch-from-spec.mjs index bfe372e08c..9522f1b882 100644 --- a/tests/install/e2e-assets/launch-from-spec.mjs +++ b/tests/install/e2e-assets/launch-from-spec.mjs @@ -16,13 +16,10 @@ * [--expect-sha --repo-dir ] [--no-update] * * --no-update: launch + wait for the window + close. The smoke arm. - * Otherwise: click Update now, then poll for completion. Two signals, - * either satisfies (poll whichever are given, first hit wins): - * --result the windows hand-off's result file - * (HERMES_HOME/.hermes-update-result.json) - * --expect-sha the installed checkout reaching the expected commit - - * the source-install signal, where the About pane's update - * runs `hermes update` and no result file exists. + * Otherwise: click Update now, then require a new successful handoff result + * or source-update receipt, the expected checkout, and marker removal. + * A checkout reset alone is not completion. No driver-assisted relaunch, + * force-kill, or rebuild may turn an unfinished update into a passing one. * The Playwright close event is unreliable across the update handoff, so * neither signal is an app event. */ @@ -33,6 +30,7 @@ import { execFileSync } from 'node:child_process'; import { parseArgs } from 'node:util'; import { _electron } from '@playwright/test'; import { prepareWindowForInput } from './window-input.cjs'; +import { observeSourceUpdate } from './source-update-observer.mjs'; /** * @typedef {{argv: string[], cwd: string, env: Record, @@ -279,14 +277,17 @@ async function main() { log(`[update-status] ${JSON.stringify(status)}`); throw e; } + const observe = observeSourceUpdate({ + home: spec.env.HERMES_HOME, + resultPath: values.result, + expectSha: values['expect-sha'], + }); await updateNow.click(); phase('update-poll'); log('clicked Update now; polling for result file'); // The app may relaunch/exit during the update; completion signals are // product state, not Playwright events. - const resultPath = values.result; - const expectSha = values['expect-sha']; const repoDir = values['repo-dir']; /** @returns {string} */ const headSha = () => { @@ -299,28 +300,19 @@ async function main() { } }; for (;;) { - if (resultPath && fs.existsSync(resultPath)) { - log(`update result present: ${fs.readFileSync(resultPath, 'utf8').slice(0, 200)}`); - break; - } - if (expectSha && repoDir && headSha() === expectSha) { - log(`checkout reached expected sha ${expectSha}`); + if (observe(repoDir ? headSha() : '')) { + log('successful update receipt and expected checkout observed'); break; } if (Date.now() > deadline) { await window.screenshot({ path: `${values.spec}.timeout.png` }).catch(() => {}); - throw new Error('update completion signal never appeared (result file / expected sha)'); + throw new Error('no successful update completion receipt; leaving the install untouched'); } await new Promise((r) => setTimeout(r, 2_000)); } - // ── Post-update: observe the hand-off state, then relaunch and verify ── - // On CI runners the rebuilt app cannot self-relaunch (chrome-sandbox needs - // root ownership; user namespaces are restricted), so the product parks on - // an "update complete, reopen Hermes to finish" overlay and never exits; - // a bare app.close() would wait on it forever. Record the hand-off state, - // close with a bounded teardown, then do what the overlay asks (the real - // user journey) and assert the relaunched app runs the updated code. + // Record what the product did. The enclosing source driver verifies the + // produced artifacts read-only; this helper does not repair or relaunch. phase('post-update'); const handoff = await window.evaluate(() => { const text = document.body ? document.body.innerText : '' @@ -330,129 +322,13 @@ async function main() { log(handoff ? `post-update hand-off state: "${handoff}"` : 'post-update: no hand-off overlay observed (app may self-relaunch)'); await window.screenshot({ path: `${values.spec}.post-update.png` }).catch(() => {}); - const boundedClose = async (application, label) => { - // ElectronApplication.process() can throw on darwin once the app has - // started tearing down; never assume it is callable. - let proc = null; - try { proc = application.process(); } catch { /* connection gone */ } - const rootPid = proc?.pid; - // Snapshot descendants BEFORE closing: once the root dies its children - // reparent to init and a PPID walk can no longer find them. - let doomed = []; - if (rootPid && process.platform !== 'win32') { - try { - const out = execFileSync('ps', ['-eo', 'pid=,ppid='], { encoding: 'utf8' }); - const children = new Map(); - for (const line of out.trim().split('\n')) { - const [pid, ppid] = line.trim().split(/\s+/).map(Number); - if (!children.has(ppid)) children.set(ppid, []); - children.get(ppid).push(pid); - } - const queue = [rootPid]; - while (queue.length) { - const next = queue.shift(); - for (const child of children.get(next) || []) { - doomed.push(child); - queue.push(child); - } - } - } catch (e) { - log(`${label}: descendant snapshot failed (continuing): ${String(e).slice(0, 120)}`); - } - } - const closed = await Promise.race([ - application.close().then(() => true).catch(() => true), - new Promise((r) => setTimeout(() => r(false), 15_000)), + if (app.windows().length) { + await Promise.race([ + app.close(), + new Promise((_, reject) => setTimeout(() => reject(new Error('app did not close after completion; no force-kill attempted')), 15_000)), ]); - if (!closed) { - // SIGTERM first: Electron runs its exit handlers, and any npm/node - // children the in-app update spawned get a chance to settle instead - // of leaving node_modules half-written. - log(`${label}: graceful close timed out after 15s - SIGTERM, then SIGKILL if needed`); - if (proc) { - try { proc.kill('SIGTERM'); } catch { /* already gone */ } - const terminated = await new Promise((r) => { - const timer = setTimeout(() => r(false), 10_000); - proc.once('exit', () => { clearTimeout(timer); r(true); }); - }); - if (!terminated) { - log(`${label}: SIGTERM ignored after 10s - SIGKILL`); - try { proc.kill('SIGKILL'); } catch { /* already gone */ } - } - } else { - log(`${label}: no process handle to signal - relying on descendant sweep`); - } - } - // Killing the Electron root does not cascade: the spawned backend - // (`hermes serve` python + node helpers) survives and keeps writing - // under the install dir. SIGTERM the snapshot first (orderly backend - // shutdown), then SIGKILL stragglers. - if (doomed.length) { - for (const pid of doomed) { - try { process.kill(pid, 'SIGTERM'); } catch { /* raced exit - fine */ } - } - await new Promise((r) => setTimeout(r, 5_000)); - let killed = 0; - for (const pid of doomed) { - try { process.kill(pid, 'SIGKILL'); killed++; } catch { /* exited on TERM */ } - } - log(`${label}: swept ${doomed.length} descendant process(es) (${killed} needed SIGKILL)`); - } - }; - await boundedClose(app, 'updated-app teardown'); - - // Relaunch from the same captured spec - the leg's own launch mechanism - - // and require the UI to come up on the updated checkout. Verification: - // the renderer's DOM carries the running build's short sha when launched - // from a git checkout (statusbar/About); require the EXPECTED sha's short - // form, or at minimum a live UI window, logging what we saw. - phase('relaunch'); - log('relaunching the updated app (the "reopen Hermes" step)'); - const relaunch = await _electron.launch({ - executablePath: launch.executablePath, - args: launch.args, - cwd: launch.cwd, - env: launch.env, - }); - let window2 = null; - const relaunchDeadline = Date.now() + 120_000; - while (!window2 && Date.now() < relaunchDeadline) { - for (const candidate of relaunch.windows()) { - const hasUi = await candidate - .evaluate(() => document.querySelector('button') !== null) - .catch(() => false); - if (hasUi) { window2 = candidate; break; } - } - if (!window2) await new Promise((r) => setTimeout(r, 1_000)); } - if (!window2) { - await boundedClose(relaunch, 'relaunch teardown'); - throw new Error('relaunched app never presented a UI window within 120s - updated build may be broken'); - } - // Give the shell a moment to paint the statusbar/version chrome. - await new Promise((r) => setTimeout(r, 10_000)); - const shortSha = (expectSha || '').slice(0, 7); - const verdict = await window2.evaluate((sha) => { - const text = document.body ? document.body.innerText : '' - const version = (text.match(/v\d+\.\d+\.\d+[^\n]*/) || [null])[0] - return { version, hasSha: sha ? text.includes(sha) : false, sample: text.slice(-200) } - }, shortSha).catch(() => null); - await window2.screenshot({ path: `${values.spec}.relaunched.png` }).catch(() => {}); - log(`relaunched app: version="${verdict?.version || 'unseen'}" expectedSha(${shortSha}) in DOM=${verdict?.hasSha}`); - if (!verdict) { - await boundedClose(relaunch, 'relaunch teardown'); - throw new Error('relaunched app UI came up but could not be read'); - } - if (shortSha && !verdict.hasSha) { - // Not fatal on its own: packaged builds do not always surface the sha in - // the DOM. The window came up on the updated install dir, which is the - // user-facing contract; log loudly so a human can tighten this later. - log(`NOTE: expected short sha ${shortSha} not found in relaunched DOM; version line was "${verdict.version}"`); - } - log('relaunch verification complete: updated app boots and presents UI'); - await boundedClose(relaunch, 'relaunch teardown'); - // Explicit exit: SIGKILLed Electron leaves driver connections holding - // the event loop; falling off main() never terminates. + log('update completed without driver-assisted recovery (automatic relaunch not asserted here)'); process.exit(0); } diff --git a/tests/install/e2e-assets/source-driver.ps1 b/tests/install/e2e-assets/source-driver.ps1 new file mode 100644 index 0000000000..b28a8842f2 --- /dev/null +++ b/tests/install/e2e-assets/source-driver.ps1 @@ -0,0 +1,19 @@ +# Dot-source only. Prefer the published command; never rescue a broken one +# through PATH, the user's shared bin, or an obsolete checkout venv. +function Get-SourceHermes([string]$Root) { + foreach ($name in @('hermes.exe', 'hermes.cmd')) { + $command = Join-Path $Root ".hermes/bin/$name" + if (Test-Path -LiteralPath $command) { + if (-not (Test-Path -LiteralPath $command -PathType Leaf)) { + throw "Invalid published launcher: $command" + } + return $command + } + } + if (Test-Path -LiteralPath (Join-Path $Root 'pm/lock.json')) { + throw "Missing published launcher under $Root/.hermes/bin" + } + $legacy = Join-Path $Root 'venv/Scripts/hermes.exe' + if (Test-Path -LiteralPath $legacy -PathType Leaf) { return $legacy } + throw "No installed Hermes command under $Root" +} \ No newline at end of file diff --git a/tests/install/e2e-assets/source-driver.sh b/tests/install/e2e-assets/source-driver.sh new file mode 100644 index 0000000000..0bff519baa --- /dev/null +++ b/tests/install/e2e-assets/source-driver.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Source-only helpers: never search PATH for a different installation. +source_hermes() { + local root="$1" command="$1/.hermes/bin/hermes" + if [ -e "$command" ] || [ -L "$command" ]; then + [ -f "$command" ] && [ -x "$command" ] || { + printf 'invalid published launcher: %s\n' "$command" >&2; return 1; + } + else + # A PM source tree promises publication during install/update. Falling + # back here would let --version complete an unfinished update for it. + [ ! -f "$root/pm/lock.json" ] || { + printf 'missing published launcher: %s\n' "$command" >&2; return 1; + } + command="$root/venv/bin/hermes" + [ -f "$command" ] && [ -x "$command" ] || { + printf 'no installed Hermes command under %s\n' "$root" >&2; return 1; + } + fi + printf '%s\n' "$command" +} \ No newline at end of file diff --git a/tests/install/e2e-assets/source-update-observer.mjs b/tests/install/e2e-assets/source-update-observer.mjs new file mode 100644 index 0000000000..beecee4c71 --- /dev/null +++ b/tests/install/e2e-assets/source-update-observer.mjs @@ -0,0 +1,34 @@ +// A checkout reset is the start of preparation, not its completion. +import { existsSync, readFileSync, readdirSync } from 'node:fs' +import { join } from 'node:path' + +export function observeSourceUpdate({ home, resultPath, expectSha }) { + const directory = join(home, 'logs/update_receipts') + const files = () => [ + ...(resultPath && existsSync(resultPath) ? [resultPath] : []), + ...(existsSync(directory) ? readdirSync(directory) + .filter(name => name.startsWith('update_') && name.endsWith('.json')) + .map(name => join(directory, name)) : []), + ] + const before = new Map(files().map(file => [file, readFileSync(file, 'utf8')])) + return head => { + let complete = false + for (const file of files()) { + const text = readFileSync(file, 'utf8') + if (before.get(file) === text) continue + let data + try { data = JSON.parse(text.replace(/^\uFEFF/, '')) } catch { continue } + if (file === resultPath) { + if (data.ok !== true || (data.exit_code != null && data.exit_code !== 0)) { + throw new Error(`update handoff failed: ${text}`) + } + complete = true + } else if (data.finished_at) { + if (data.outcome !== 'success') throw new Error(`source update failed: ${text}`) + if (!expectSha || data.post_update?.sha === expectSha) complete = true + } + } + return complete && (!expectSha || head === expectSha) + && !existsSync(join(home, '.hermes-update-in-progress')) + } +} \ No newline at end of file diff --git a/tests/install/e2e-assets/source_driver.py b/tests/install/e2e-assets/source_driver.py new file mode 100644 index 0000000000..cd234ee6b4 --- /dev/null +++ b/tests/install/e2e-assets/source_driver.py @@ -0,0 +1,105 @@ +"""Read-only source-install acceptance, before a CLI launch can repair it. + +The driver owns this file; probes import only the installed tree's passive +readers. No bootstrap, installer, build, PM worker, or application entry point. + +""" +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import subprocess +import sys + + +def desktop_outputs(root: Path) -> list[Path]: + desktop = root / "apps/desktop" + return [path for pattern in ( + "release/*/resources/app.asar.unpacked/dist", + "release/mac*/Hermes.app/Contents/Resources/app.asar.unpacked/dist", + ) for path in desktop.glob(pattern)] + + +def verify_products(root: Path, desktop: str, node: Path | None = None) -> None: + app = root / "apps/desktop" + outputs = desktop_outputs(root) + has_desktop = (app / "dist/index.html").exists() or any((app / "release").glob("*")) + if desktop == "absent" and has_desktop: + raise RuntimeError("unexpected desktop output in a no-desktop scenario") + if desktop == "present": + if not outputs or not any((out / "index.html").is_file() for out in outputs): + raise RuntimeError("desktop packaged renderer is missing or incomplete") + executables = [path for pattern in ( + "release/*/Hermes.exe", "release/*/Hermes", "release/*/hermes", + "release/mac*/Hermes.app/Contents/MacOS/Hermes", + ) for path in app.glob(pattern) if path.is_file() and path.stat().st_size] + if not executables: + raise RuntimeError("desktop executable is missing or empty") + if node is None: + return # Historical builds have no compiler receipt contract. + products = [("tui", root / "ui-tui/dist"), ("web", root / "hermes_cli/web_dist")] + if desktop == "present": + products.extend(("desktop", out) for out in outputs) + for product, out in products: + result = subprocess.run( + [str(node), str(root / "scripts/build/freshness.mjs"), + "--source", str(root), "--product", product, "--out", str(out)], + cwd=root, capture_output=True, text=True, check=True, timeout=120, + ) + if result.stdout.strip() != "true": + raise RuntimeError(f"{product} output is missing, stale, or damaged: {out}") + + +def probe_pm(root: Path, desktop: str, command: list[str]) -> None: + # Use the launcher's Python ABI, but deliberately do NOT execute its + # bootstrap: that would complete dependencies or recover markers for it. + sys.path.insert(0, str(root)) + from hermes_cli._launchers import runtime_command + from hermes_cli.runtime_paths import selected_venv, site_packages + + if command != runtime_command(root): + raise RuntimeError("published launcher belongs to another installation or Python") + if any((root / marker).exists() for marker in (".update-incomplete", ".lazy-refresh-incomplete")): + raise RuntimeError("source update left an incomplete marker; refusing launch-time recovery") + sys.path.insert(1, str(site_packages(selected_venv(root)))) + from pm.ensure import installed_package, venv_is_current + + if not venv_is_current(project_root=root): + raise RuntimeError("installed dependency generation is not current") + node = installed_package("node") + if node is None or node.binary is None: + raise RuntimeError("installed Node is missing or does not match the target pin") + verify_products(root, desktop, node.binary) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, required=True) + parser.add_argument("--launcher", type=Path, required=True) + parser.add_argument("--desktop", choices=("absent", "present"), required=True) + parser.add_argument("--runtime-command", help=argparse.SUPPRESS) + args = parser.parse_args() + root = args.root.resolve() + if args.runtime_command: + probe_pm(root, args.desktop, json.loads(args.runtime_command)) + elif (root / "pm/lock.json").is_file(): + env = dict(os.environ, PYTHONDONTWRITEBYTECODE="1", HERMES_DISABLE_LAZY_INSTALLS="1") + result = subprocess.run([str(args.launcher), "--print-runtime-command"], + capture_output=True, text=True, check=True, env=env, timeout=30) + command = json.loads(result.stdout) + if not isinstance(command, list) or not command or not all(isinstance(s, str) for s in command): + raise RuntimeError("invalid published runtime command") + subprocess.run([command[0], "-I", "-B", str(Path(__file__).resolve()), + "--root", str(root), "--launcher", str(args.launcher), + "--desktop", args.desktop, "--runtime-command", json.dumps(command)], + env=env, check=True, timeout=300) + else: + verify_products(root, args.desktop) + print("Historical install: artifact presence only (no PM/compiler receipt capability)") + print(f"Read-only source verification passed: {root} (desktop={args.desktop})") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tests/install/installer-script-e2e.sh b/tests/install/installer-script-e2e.sh index b3400286f2..e6a4df1d20 100755 --- a/tests/install/installer-script-e2e.sh +++ b/tests/install/installer-script-e2e.sh @@ -101,6 +101,8 @@ fail() { printf 'E2E ASSERTION FAILED: %s\n' "$*" >&2; exit 1; } source "$(dirname "$0")/e2e-assets/ts-prefix.sh" 2>/dev/null || ts_prefix() { cat; } # shellcheck source=../e2e-assets/preserve-plugins.sh source "$(dirname "$0")/e2e-assets/preserve-plugins.sh" +# shellcheck source=e2e-assets/source-driver.sh +source "$(dirname "$0")/e2e-assets/source-driver.sh" # Full transcript in the job log, collapsed (GitHub renders ::group:: as a # fold; plain text anywhere else). Win or lose -- a green install's log is # how you diagnose the leg that fails next. @@ -295,55 +297,22 @@ assert_checkout() { got="$(git -C "$INSTALL_DIR" rev-parse HEAD)" [ "$got" = "$1" ] || fail "installed checkout is $got, expected $2 ($1)" ok "checkout is $2 ($1)" - local hermes="$INSTALL_DIR/.hermes/bin/hermes" - case "$1" in "$OLD_SHA"|old) hermes="$INSTALL_DIR/venv/bin/hermes" ;; esac - [ -x "$hermes" ] || fail "no hermes console script at $hermes" - "$hermes" --version 2>&1 | ts_prefix > "$LOG_DIR/version-$2.log" \ + local hermes + hermes="$(source_hermes "$INSTALL_DIR")" || fail "no usable installed command at $2" + python3 -B "$REPO_ROOT/tests/install/e2e-assets/source_driver.py" \ + --root "$INSTALL_DIR" --launcher "$hermes" --desktop "$EXPECT_DESKTOP" \ + || fail "read-only verification failed at $2; no repair was attempted" + HERMES_DISABLE_LAZY_INSTALLS=1 PYTHONDONTWRITEBYTECODE=1 "$hermes" --version 2>&1 | ts_prefix > "$LOG_DIR/version-$2.log" \ || fail "hermes --version failed after $2; log in $LOG_DIR/version-$2.log" ok "hermes --version works: $(head -c 120 "$LOG_DIR/version-$2.log" | tr -d '\n')" } -smoke_desktop() { - # $1: label (old|head). Prove the installed CLI can produce the desktop - # app: `hermes desktop --build-only` runs the full desktop pipeline - # (workspace install, renderer build, stamp write) and stops before the - # launch -- the same call `hermes update` itself makes. Probe the - # INSTALLED hermes for the flag rather than assuming this checkout's - # surface: sampled OLD releases may predate `hermes desktop` or - # --build-only entirely, and for them the phase skips, loudly. - local hermes="$INSTALL_DIR/.hermes/bin/hermes" - case "$1" in "$OLD_SHA"|old) hermes="$INSTALL_DIR/venv/bin/hermes" ;; esac - local help - if ! help="$("$hermes" desktop --help 2>&1)"; then - if [ "$1" = old ] && printf '%s' "$help" | grep -q 'invalid choice.*desktop'; then - ok "old release predates desktop; skipping desktop smoke" - return 0 - fi - fail "desktop help failed at $1: $help" - fi - if ! printf '%s' "$help" | grep -qF -- --build-only; then - [ "$1" = old ] || fail "TARGET is missing desktop --build-only" - ok "old release predates --build-only; skipping desktop smoke" - return 0 - fi - local rc=0 - (cd "$INSTALL_DIR" && "$hermes" desktop --build-only < /dev/null 2>&1 \ - | ts_prefix > "$LOG_DIR/desktop-smoke-$1.log") || rc=$? - log_group "hermes desktop --build-only ($1) transcript" "$LOG_DIR/desktop-smoke-$1.log" - [ "$rc" -eq 0 ] || fail "hermes desktop --build-only ($1) exited $rc; transcript above" - ok "hermes desktop --build-only works at $1" - # TODO(launch): LAUNCH the built app and auto-close it. Mechanism when - # the pieces land: driver-side spawn interception (a sitecustomize.py on - # PYTHONPATH wraps subprocess.run under an env-var opt-in and captures - # the real argv/cwd/env at the spawn site) + Playwright _electron.launch - # on the captured spec; electronApp.close() is the auto-close. Blocked - # on that asset and, for linux runners, on a virtual display (Xvfb). -} - # --- install OLD --------------------------------------------------------------- step "installing OLD ($INSTALL_REF) via its own scripts/install.sh ($INSTALL_METHOD)" +EXPECT_DESKTOP=absent if [ "$INSTALL_METHOD" = "installer-script+desktop" ]; then + EXPECT_DESKTOP=present run_installer "$OLD_SHA" old desktop assert_checkout "$OLD_SHA" OLD assert_desktop_artifact OLD @@ -351,7 +320,6 @@ else run_installer "$OLD_SHA" old assert_checkout "$OLD_SHA" OLD fi -smoke_desktop old preserve_before_upgrade # --- update OLD -> HEAD ---------------------------------------------------------- @@ -366,8 +334,9 @@ case "$UPDATE_METHOD" in # `--yes` reaches the update subcommand only in later releases, and # argparse rejects the whole invocation when it does not exist. Ask the # installed hermes; older ones read the prompt from stdin, so close it. - HERMES="$INSTALL_DIR/venv/bin/hermes" - if "$HERMES" update --help 2>&1 | grep -qF -- --yes; then + HERMES="$(source_hermes "$INSTALL_DIR")" || fail "no installed update command" + help="$("$HERMES" update --help 2>&1)" || fail "installed update --help failed: $help" + if grep -qF -- --yes <<< "$help"; then update_cmd=("$HERMES" update --yes) else update_cmd=("$HERMES" update) @@ -382,6 +351,7 @@ case "$UPDATE_METHOD" in run_installer "$TARGET_SHA" "$TARGET_LABEL" ;; installer-script+desktop) + EXPECT_DESKTOP=present run_installer "$TARGET_SHA" "$TARGET_LABEL" desktop assert_desktop_artifact "$TARGET_LABEL" ;; @@ -393,7 +363,8 @@ case "$UPDATE_METHOD" in # e2e-assets/launch-capture/sitecustomize.py - and re-executes it under # _electron.launch. Everything before the spawn (build, stamps, sandbox # fixup) runs for real in the installed code. - HERMES="$INSTALL_DIR/venv/bin/hermes" + EXPECT_DESKTOP=present + HERMES="$(source_hermes "$INSTALL_DIR")" || fail "no installed desktop command" ASSETS="$REPO_ROOT/tests/install/e2e-assets" SPEC="$WORK_ROOT/launch-spec.json" @@ -428,7 +399,7 @@ case "$UPDATE_METHOD" in (cd "$PW_DIR" && npm install --no-save --no-audit --no-fund \ "@playwright/test@1.58.2" 2>&1 | ts_prefix > "$LOG_DIR/playwright-install.log") \ || { log_group "playwright install transcript" "$LOG_DIR/playwright-install.log"; fail "playwright install failed"; } - cp "$ASSETS/launch-from-spec.mjs" "$ASSETS/window-input.cjs" "$PW_DIR/" + cp "$ASSETS/launch-from-spec.mjs" "$ASSETS/source-update-observer.mjs" "$ASSETS/window-input.cjs" "$PW_DIR/" rc=0 (cd "$PW_DIR" && node launch-from-spec.mjs \ --spec "$SPEC" \ @@ -438,61 +409,6 @@ case "$UPDATE_METHOD" in | ts_prefix > "$LOG_DIR/app-update.log") || rc=$? log_group "app update (Playwright) transcript" "$LOG_DIR/app-update.log" [ "$rc" -eq 0 ] || fail "app-driven update exited $rc; transcript above" - # The in-app update spawns a DETACHED npm/updater whose parent chain does - # not pass through the Electron root, so the driver's descendant sweep - # cannot see it and a pre-clean can race a still-writing npm. - # Deterministic quiesce instead: find processes whose cwd is inside - # $INSTALL_DIR, wait for them to finish (they are the updater's tail), - # then escalate TERM -> KILL. cwd matching is precise to this sandbox; - # no name patterns. - step "quiescing $INSTALL_DIR before the head desktop smoke" - procs_in_install_dir() { - # Linux: /proc cwd links (fast, no tools needed). Darwin has no /proc: - # one lsof pass over ALL cwd descriptors, filtered by prefix in the - # reader. Deliberately NOT `+D "$INSTALL_DIR"`: lsof exits 1 when a +D - # match comes up empty, and under `set -euo pipefail` that non-zero - # kills the leg at the assignment. The unanchored form always matches - # other processes, so empty-for-OUR-dir is exit 0. - if [ -d /proc ]; then - local pid cwd - for pid in /proc/[0-9]*; do - cwd="$(readlink "$pid/cwd" 2>/dev/null)" || continue - case "$cwd" in "$INSTALL_DIR"*) echo "${pid#/proc/}";; esac - done - else - lsof -d cwd -F pn 2>/dev/null | awk -v dir="$INSTALL_DIR" ' - /^p/ { pid = substr($0, 2) } - /^n/ { if (index(substr($0, 2), dir) == 1) print pid }' - fi - } - # If the probe mechanism itself is broken (no lsof on the runner, output - # shape surprise), say so and skip the wait... a blind quiesce must be - # VISIBLE, not a vacuous "install dir quiet". - if [ ! -d /proc ] && ! command -v lsof >/dev/null 2>&1; then - echo "WARNING: no /proc and no lsof; quiesce is blind, proceeding on the pre-clean alone" - else - quiesce_deadline=$((SECONDS + 60)) - while :; do - lingering="$(procs_in_install_dir || true)" - [ -z "$lingering" ] && { ok "install dir quiet"; break; } - if [ "$SECONDS" -ge "$quiesce_deadline" ]; then - echo "install-dir processes still alive after 60s; terminating: $lingering" - kill $lingering 2>/dev/null || true - sleep 5 - lingering="$(procs_in_install_dir || true)" - [ -n "$lingering" ] && kill -9 $lingering 2>/dev/null || true - ok "install dir force-quieted" - break - fi - sleep 2 - done - fi - # The smoke check rebuilds from scratch anyway; give it a pristine tree - # rather than whatever the interrupted in-app update left behind. - step "clearing node_modules after driver-killed in-app update" - find "$INSTALL_DIR" -maxdepth 3 -name node_modules -type d -prune -print0 2>/dev/null \ - | xargs -0 rm -rf 2>/dev/null || true - ok "node_modules cleared for the head desktop smoke" ;; esac @@ -514,7 +430,6 @@ ls -la "$INSTALL_DIR/venv/bin" > "$ildest/venv-bin-ls.txt" 2>/dev/null || true ok "collected install-side logs to $ildest" assert_checkout "$TARGET_SHA" "$TARGET_LABEL" -smoke_desktop "$TARGET_LABEL" preserve_after_upgrade diff --git a/tests/install/macos-desktop-e2e.sh b/tests/install/macos-desktop-e2e.sh index 872ef418de..60c93a9ef9 100755 --- a/tests/install/macos-desktop-e2e.sh +++ b/tests/install/macos-desktop-e2e.sh @@ -21,7 +21,7 @@ # hermes-desktop-app-update capture `hermes desktop`'s spawn, # launch the spec under Playwright, # click Update now -# hermes-update CLI update from the installed venv +# hermes-update CLI update from the installed command # installer-script[+desktop] re-run the current install one-liner # # Usage: @@ -93,6 +93,8 @@ fail() { printf 'E2E ASSERTION FAILED: %s\n' "$*" >&2; exit 1; } source "$(dirname "$0")/e2e-assets/ts-prefix.sh" 2>/dev/null || ts_prefix() { cat; } # shellcheck source=../install/e2e-assets/preserve-plugins.sh source "$(dirname "$0")/e2e-assets/preserve-plugins.sh" +# shellcheck source=e2e-assets/source-driver.sh +source "$(dirname "$0")/e2e-assets/source-driver.sh" log_group() { printf '::group::%s\n' "$1" cat "$2" @@ -212,13 +214,11 @@ phase_stage() { } find_installed_app() { - # The bootstrap installs the packaged app; look where the product puts it - # (the checkout's release dir), plus /Applications for a copied bundle. + # Require this installation's app, never an unrelated /Applications copy. local cand for cand in \ "$INSTALL_DIR/apps/desktop/release/mac-arm64/Hermes.app" \ - "$INSTALL_DIR/apps/desktop/release/mac/Hermes.app" \ - "/Applications/Hermes.app"; do + "$INSTALL_DIR/apps/desktop/release/mac/Hermes.app"; do [ -d "$cand" ] && { printf '%s' "$cand"; return 0; } done return 1 @@ -268,9 +268,11 @@ phase_install() { got="$(git -C "$INSTALL_DIR" rev-parse HEAD)" [ "$got" = "$OLD_SHA" ] || fail "installed checkout is $got, expected OLD ($OLD_SHA)" ok "checkout is OLD ($OLD_SHA)" - local hermes="$INSTALL_DIR/venv/bin/hermes" - [ -x "$hermes" ] || fail "no hermes console script at $hermes" - "$hermes" --version 2>&1 | ts_prefix > "$LOG_DIR/version-old.log" || fail "hermes --version failed after install" + local hermes + hermes="$(source_hermes "$INSTALL_DIR")" || fail "no installed command after install" + python3 -B "$ASSETS/source_driver.py" --root "$INSTALL_DIR" --launcher "$hermes" --desktop present \ + || fail "read-only verification failed after install" + HERMES_DISABLE_LAZY_INSTALLS=1 PYTHONDONTWRITEBYTECODE=1 "$hermes" --version 2>&1 | ts_prefix > "$LOG_DIR/version-old.log" || fail "hermes --version failed after install" ok "hermes --version works: $(head -c 120 "$LOG_DIR/version-old.log" | tr -d '\n')" find_installed_app >/dev/null || fail "no installed Hermes.app after the dmg bootstrap" ok "installed app: $(find_installed_app)" @@ -326,7 +328,7 @@ run_playwright_update() { local spec="$1" local pw_dir pw_dir="$(ensure_playwright)" - cp "$ASSETS/launch-from-spec.mjs" "$ASSETS/window-input.cjs" "$pw_dir/" + cp "$ASSETS/launch-from-spec.mjs" "$ASSETS/source-update-observer.mjs" "$ASSETS/window-input.cjs" "$pw_dir/" local rc=0 (cd "$pw_dir" && node launch-from-spec.mjs \ --spec "$spec" \ @@ -361,9 +363,11 @@ phase_update() { hermes-update) # The CLI route a dmg user takes from a terminal. `--yes` reaches the # update subcommand only in later releases; ask the installed hermes. - local hermes="$INSTALL_DIR/venv/bin/hermes" + local hermes help + hermes="$(source_hermes "$INSTALL_DIR")" || fail "no installed update command" local update_cmd=("$hermes" update) - if "$hermes" update --help 2>&1 | grep -qF -- --yes; then + help="$("$hermes" update --help 2>&1)" || fail "installed update --help failed: $help" + if grep -qF -- --yes <<< "$help"; then update_cmd=("$hermes" update --yes) fi local rc=0 @@ -409,7 +413,8 @@ PYEOF ;; hermes-desktop-app-update) # The product's own launch, captured at its spawn site. - local hermes="$INSTALL_DIR/venv/bin/hermes" + local hermes + hermes="$(source_hermes "$INSTALL_DIR")" || fail "no installed desktop command" local spec="$WORK_ROOT/launch-spec.json" local rc=0 (cd "$INSTALL_DIR" && \ @@ -446,7 +451,11 @@ PYEOF ls -la "$INSTALL_DIR/venv" > "$ildest/venv-ls.txt" 2>/dev/null || true ok "collected install-side logs to $ildest" - "$INSTALL_DIR/venv/bin/hermes" --version 2>&1 | ts_prefix > "$LOG_DIR/version-head.log" \ + local command + command="$(source_hermes "$INSTALL_DIR")" || fail "no installed command after update" + python3 -B "$ASSETS/source_driver.py" --root "$INSTALL_DIR" --launcher "$command" --desktop present \ + || fail "read-only verification failed after update; no repair was attempted" + HERMES_DISABLE_LAZY_INSTALLS=1 PYTHONDONTWRITEBYTECODE=1 "$command" --version 2>&1 | ts_prefix > "$LOG_DIR/version-head.log" \ || fail "hermes --version failed after update" ok "hermes --version works post-update" preserve_after_upgrade diff --git a/tests/install/windows-e2e.ps1 b/tests/install/windows-e2e.ps1 index ad23f302a1..a6ee309316 100644 --- a/tests/install/windows-e2e.ps1 +++ b/tests/install/windows-e2e.ps1 @@ -23,7 +23,7 @@ # target sha, marker cleanup, result JSON (when # the script path wrote one), working hermes, # and the relaunched app window. -# update run `hermes update` from the installed venv +# update run `hermes update` from the installed command # (the CLI route a GUI user might take). # installer re-run the bootstrap installer over the # existing install (download Hermes-Setup.exe @@ -321,10 +321,24 @@ function Save-InstallSideState([string]$Label) { function Test-HermesRuns([string]$Label) { Save-InstallSideState $Label - $hermesExe = Join-Path $InstallDir "venv\Scripts\hermes.exe" - Assert-True (Test-Path -LiteralPath $hermesExe) "$Label -- venv\Scripts\hermes.exe exists" - & $hermesExe --version 2>&1 | ForEach-Object { Write-Host " hermes --version| $_" } - Assert-True ($LASTEXITCODE -eq 0) "$Label -- hermes --version exits 0" + $hermesExe = Get-SourceHermes $InstallDir + & python -B (Join-Path $AssetsDir 'source_driver.py') --root $InstallDir --launcher $hermesExe --desktop $script:ExpectedDesktop + Assert-True ($LASTEXITCODE -eq 0) "$Label -- read-only install verification (no repair)" + $prevLazy = $env:HERMES_DISABLE_LAZY_INSTALLS + $prevBytecode = $env:PYTHONDONTWRITEBYTECODE + $prevEap = $ErrorActionPreference + try { + $env:HERMES_DISABLE_LAZY_INSTALLS = '1' + $env:PYTHONDONTWRITEBYTECODE = '1' + $ErrorActionPreference = 'Continue' + & $hermesExe --version 2>&1 | ForEach-Object { Write-Host " hermes --version| $_" } + $versionExit = $LASTEXITCODE + } finally { + $env:HERMES_DISABLE_LAZY_INSTALLS = $prevLazy + $env:PYTHONDONTWRITEBYTECODE = $prevBytecode + $ErrorActionPreference = $prevEap + } + Assert-True ($versionExit -eq 0) "$Label -- hermes --version exits 0" } # ---------------------------------------------------------------------------- @@ -334,6 +348,7 @@ function Test-HermesRuns([string]$Label) { # ---------------------------------------------------------------------------- # shellcheck source=../e2e-assets/ts-prefix.ps1 . (Join-Path $PSScriptRoot "e2e-assets\ts-prefix.ps1") +. (Join-Path $PSScriptRoot "e2e-assets\source-driver.ps1") function Write-LogGroup([string]$Title, [string]$LogPath) { Write-Host "::group::$Title" @@ -372,12 +387,17 @@ function Assert-DesktopArtifact([string]$Label) { } function Invoke-HermesUpdate { - # The venv updater. --yes reaches the update subcommand only in later + # --yes reaches the update subcommand only in later # releases; ask the installed binary, never parse its source. - $hermesExe = Join-Path $InstallDir "venv\Scripts\hermes.exe" + $hermesExe = Get-SourceHermes $InstallDir $updateArgs = @("update") $prevEap = $ErrorActionPreference; $ErrorActionPreference = "Continue" $helpText = & $hermesExe update --help 2>&1 | Out-String + $helpExit = $LASTEXITCODE + if ($helpExit -ne 0) { + $ErrorActionPreference = $prevEap + throw "Installed update --help failed: $helpText" + } if ($helpText -match '--yes') { $updateArgs += "--yes" } New-Item -ItemType Directory -Path (Join-Path $WorkRoot "logs") -Force | Out-Null $log = Join-Path $WorkRoot "logs\update.log" @@ -398,7 +418,7 @@ function Invoke-HermesDesktopAppUpdate([string]$TargetSha) { # real pipeline; the driver intercepts the product's final spawn # (argv/cwd/env captured by e2e-assets/launch-capture/sitecustomize.py) # and re-executes it under Playwright, which clicks Update now. - $hermesExe = Join-Path $InstallDir "venv\Scripts\hermes.exe" + $hermesExe = Get-SourceHermes $InstallDir $spec = Join-Path $WorkRoot "launch-spec.json" New-Item -ItemType Directory -Path (Join-Path $WorkRoot "logs") -Force | Out-Null $log = Join-Path $WorkRoot "logs\desktop-launch-capture.log" @@ -439,6 +459,7 @@ function Invoke-HermesDesktopAppUpdate([string]$TargetSha) { Assert-True ($npmExit -eq 0) "npm install @playwright/test@$PlaywrightVersion into the driver dir" Copy-Item (Join-Path $AssetsDir "launch-from-spec.mjs") (Join-Path $driverDir "launch-from-spec.mjs") -Force + Copy-Item (Join-Path $AssetsDir "source-update-observer.mjs") (Join-Path $driverDir "source-update-observer.mjs") -Force Copy-Item (Join-Path $AssetsDir "window-input.cjs") (Join-Path $driverDir "window-input.cjs") -Force $prevEap = $ErrorActionPreference; $ErrorActionPreference = "Continue" Push-Location $driverDir @@ -923,6 +944,7 @@ function Invoke-PhaseInstall { # checkout at OLD, hermes runs, and state carries how OLD landed so any # update arm can follow any install arm. $state = Read-State + $script:ExpectedDesktop = if ($InstallMethod -eq 'installer-script') { 'absent' } else { 'present' } # Isolated install target for every arm; serve.git's file:// origin # looks like a fork to the updater, whose "add the official repo as # upstream?" prompt would hang a headless run - the marker is the @@ -951,6 +973,9 @@ function Invoke-PhaseInstall { function Invoke-PhaseUpdate { $state = Read-State + $script:ExpectedDesktop = if ($InstallMethod -ne 'installer-script' -or $Route -in @( + 'installer-script+desktop', 'desktop-installer@latest', 'open-app-update', 'hermes-desktop-app-update' + )) { 'present' } else { 'absent' } $env:HERMES_HOME = $HermesHome # Match the POSIX driver's explicit opt-out when a detached updater bypasses # the PATH shim and sees our local transport as a fork. diff --git a/tests/pm/_fixtures.py b/tests/pm/_fixtures.py new file mode 100644 index 0000000000..47b8336878 --- /dev/null +++ b/tests/pm/_fixtures.py @@ -0,0 +1,123 @@ +"""Real artifacts and isolated PM workers shared by lifecycle tests.""" +from __future__ import annotations + +from functools import partial +import hashlib +from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler +import importlib +import io +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tarfile +import threading +import zipfile + +import pytest + + +def _wheel(directory: Path, name: str, version: str = "1.0", requirements=()) -> Path: + metadata = f"{name}-{version}.dist-info" + entries = { + f"{name}/__init__.py": f"__version__ = {version!r}\n", + f"{metadata}/METADATA": f"Metadata-Version: 2.1\nName: {name}\nVersion: {version}\n" + + "".join(f"Requires-Dist: {requirement}\n" for requirement in requirements), + f"{metadata}/WHEEL": "Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n", + } + entries[f"{metadata}/RECORD"] = "".join(f"{path},,\n" for path in entries) + wheel = directory / f"{name}-{version}-py3-none-any.whl" + with zipfile.ZipFile(wheel, "w") as archive: + for path, body in entries.items(): + archive.writestr(path, body) + return wheel + + +def _run(command, *, cwd: Path, env: dict) -> str: + result = subprocess.run(command, cwd=cwd, env=env, capture_output=True, text=True, timeout=60) + assert result.returncode == 0, result.stderr + return result.stdout.strip() + + +def make_tar(docroot: Path, name: str, files: dict[str, str]) -> tuple[str, str]: + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w:gz") as tf: + for rel, content in files.items(): + data = content.encode() + info = tarfile.TarInfo(rel) + info.size = len(data) + info.mode = 0o755 + tf.addfile(info, io.BytesIO(data)) + payload = buf.getvalue() + (docroot / name).write_bytes(payload) + return name, hashlib.sha256(payload).hexdigest() + + +@pytest.fixture +def served(tmp_path): + docroot = tmp_path / "www" + docroot.mkdir() + handler = partial(SimpleHTTPRequestHandler, directory=str(docroot)) + with ThreadingHTTPServer(("127.0.0.1", 0), handler) as server: + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield docroot, f"http://127.0.0.1:{server.server_port}" + finally: + server.shutdown() + thread.join(timeout=5) + + +@pytest.fixture(scope="module") +def isolated_python(tmp_path_factory): + from pm.runtime_stage import stage_runtime + + root = tmp_path_factory.mktemp("pm-python") + uv = shutil.which("uv") + assert uv, "the worker contract requires real uv" + python = stage_runtime(Path(uv), Path(sys.executable), root) + _run([str(python), "-I", "-c", "import importlib.util; assert importlib.util.find_spec('yaml') is None"], + cwd=root, env=dict(os.environ)) + return python + + +@pytest.fixture +def client(tmp_path, monkeypatch, isolated_python): + from pm import paths + + client = importlib.import_module("pm.client") + monkeypatch.setattr("pm.runtime.runtime_python", lambda **kwargs: isolated_python) + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home")) + monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "store")) + monkeypatch.setattr(paths, "lockfile_path", lambda: tmp_path / "lock.json") + return client + + +@pytest.fixture +def build_worker(client, isolated_python, monkeypatch): + """Inject prepared real tools only inside the independent worker process.""" + uv = shutil.which("uv") + assert uv, "the worker contract requires real uv" + worker = Path(client.__file__).with_name("worker.py") + script = ( + "import runpy, sys; from pathlib import Path; " + f"sys.path.insert(0, {str(worker.parent.parent)!r}); " + "import pm._uv; " + f"pm._uv._toolchain = lambda **kwargs: (Path({uv!r}), Path({sys.executable!r})); " + f"runpy.run_path({str(worker)!r}, run_name='__main__')" + ) + monkeypatch.setattr(client, "runtime_command", lambda path, **kwargs: [str(isolated_python), "-I", "-B", "-c", script]) + monkeypatch.setattr(client, "is_runtime", lambda: False) + + def caller_engine(*args, **kwargs): + pytest.fail("dependency engine ran in the caller, not the PM worker") + + for module, names in { + "pm.operations": ("build_environment", "lock_project"), + "pm.build_operations": ("build_requirements_environment", "export_requirements", "check_project_lock"), + }.items(): + for name in names: + monkeypatch.setattr(importlib.import_module(module), name, caller_engine) + return client \ No newline at end of file diff --git a/tests/pm/test_activation_setup.py b/tests/pm/test_activation_setup.py index 4bf52f43ef..56ed340bfa 100644 --- a/tests/pm/test_activation_setup.py +++ b/tests/pm/test_activation_setup.py @@ -20,8 +20,7 @@ import pytest from pm.lock import Lockfile from pm.store import current_target -from tests.pm.test_pm_core import make_tar, served # noqa: F401 -- shared HTTP fixture -from tests.pm.test_workspace_build_inputs import _wheel +from tests.pm._fixtures import _wheel, make_tar, served # noqa: F401 -- shared HTTP fixture pytestmark = pytest.mark.platforms("posix") @@ -77,6 +76,7 @@ def test_activation_real_setup_pm_lifecycle(tmp_path, served): home / ".bashrc", home / ".bash_profile", home / ".zshrc"): path.parent.mkdir(parents=True, exist_ok=True) path.write_text("user-owned fixture\n", encoding="utf-8") + (hermes_home / "skills").chmod(0o755) (core / ".env.example").write_text("FIXTURE_ONLY=example\n", encoding="utf-8") (core / "skills").mkdir() (core / "skills" / "bundled.md").write_text("must not be seeded\n", encoding="utf-8") @@ -178,7 +178,7 @@ test "${PYTHONPATH-}" = "$prior_pythonpath" || exit 96 [bash, "--noprofile", "--norc", "-c", script, "activation-test", str(core)], cwd=tmp_path, env=env, capture_output=True, text=True, timeout=90, ) - assert _snapshot(protected) == untouched + assert _snapshot(protected) == untouched, result.stdout + result.stderr assert (result.returncode == 0) is succeeds, result.stdout + result.stderr return result diff --git a/tests/pm/test_admission_members_locked.py b/tests/pm/test_admission_members_locked.py deleted file mode 100644 index d739d17d4d..0000000000 --- a/tests/pm/test_admission_members_locked.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Admission includes profile changes committed before its install lock is acquired.""" -from contextlib import contextmanager -import importlib -from types import SimpleNamespace - - -def test_admission_reads_other_profiles_after_taking_lock(tmp_path, monkeypatch): - from hermes_cli import plugins_admission as admission - from hermes_cli import runtime_state - import pm.paths as paths - - ensure = importlib.import_module("pm.ensure") - # Exercise the engine lock ordering, not cross-process transport. - monkeypatch.setattr("pm.client.sync_venv", ensure.sync_venv) - monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home")) - repo = tmp_path / "repo" - repo.mkdir() - monkeypatch.setattr(paths, "repo_root", lambda: repo) - monkeypatch.setattr(ensure, "lazy_installs_allowed", lambda: True) - monkeypatch.setattr(ensure, "_facts", lambda: {}) - sibling = tmp_path / "sibling-plugin" - state = {"members": [], "inside": False} - real_lock = runtime_state.runtime_lock - - @contextmanager - def after_competing_publication(project): - with real_lock(project): - state["members"] = [sibling] - state["inside"] = True - try: - yield - finally: - state["inside"] = False - - def members(*args, **kwargs): - return list(state["members"]) - - def apply(extras, *, plugin_dirs): - assert state["inside"] - assert plugin_dirs == [sibling] - return {} - - monkeypatch.setattr(runtime_state, "runtime_lock", after_competing_publication) - monkeypatch.setattr(admission, "candidate_member_dirs", members) - monkeypatch.setattr(ensure, "get_package", lambda _: SimpleNamespace( - expected_stamp=lambda *args, **kwargs: "new", apply=apply, - )) - admission.admit_plugin_set_change({"requested"}, set()) diff --git a/tests/pm/test_build_operations.py b/tests/pm/test_build_operations.py index dc54ad95e9..e2b30cce53 100644 --- a/tests/pm/test_build_operations.py +++ b/tests/pm/test_build_operations.py @@ -4,23 +4,22 @@ from __future__ import annotations import json import os from pathlib import Path -import shutil import sys import pytest from pm.package import InstallError -from tests.pm.test_environment_build import _run, _wheel +from tests.pm._fixtures import ( + _run, + _wheel, + build_worker as build_worker, + client as client, + isolated_python as isolated_python, +) @pytest.fixture -def build_tools(tmp_path, monkeypatch): - uv = shutil.which("uv") - assert uv, "PM build tests require real uv" - # Worker transport is covered separately. These public operation tests use - # real engine/tools without downloading a second toolchain. - monkeypatch.setattr("pm.client.is_runtime", lambda: True) - monkeypatch.setattr("pm._uv._toolchain", lambda **kwargs: (Path(uv), Path(sys.executable))) +def build_tools(tmp_path, monkeypatch, build_worker): env = {key: value for key, value in os.environ.items() if not key.startswith(("UV_", "PYTHON")) and key != "VIRTUAL_ENV"} home = tmp_path / "home" @@ -33,7 +32,7 @@ def build_tools(tmp_path, monkeypatch): @pytest.fixture def locked_source(tmp_path, build_tools): - from pm.operations import lock_project + from pm import lock_project wheels = tmp_path / "wheelhouse" wheels.mkdir() @@ -192,7 +191,7 @@ def test_failed_requirement_build_removes_only_its_candidate(tmp_path, build_too build_requirements_environment(["app-dep==1.0"], out=out, python=python, wheelhouse=wheels, env=build_tools, cache=tmp_path / "cold-cache", offline=True, explicit=True) assert not out.exists() - with pytest.raises(FileExistsError): + with pytest.raises(FileExistsError, match="already exists"): build_requirements_environment(["app-dep==1.0"], out=previous, wheelhouse=wheels, env=build_tools, cache=tmp_path / "cache", offline=True, explicit=True) assert (previous / "pyvenv.cfg").read_bytes() == previous_cfg diff --git a/tests/pm/test_download_partial_integrity.py b/tests/pm/test_download_partial_integrity.py deleted file mode 100644 index 97d8e2b8e6..0000000000 --- a/tests/pm/test_download_partial_integrity.py +++ /dev/null @@ -1,40 +0,0 @@ -"""A resume bitmap is usable only while its partial file still contains those bytes.""" - -import hashlib -import json -import os -import time - -from pm.download_state import collect_partials -from pm.downloader import Download, Source -from tests.pm._range_server import RangeHandler, dl_server, url # noqa: F401 - - -def test_missing_partial_cannot_turn_a_bitmap_into_completed_model(tmp_path, dl_server): - payload = b"real model bytes" - RangeHandler.payloads["/model"] = payload - source_url = url(dl_server, "/model") - partials = tmp_path / "partials" - partials.mkdir() - key = hashlib.sha256(source_url.encode()).hexdigest() - (partials / f"{key}.ranges").write_text(json.dumps({ - "total": len(payload), "etag": '"' + hashlib.sha256(payload).hexdigest() + '"', - "sha256": "", "ranges": [[0, len(payload)]], - }), encoding="utf-8") - dest = tmp_path / "model.gguf" - Download([Source(source_url, dest)], partials_dir=partials).run() - assert dest.read_bytes() == payload - - -def test_gc_protects_both_halves_of_live_partial(tmp_path): - partials = tmp_path / "partials" - partials.mkdir() - part = partials / "key.part" - side = partials / "key.ranges" - part.write_bytes(b"partial") - side.write_text("[]", encoding="utf-8") - old = time.time() - 100 - os.utime(side, (old, old)) - collect_partials(partials, grace_seconds=30) - assert part.read_bytes() == b"partial" - assert side.read_text(encoding="utf-8") == "[]" diff --git a/tests/pm/test_download_state.py b/tests/pm/test_download_state.py index 35697dd85e..e0fe02c984 100644 --- a/tests/pm/test_download_state.py +++ b/tests/pm/test_download_state.py @@ -13,7 +13,7 @@ from pm.lock import Facts from pm.store import Store -@pytest.mark.parametrize("state", ["held", "recent-pair", "unused"]) +@pytest.mark.parametrize("state", ["held", "recent-part", "recent-ranges", "unused"]) def test_gc_respects_partial_ownership_and_recent_resume(tmp_path, monkeypatch, state): partials = tmp_path / "partials" partials.mkdir() @@ -24,8 +24,8 @@ def test_gc_respects_partial_ownership_and_recent_resume(tmp_path, monkeypatch, old = time.time() - 10 * 24 * 60 * 60 for path in (part, side): os.utime(path, (old, old)) - if state == "recent-pair": - side.touch() + if state.startswith("recent-"): + (part if state == "recent-part" else side).touch() monkeypatch.setattr("pm.paths.partials_root", lambda: partials) store = Store(tmp_path / "store") with partial_lock(partials, "example") if state == "held" else nullcontext(): diff --git a/tests/pm/test_downloader.py b/tests/pm/test_downloader.py index ae79a476bf..12160d28e4 100644 --- a/tests/pm/test_downloader.py +++ b/tests/pm/test_downloader.py @@ -33,17 +33,53 @@ def _sha(data: bytes) -> str: # ── parallelism ─────────────────────────────────────────────── -def test_parallel_uses_8_connections(dl_server, tmp_path): +@pytest.mark.parametrize("connections", [2, 8]) +def test_parallel_requests_overlap_without_exceeding_limit(tmp_path, connections): + from http.server import ThreadingHTTPServer + total = 8 * (4 << 20) # 32 MiB -> 8 x 4 MiB ranges payload = _payload(total) - _Handler.payloads["/big"] = payload + barrier = threading.Barrier(connections) + lock = threading.Lock() + active = peak = 0 + broken = [] + + class ConcurrentHandler(_Handler): + payloads = {"/big": payload} + ranges_seen = [] + + def do_GET(self): + nonlocal active, peak + if self.headers.get("Range") == "bytes=0-0": + return super().do_GET() + with lock: + active += 1 + peak = max(peak, active) + try: + try: + barrier.wait(timeout=5) + except threading.BrokenBarrierError: + broken.append(self.headers.get("Range")) + super().do_GET() + finally: + with lock: + active -= 1 + dest = tmp_path / "big.bin" - dl = Download([Source(_url(dl_server, "/big"), dest, _sha(payload))], - partials_dir=tmp_path / "partials") - dl.run() + with ThreadingHTTPServer(("127.0.0.1", 0), ConcurrentHandler) as server: + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + Download([Source(_url(server, "/big"), dest, _sha(payload))], + connections=connections, partials_dir=tmp_path / "partials").run() + finally: + server.shutdown() + thread.join(timeout=5) assert dest.read_bytes() == payload - expected = [(i * total // 8, (i + 1) * total // 8 - 1) for i in range(8)] - assert sorted((s, e) for _, s, e in _Handler.ranges_seen) == expected + assert not broken, "range requests were serialized instead of overlapping" + assert peak == connections and active == 0 + expected = [(i * total // connections, (i + 1) * total // connections - 1) for i in range(connections)] + assert sorted((s, e) for _, s, e in ConcurrentHandler.ranges_seen) == expected def test_progress_carries_overall_and_ranges(dl_server, tmp_path): diff --git a/tests/pm/test_environment_build.py b/tests/pm/test_environment_build.py index be1fb283e1..3828dc7f2f 100644 --- a/tests/pm/test_environment_build.py +++ b/tests/pm/test_environment_build.py @@ -7,31 +7,15 @@ from pathlib import Path import shutil import subprocess import sys -import zipfile import pytest - - -def _wheel(directory: Path, name: str, version: str = "1.0", requirements=()) -> Path: - metadata = f"{name}-{version}.dist-info" - entries = { - f"{name}/__init__.py": f"__version__ = {version!r}\n", - f"{metadata}/METADATA": f"Metadata-Version: 2.1\nName: {name}\nVersion: {version}\n" - + "".join(f"Requires-Dist: {requirement}\n" for requirement in requirements), - f"{metadata}/WHEEL": "Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n", - } - entries[f"{metadata}/RECORD"] = "".join(f"{path},,\n" for path in entries) - wheel = directory / f"{name}-{version}-py3-none-any.whl" - with zipfile.ZipFile(wheel, "w") as archive: - for path, body in entries.items(): - archive.writestr(path, body) - return wheel - - -def _run(command, *, cwd: Path, env: dict) -> str: - result = subprocess.run(command, cwd=cwd, env=env, capture_output=True, text=True, timeout=60) - assert result.returncode == 0, result.stderr - return result.stdout.strip() +from tests.pm._fixtures import ( + _run, + _wheel, + build_worker as build_worker, + client as client, + isolated_python as isolated_python, +) def test_prune_site_pth_keeps_only_load_bearing_pth(tmp_path): @@ -105,12 +89,8 @@ def locked_project(tmp_path): @pytest.fixture -def installable_project(locked_project, monkeypatch): +def installable_project(locked_project, build_worker): source, uv, env = locked_project - # The parent suite exercises the worker transport; these tests exercise the - # build adapter and engine with locally supplied, real tool binaries. - monkeypatch.setattr("pm.client.is_runtime", lambda: True) - monkeypatch.setattr("pm._uv._toolchain", lambda **kwargs: (uv, Path(sys.executable))) metadata = source / "pyproject.toml" metadata.write_text(metadata.read_text().replace("package=false", "package=true") + '\n[build-system]\nrequires=[]\nbuild-backend="local_backend"\nbackend-path=["."]\n') @@ -148,7 +128,7 @@ def test_public_build_installs_all_extras_at_explicit_destination(installable_pr import pm.workspace source, uv, env = installable_project - monkeypatch.setattr(pm.paths, "repo_root", lambda: pytest.fail("implicit source")) + monkeypatch.setattr(pm.paths, "repo_root", lambda: tmp_path / "unrelated-project") monkeypatch.setattr(pm.workspace, "enabled_member_dirs", lambda: pytest.fail("user plugins")) before = dict(os.environ) locked = (source / "uv.lock").read_bytes() @@ -199,7 +179,7 @@ def test_public_dependency_only_build_needs_no_application_source(installable_pr assert not Path(env["HERMES_HOME"]).exists() -def test_group_only_build_excludes_application_dependencies(locked_project, tmp_path): +def test_group_only_build_excludes_application_dependencies(locked_project, tmp_path, build_worker): import pm source, _, env = locked_project @@ -215,6 +195,53 @@ def test_group_only_build_excludes_application_dependencies(locked_project, tmp_ cwd=tmp_path, env=env) == "1.0" +def test_worker_sync_reuses_unions_and_reports_real_lock_drift(locked_project, build_worker, tmp_path, monkeypatch): + import pm + from hermes_cli.runtime_paths import selected_venv, runtime_facts_path + from pm.lock import Facts, Lockfile + from pm import paths + + source, _, env = locked_project + manifest = source / "pyproject.toml" + manifest.write_text(manifest.read_text().replace('[tool.uv.workspace]\nmembers=["member"]\n', ""), encoding="utf-8") + monkeypatch.setattr(paths, "repo_root", lambda: source) + pm.lock_project(source, offline=True, explicit=True) + assert pm.check() == [] + parent_path, parent_env = list(sys.path), dict(os.environ) + + pm.sync_venv(["chosen"], explicit=True, plugin_dirs=[]) + first = selected_venv(source) + first_fact = Facts(runtime_facts_path(source)).get("venv") + pm.sync_venv(["chosen"], explicit=True, plugin_dirs=[]) + assert selected_venv(source) == first + assert Facts(runtime_facts_path(source)).get("venv") == first_fact + assert _run([str(first / ("Scripts/python.exe" if os.name == "nt" else "bin/python")), "-I", "-c", + "import base_dep, chosen_dep, importlib.util; assert importlib.util.find_spec('other_dep') is None; print(chosen_dep.__version__)"], + cwd=tmp_path, env=env) == "1.0" + + pm.sync_venv(["other"], explicit=True, plugin_dirs=[]) + second = selected_venv(source) + assert second != first + assert Facts(runtime_facts_path(source)).get("venv")["extras"] == ["chosen", "other"] + pm.sync_venv(["chosen"], explicit=True, plugin_dirs=[]) + assert selected_venv(source) == second + assert _run([str(second / ("Scripts/python.exe" if os.name == "nt" else "bin/python")), "-I", "-c", + "import chosen_dep, other_dep; print(chosen_dep.__version__, other_dep.__version__)"], + cwd=tmp_path, env=env) == "1.0 1.0" + assert pm.check() == [] + _wheel(tmp_path / "wheels", "base_dep", "1.1") + manifest.write_text(manifest.read_text().replace("base-dep==1.0", "base-dep==1.1"), encoding="utf-8") + pm.lock_project(source, offline=True, explicit=True) + assert pm.check() == ["venv: out of sync with uv.lock"] + lock = Lockfile(paths.lockfile_path()) + lock.set_pin("node", "fixture", {"any": {"url": "https://example.invalid/node", "sha256": "0" * 64}}) + lock.save() + assert "node: not installed or outdated" in pm.check() + assert selected_venv(source) == second + assert list(sys.path) == parent_path and dict(os.environ) == parent_env + assert not {"base_dep", "chosen_dep", "other_dep"} & sys.modules.keys() + + def test_child_output_is_live_and_keeps_explicit_index_credentials(tmp_path): import io from pm.environment import PythonEnvironment @@ -258,20 +285,37 @@ def test_child_output_is_live_and_keeps_explicit_index_credentials(tmp_path): assert "child-complete" in result.stderr, "failure diagnostics must retain a bounded output tail" -@pytest.mark.parametrize("parent_exits", [True, False]) -def test_streaming_deadline_includes_inherited_stdout(tmp_path, parent_exits): +@pytest.fixture(params=["environment", "cli"]) +def streaming_runner(request, tmp_path): + import contextlib import io - import threading - import time + from pm.cli import _run_live from pm.environment import PythonEnvironment - release = tmp_path / "release-descendant" - finished = tmp_path / "descendant-finished" output = io.StringIO() environment = PythonEnvironment( uv=Path(sys.executable), python=Path(sys.executable), destination=tmp_path / "venv", cache=tmp_path / "cache", env=dict(os.environ), output=output, ) + + def run(script, timeout): + if request.param == "cli": + with contextlib.redirect_stdout(output): + return _run_live([sys.executable, "-c", script], cwd=tmp_path, + env=dict(os.environ), timeout=timeout) + return environment._run(["-c", script], cwd=tmp_path, timeout=timeout) + + return run, output, RuntimeError if request.param == "cli" else subprocess.TimeoutExpired + + +@pytest.mark.parametrize("parent_exits", [True, False]) +def test_streaming_deadline_includes_inherited_stdout(tmp_path, parent_exits, streaming_runner): + import threading + import time + + run, output, timeout_error = streaming_runner + release = tmp_path / "release-descendant" + finished = tmp_path / "descendant-finished" # The descendant inherits stdout even when the direct child has already exited. # Its finite lifetime also bounds this regression on the broken implementation. descendant = ( @@ -290,8 +334,8 @@ def test_streaming_deadline_includes_inherited_stdout(tmp_path, parent_exits): timeout = 2 started = time.monotonic() try: - with pytest.raises(subprocess.TimeoutExpired): - environment._run(["-c", parent], cwd=tmp_path, timeout=timeout) + with pytest.raises(timeout_error): + run(parent, timeout) assert time.monotonic() - started < timeout + 2, "draining stdout restarted the timeout" assert "inherited-output" in output.getvalue() assert set(threading.enumerate()) <= threads_before, "timeout leaked an output reader" @@ -307,16 +351,10 @@ def test_streaming_deadline_includes_inherited_stdout(tmp_path, parent_exits): thread.join(timeout=5) -def test_streaming_eof_does_not_restart_process_wait_timeout(tmp_path): - import io +def test_streaming_eof_does_not_restart_process_wait_timeout(tmp_path, streaming_runner): import time - from pm.environment import PythonEnvironment - output = io.StringIO() - environment = PythonEnvironment( - uv=Path(sys.executable), python=Path(sys.executable), destination=tmp_path / "venv", - cache=tmp_path / "cache", env=dict(os.environ), output=output, - ) + run, output, timeout_error = streaming_runner # Spend most of the budget before EOF, then leave the process alive without # any pipe writers. Waiting for exit must use only the remaining budget. script = ( @@ -325,10 +363,9 @@ def test_streaming_eof_does_not_restart_process_wait_timeout(tmp_path): ) timeout = 4 started = time.monotonic() - with pytest.raises(subprocess.TimeoutExpired) as error: - environment._run(["-c", script], cwd=tmp_path, timeout=timeout) + with pytest.raises(timeout_error): + run(script, timeout) assert time.monotonic() - started < timeout + 2, "EOF restarted the process wait budget" - assert error.value.timeout == timeout assert "before-eof" in output.getvalue() @@ -345,7 +382,7 @@ def test_failed_build_removes_only_its_candidate(installable_project, tmp_path, source_lock = (source / "uv.lock").read_bytes() # Check destination refusal with valid inputs. The contract does not specify # which error comes first when the source is also damaged. - with pytest.raises(FileExistsError): + with pytest.raises(FileExistsError, match="already exists"): build_environment(explicit=True, source=source, python=Path(sys.executable), out=previous, env=env, cache=tmp_path / "cache", offline=True) if damage == "source": diff --git a/tests/pm/test_network_retries.py b/tests/pm/test_network_retries.py index ab75c52d99..2335bdb158 100644 --- a/tests/pm/test_network_retries.py +++ b/tests/pm/test_network_retries.py @@ -124,12 +124,15 @@ def test_pm_metadata_reads_retry_transient_http_failure(dl_server, monkeypatch, } if reader_name == "digests": import urllib.request - original_urlopen = urllib.request.urlopen - def local_release(request, **kwargs): - return original_urlopen(urllib.request.Request(endpoint, headers=request.headers), **kwargs) + class LocalRelease(urllib.request.HTTPSHandler): + def https_open(self, request): + assert request.host == "api.github.com" + return urllib.request.urlopen( + urllib.request.Request(endpoint, headers=request.headers), timeout=request.timeout, + ) - monkeypatch.setattr(urllib.request, "urlopen", local_release) + monkeypatch.setattr(urllib.request, "_opener", urllib.request.build_opener(LocalRelease())) monkeypatch.setattr(packages, "_release_digest_cache", {}) readers["digests"] = (lambda: packages._github_release_digests("test/repo", "test"), {"tool.zip": "abc123"}) read, expected = readers[reader_name] diff --git a/tests/pm/test_plugins_state.py b/tests/pm/test_plugins_state.py index 37ffd9fc20..4f0e8b226e 100644 --- a/tests/pm/test_plugins_state.py +++ b/tests/pm/test_plugins_state.py @@ -45,7 +45,7 @@ def test_enabled_plugins_ordered_reads_all_homes(homes): assert by_root.get(profile_home / "plugins") == ["c-plug"] -@pytest.mark.parametrize("boundary", ["profile-listing", "profile-stat", "config-read", "plugin-stat", "manifest-read", "provider-stat"]) +@pytest.mark.parametrize("boundary", ["profile-listing", "profile-stat", "config-read", "plugin-stat", "manifest-read", "manifest-stat", "provider-stat"]) def test_unreadable_profile_state_is_not_an_empty_selection(homes, monkeypatch, boundary): from pm.workspace import enabled_member_dirs @@ -63,6 +63,7 @@ def test_unreadable_profile_state_is_not_an_empty_selection(homes, monkeypatch, "config-read": ("read_text", profile_home / "config.yaml"), "plugin-stat": ("stat", plugin), "manifest-read": ("read_text", manifest), + "manifest-stat": ("stat", manifest), "provider-stat": ("stat", plugin), }[boundary] original = getattr(Path, method) diff --git a/tests/pm/test_pm_authority.py b/tests/pm/test_pm_authority.py index b5256a4f32..9e02724f97 100644 --- a/tests/pm/test_pm_authority.py +++ b/tests/pm/test_pm_authority.py @@ -8,14 +8,9 @@ matches lock, digest matches bytes), not snapshots.""" from __future__ import annotations import hashlib -import io import json import logging import shutil -import tarfile -import threading -from functools import partial -from http.server import HTTPServer, SimpleHTTPRequestHandler from pathlib import Path import pytest @@ -26,6 +21,7 @@ from pm.lock import Facts, Lockfile from pm.package import Package from pm.packages import BinaryPackage from pm.store import Store, current_target, tree_digest +from tests.pm._fixtures import make_tar, served as served class FakeTool(BinaryPackage): @@ -37,32 +33,6 @@ class FakeTool(BinaryPackage): return f"{FakeTool.base_url}/{self.name}-{version}.tar.gz" -def make_tar(docroot: Path, name: str, files: dict[str, str]) -> tuple[str, str]: - buf = io.BytesIO() - with tarfile.open(fileobj=buf, mode="w:gz") as tf: - for rel, content in files.items(): - data = content.encode() - info = tarfile.TarInfo(rel) - info.size = len(data) - info.mode = 0o755 - tf.addfile(info, io.BytesIO(data)) - payload = buf.getvalue() - (docroot / name).write_bytes(payload) - return name, hashlib.sha256(payload).hexdigest() - - -@pytest.fixture -def served(tmp_path): - docroot = tmp_path / "www" - docroot.mkdir() - handler = partial(SimpleHTTPRequestHandler, directory=str(docroot)) - server = HTTPServer(("127.0.0.1", 0), handler) - thread = threading.Thread(target=server.serve_forever, daemon=True) - thread.start() - yield docroot, f"http://127.0.0.1:{server.server_port}" - server.shutdown() - - @pytest.fixture def pm_env(tmp_path, served, monkeypatch): docroot, base_url = served diff --git a/tests/pm/test_pm_core.py b/tests/pm/test_pm_core.py index c4e7e8c0c3..8029b9a604 100644 --- a/tests/pm/test_pm_core.py +++ b/tests/pm/test_pm_core.py @@ -5,14 +5,7 @@ stores.""" from __future__ import annotations -import hashlib -import io -import json -import os -import tarfile import threading -from functools import partial -from http.server import HTTPServer, SimpleHTTPRequestHandler from pathlib import Path import pytest @@ -20,9 +13,10 @@ import pytest import pm.paths as paths import pm.registry as registry from pm.lock import Facts, Lockfile -from pm.package import InstallError, Package, StatePackage, compose_env +from pm.package import InstallError, compose_env from pm.packages import BinaryPackage from pm.store import Store, current_target, flatten_single_dir +from tests.pm._fixtures import make_tar, served as served class FakeTool(BinaryPackage): @@ -63,32 +57,6 @@ class MultiTool(BinaryPackage): return self.fetch_urls(version, target)[0] -def make_tar(docroot: Path, name: str, files: dict[str, str]) -> tuple[str, str]: - buf = io.BytesIO() - with tarfile.open(fileobj=buf, mode="w:gz") as tf: - for rel, content in files.items(): - data = content.encode() - info = tarfile.TarInfo(rel) - info.size = len(data) - info.mode = 0o755 - tf.addfile(info, io.BytesIO(data)) - payload = buf.getvalue() - (docroot / name).write_bytes(payload) - return name, hashlib.sha256(payload).hexdigest() - - -@pytest.fixture -def served(tmp_path): - docroot = tmp_path / "www" - docroot.mkdir() - handler = partial(SimpleHTTPRequestHandler, directory=str(docroot)) - server = HTTPServer(("127.0.0.1", 0), handler) - thread = threading.Thread(target=server.serve_forever, daemon=True) - thread.start() - yield docroot, f"http://127.0.0.1:{server.server_port}" - server.shutdown() - - @pytest.fixture def pm_env(tmp_path, served, monkeypatch): docroot, base_url = served @@ -594,80 +562,6 @@ def test_adopt_noop_without_facts(pm_env, monkeypatch): assert pm.adopt() is False -class FakeVenv(StatePackage): - name = "venv" - - def __init__(self): - self.applied: list[list[str]] = [] - self.lock_content = b"lock-v1" - - def expected_stamp(self, extras): - import hashlib - - h = hashlib.sha256(self.lock_content) - h.update(",".join(sorted(extras)).encode()) - return h.hexdigest() - - def apply(self, extras): - self.applied.append(list(extras)) - from hermes_cli.runtime_paths import install_state_dir - - environment = install_state_dir(paths.repo_root()) / "environments" / str(len(self.applied)) / "venv" - environment.mkdir(parents=True) - (environment / "pyvenv.cfg").write_text("home = test\n", encoding="utf-8") - return {"environment": environment} - - -@pytest.fixture -def venv_env(pm_env, tmp_path, monkeypatch): - project = tmp_path / "venv-project" - project.mkdir() - monkeypatch.setattr(paths, "repo_root", lambda: project) - fake = FakeVenv() - registry._packages["venv"] = fake - return pm_env, fake - - -def test_sync_venv_applies_once_then_stamps(venv_env): - from pm.ensure import sync_venv - - _, fake = venv_env - sync_venv() - sync_venv() - assert fake.applied == [[]] - - -def test_sync_venv_unions_extras(venv_env): - from pm.ensure import sync_venv - - _, fake = venv_env - sync_venv(["telegram"]) - sync_venv(["anthropic"]) - sync_venv(["telegram"]) - assert fake.applied == [["telegram"], ["anthropic", "telegram"]] - - -def test_check_reports_venv_drift_and_missing_tools(venv_env): - from pm.ensure import check, ensure, sync_venv - - (pm_env_tuple, fake) = venv_env - lockfile_path, runtime, *_ = pm_env_tuple - - assert check() == [] # pm never touched this install: silent - - ensure("faketool", base_env={}) - sync_venv() - assert check() == [] - - fake.lock_content = b"lock-v2" # uv.lock changed underneath - problems = check() - assert problems == ["venv: out of sync with uv.lock"] - - _pin(lockfile_path, "faketool", "9.9", "0" * 64) # tool outdated now - problems = check() - assert "faketool: not installed or outdated" in problems - - def test_bundle_package_names_include_browsers(monkeypatch, tmp_path): from scripts.bundles.native import _bundle_package_names from pm.lock import Lockfile diff --git a/tests/pm/test_recovery.py b/tests/pm/test_recovery.py index 394f5f80c0..ee24dd0616 100644 --- a/tests/pm/test_recovery.py +++ b/tests/pm/test_recovery.py @@ -13,7 +13,7 @@ import pytest from pm.lock import Facts from pm.runtime import runtime_environment -from tests.pm.test_workspace_build_inputs import _wheel +from tests.pm._fixtures import _wheel @pytest.fixture(autouse=True) def isolated_machine_home(tmp_path, monkeypatch): diff --git a/tests/pm/test_security_consumers.py b/tests/pm/test_security_consumers.py new file mode 100644 index 0000000000..597d32b037 --- /dev/null +++ b/tests/pm/test_security_consumers.py @@ -0,0 +1,338 @@ +"""Security consumers cross the real PM worker/HTTP/publication boundary.""" +from __future__ import annotations + +import hashlib +import io +import json +import os +from pathlib import Path +import subprocess +import sys +import tarfile +import zipfile + +import pytest + +import pm +from pm import paths +from tests.pm._range_server import RangeHandler, url +from tests.pm._range_server import dl_server as dl_server + + +@pytest.fixture +def consumer_store(tmp_path, monkeypatch, dl_server): + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home")) + monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "store")) + monkeypatch.setenv("HERMES_DISABLE_LAZY_INSTALLS", "1") + monkeypatch.setattr(paths, "lockfile_path", lambda: tmp_path / "lock.json") + # Only the already-prepared interpreter is supplied: the worker, downloader, + # extractor, probes, publication and read-only selector are all real. + monkeypatch.setattr("pm.runtime.runtime_python", lambda **kwargs: Path(sys.executable)) + monkeypatch.setattr("urllib.request.urlopen", lambda *a, **kw: pytest.fail("consumer bypassed PM acquisition")) + return dl_server + + +def pin(server, name, version="1", *, bad_hash=False, payload=None, signature=None, link=False): + payload = payload or ( + f"#!{sys.executable}\nimport json,sys\n" + f"print({name + ' fixture ' + version!r})\n" + ).encode() + buf = io.BytesIO() + ext = ".zip" if name == "bws" else ".tar.gz" + if ext == ".zip": + with zipfile.ZipFile(buf, "w") as zf: + zf.writestr(name, payload) + else: + with tarfile.open(fileobj=buf, mode="w:gz") as tf: + info = tarfile.TarInfo("payload" if link else name) + info.size, info.mode = len(payload), 0o755 + tf.addfile(info, io.BytesIO(payload)) + if link: + info = tarfile.TarInfo(name) + info.type, info.linkname = tarfile.SYMTYPE, "payload" + tf.addfile(info) + archive = f"/{name}-{version}{ext}" + RangeHandler.payloads[archive] = buf.getvalue() + digest = hashlib.sha256(buf.getvalue()).hexdigest() + artifacts = [{"url": url(server, archive), "sha256": "0" * 64 if bad_hash else digest}] + if name != "bws": + checksum_path = f"/{name}/{version}/checksums.txt" + checksum = f"{digest} {archive[1:]}\n".encode() + RangeHandler.payloads[checksum_path] = checksum + artifacts.append({"url": url(server, checksum_path), "sha256": hashlib.sha256(checksum).hexdigest()}) + if signature is not None: + files = ({"checksums.txt.sig": signature, "checksums.txt.pem": b"certificate"} + if name == "tirith" else {"checksums.txt.asc": signature, "public-key.asc": b"public-key"}) + for filename, raw in files.items(): + path = f"/{name}/{version}/{filename}" + RangeHandler.payloads[path] = raw + artifacts.append({"url": url(server, path), "sha256": hashlib.sha256(raw).hexdigest()}) + lock = pm.Lockfile(paths.lockfile_path()) + lock.set_pin(name, version, {pm.current_target(): artifacts}) + lock.save() + + +def consumer(name): + if name == "bws": + from agent.secret_sources.bitwarden import find_bws, install_bws + return find_bws, install_bws + if name == "iron-proxy": + from agent.proxy_sources.iron_proxy import find_iron_proxy, install_iron_proxy + return find_iron_proxy, install_iron_proxy + from tools.tirith_security import ensure_installed + return (lambda **kw: ensure_installed(), + lambda **kw: ensure_installed(explicit=True)) + + +@pytest.mark.platforms("posix") +@pytest.mark.parametrize("name", ["bws", "iron-proxy", "tirith"]) +def test_consumer_lifecycle(consumer_store, monkeypatch, tmp_path, name): + find, install = consumer(name) + + monkeypatch.setenv("PATH", "") + monkeypatch.setenv("TIRITH_ENABLED", "true") + pin(consumer_store, name) + if name != "tirith": + external = tmp_path / name + external.write_text(f"#!{sys.executable}\nprint('external')\n") + external.chmod(0o755) + monkeypatch.setenv("PATH", str(tmp_path)) + assert find(install_if_missing=True) == external + assert subprocess.check_output([external], text=True).strip() == "external" + assert pm.installed_package(name) is None + monkeypatch.setenv("PATH", "") + assert find(install_if_missing=True) is None # lazy refusal, no HTTP + assert not RangeHandler.ranges_seen + binary = Path(install()) + assert binary == pm.installed_package(name).binary == Path(find()) + assert subprocess.check_output([binary], text=True).strip() == f"{name} fixture 1" + requests = list(RangeHandler.ranges_seen) + assert Path(install()) == binary + assert RangeHandler.ranges_seen == requests + binary.write_text("damaged executable") + assert Path(install(force=True)) == binary + assert subprocess.check_output([binary], text=True).strip() == f"{name} fixture 1" + pin(consumer_store, name, "2", bad_hash=True) + with pytest.raises(pm.InstallError, match="[Hh]ash|[Cc]hecksum|sha256"): + install(force=True) + assert pm.installed_package(name, allow_outdated=True).binary == binary + assert subprocess.check_output([binary], text=True).strip() == f"{name} fixture 1" + pin(consumer_store, name, "2") + updated = install(force=True) + assert subprocess.check_output([updated], text=True).strip() == f"{name} fixture 2" + + +@pytest.mark.platforms("posix") +@pytest.mark.parametrize("name,verifier", [("tirith", "cosign"), ("iron-proxy", "gpg")]) +def test_signature_rejection_preserves_previous_selection(consumer_store, tmp_path, monkeypatch, name, verifier): + """A real child verifier rejects after PM hashes pass; publication must not happen.""" + commands = tmp_path / "commands" + commands.mkdir() + log = tmp_path / "verifier.jsonl" + executable = commands / verifier + executable.write_text( + f"#!{sys.executable}\nimport json,pathlib,sys\n" + f"with open({str(log)!r}, 'a') as f: f.write(json.dumps(sys.argv[1:]) + '\\n')\n" + "args=sys.argv[1:]\n" + "flag='--signature' if '--signature' in args else '--verify'\n" + "if flag not in args: sys.exit(0)\n" + "sys.exit(0 if pathlib.Path(args[args.index(flag)+1]).read_bytes() == b'accept' else 1)\n", + encoding="utf-8", + ) + executable.chmod(0o755) + monkeypatch.setenv("PATH", str(commands)) + monkeypatch.setenv("TIRITH_ENABLED", "true") + _, install = consumer(name) + pin(consumer_store, name, signature=b"accept") + old = Path(install()) + pin(consumer_store, name, "2", signature=b"reject") + with pytest.raises(RuntimeError, match="cosign_verification_failed|GPG signature verification"): + install(force=True) + assert pm.installed_package(name, allow_outdated=True).binary == old + assert subprocess.check_output([old], text=True).strip() == f"{name} fixture 1" + calls = [json.loads(line) for line in log.read_text().splitlines()] + if verifier == "cosign": + args = calls[0] + assert args[args.index("--certificate-identity-regexp") + 1] == ( + r"^https://github.com/sheeki03/tirith/\.github/workflows/release\.yml@refs/tags/v" + ) + assert args[args.index("--certificate-oidc-issuer") + 1] == "https://token.actions.githubusercontent.com" + else: + assert "--import" in calls[0] and "--verify" in calls[1] + assert not Path(calls[0][calls[0].index("--homedir") + 1]).exists() + + +@pytest.mark.platforms("posix") +def test_tirith_opt_in_background_and_explicit_override(consumer_store, tmp_path, monkeypatch): + from tools import tirith_security as tirith + import threading + + monkeypatch.setenv("PATH", "") + monkeypatch.setenv("TIRITH_ENABLED", "false") + pin(consumer_store, "tirith") + assert tirith.ensure_installed(explicit=True) is None + assert not RangeHandler.ranges_seen + monkeypatch.setenv("TIRITH_ENABLED", "true") + monkeypatch.setenv("TIRITH_BIN", str(tmp_path / "missing")) + assert tirith.ensure_installed(explicit=True) is None + assert not RangeHandler.ranges_seen + external = tmp_path / "external-tirith" + external.write_text(f"#!{sys.executable}\nimport json,sys\nprint(json.dumps({{'summary':'external'}}))\nsys.exit(1)\n") + external.chmod(0o755) + monkeypatch.setenv("TIRITH_BIN", str(external)) + assert tirith.check_command_security("echo hello")["summary"] == "external" + assert not RangeHandler.ranges_seen + monkeypatch.delenv("TIRITH_BIN") + monkeypatch.delenv("HERMES_DISABLE_LAZY_INSTALLS") + home = Path(os.environ["HERMES_HOME"]) + home.mkdir(exist_ok=True) + (home / "config.yaml").write_text("security:\n allow_lazy_installs: true\n") + # Hold the real worker's HTTP request so returning before completion is + # event-proven, not an assertion against a replaced Thread constructor. + entered, release = threading.Event(), threading.Event() + real_get = RangeHandler.do_GET + def blocked_get(handler): + entered.set() + assert release.wait(10) + real_get(handler) + monkeypatch.setattr(RangeHandler, "do_GET", blocked_get) + try: + assert tirith.ensure_installed() is None + assert entered.wait(10) + assert pm.installed_package("tirith") is None + finally: + release.set() + for thread in tirith._install_threads.values(): + thread.join(10) + assert not thread.is_alive() + assert Path(tirith.ensure_installed()) == pm.installed_package("tirith").binary + + +@pytest.mark.platforms("posix") +def test_managed_consumers_run_their_business_protocol(consumer_store, monkeypatch): + from agent.secret_sources.bitwarden import fetch_bitwarden_secrets, install_bws + from agent.proxy_sources.iron_proxy import install_iron_proxy, iron_proxy_version + from tools.tirith_security import check_command_security, ensure_installed + + monkeypatch.setenv("PATH", "") + monkeypatch.setenv("TIRITH_ENABLED", "true") + monkeypatch.setenv("MY_PRIVATE_TOKEN", "not-for-proxy") + pin(consumer_store, "bws", payload=( + f"#!{sys.executable}\nimport json,os,sys\n" + "if '--version' in sys.argv: print('bws fixture')\n" + "else:\n" + " assert sys.argv[1:] == ['secret','list','project','--output','json']\n" + " print(json.dumps([{'key':'FIXTURE_VALUE','value':os.environ['BWS_ACCESS_TOKEN']}]))\n" + ).encode()) + install_bws() + assert fetch_bitwarden_secrets(access_token="local-token", project_id="project", use_cache=False) == ( + {"FIXTURE_VALUE": "local-token"}, [] + ) + pin(consumer_store, "iron-proxy", payload=( + f"#!{sys.executable}\nimport os,sys\n" + "assert 'MY_PRIVATE_TOKEN' not in os.environ\n" + "assert sys.argv[1:] == ['--version']\nprint('private proxy fixture')\n" + ).encode()) + proxy = install_iron_proxy() + assert iron_proxy_version(proxy) == "private proxy fixture" + pin(consumer_store, "tirith", payload=( + f"#!{sys.executable}\nimport json,sys\n" + "if '--version' in sys.argv: print('tirith fixture')\n" + "else:\n" + " assert sys.argv[1:] == ['check','--json','--non-interactive','--shell','posix','--','echo hello']\n" + " print(json.dumps({'summary':'managed scan','findings':[]}))\n sys.exit(2)\n" + ).encode()) + ensure_installed(explicit=True) + assert check_command_security("echo hello") == {"action": "warn", "summary": "managed scan", "findings": []} + + +@pytest.mark.platforms("posix") +def test_nonregular_security_binary_is_never_published(consumer_store, monkeypatch): + monkeypatch.setenv("PATH", "") + monkeypatch.setenv("TIRITH_ENABLED", "true") + _, install = consumer("tirith") + pin(consumer_store, "tirith") + old = install() + pin(consumer_store, "tirith", "2", link=True) + with pytest.raises(pm.InstallError, match="not a regular file"): + install() + assert str(pm.installed_package("tirith", allow_outdated=True).binary) == old + + +@pytest.mark.platforms("posix") +def test_interrupted_consumer_download_can_retry_without_losing_selection(consumer_store, monkeypatch): + monkeypatch.setenv("PATH", "") + _, install = consumer("bws") + pin(consumer_store, "bws") + old = install() + pin(consumer_store, "bws", "2") + RangeHandler.abort_after = 0 + try: + with pytest.raises(pm.InstallError): + install(force=True) + finally: + RangeHandler.abort_after = None + assert pm.installed_package("bws", allow_outdated=True).binary == old + assert subprocess.check_output([old], text=True).strip() == "bws fixture 1" + updated = install(force=True) + assert subprocess.check_output([updated], text=True).strip() == "bws fixture 2" + + +@pytest.mark.platforms("posix") +@pytest.mark.parametrize("name", ["bws", "iron-proxy"]) +def test_external_executable_does_not_require_pm_platform_support(tmp_path, monkeypatch, name): + external = tmp_path / name + external.write_text(f"#!{sys.executable}\nprint('external')\n") + external.chmod(0o755) + monkeypatch.setenv("PATH", str(tmp_path)) + def unsupported(_name): + raise RuntimeError("unsupported architecture: external tool does not need PM") + monkeypatch.setattr(pm, "installed_package", unsupported) + find, _ = consumer(name) + assert find(install_if_missing=True) == external + assert subprocess.check_output([external], text=True).strip() == "external" + + +@pytest.mark.platforms("posix") +def test_tirith_failed_cold_scans_make_one_attempt_then_explicit_can_retry(consumer_store, monkeypatch): + from tools import tirith_security as tirith + monkeypatch.setenv("PATH", "") + monkeypatch.setenv("TIRITH_ENABLED", "true") + monkeypatch.delenv("HERMES_DISABLE_LAZY_INSTALLS") + monkeypatch.setattr(tirith, "_crash_count", 0) + monkeypatch.setattr(tirith, "_circuit_open", False) + pin(consumer_store, "tirith", bad_hash=True) + requests = [] + real_get = RangeHandler.do_GET + def record(handler): + requests.append(handler.path) + real_get(handler) + monkeypatch.setattr(RangeHandler, "do_GET", record) + tirith.check_command_security("echo hello") + first_attempt = list(requests) + assert first_attempt + tirith.check_command_security("echo hello") + tirith.check_command_security("echo hello") + assert requests == first_attempt + pin(consumer_store, "tirith") + assert tirith.ensure_installed(explicit=True) + + +@pytest.mark.platforms("posix") +@pytest.mark.parametrize("name", ["tirith", "iron-proxy"]) +def test_locked_provenance_is_required_even_without_a_verifier(consumer_store, monkeypatch, name): + monkeypatch.setenv("PATH", "") + monkeypatch.setenv("TIRITH_ENABLED", "true") + _, install = consumer(name) + pin(consumer_store, name, signature=b"pinned-signature") + old = Path(install()) # absent verifier permits hash-verified installation + pin(consumer_store, name, "2", signature=b"pinned-signature") + suffix = "sig" if name == "tirith" else "asc" + missing = f"/{name}/2/checksums.txt.{suffix}" + raw = RangeHandler.payloads.pop(missing) + with pytest.raises(pm.InstallError): + install() + assert pm.installed_package(name, allow_outdated=True).binary == old + RangeHandler.payloads[missing] = raw + assert subprocess.check_output([install()], text=True).strip() == f"{name} fixture 2" \ No newline at end of file diff --git a/tests/pm/test_setup_lock_format.py b/tests/pm/test_setup_lock_format.py index 982f4f454e..5c04361ebf 100644 --- a/tests/pm/test_setup_lock_format.py +++ b/tests/pm/test_setup_lock_format.py @@ -16,7 +16,7 @@ import sys import pytest from pm.store import current_target -from tests.pm.test_pm_core import make_tar, served # noqa: F401 -- shared HTTP fixture +from tests.pm._fixtures import make_tar, served # noqa: F401 -- shared HTTP fixture pytestmark = pytest.mark.platforms("posix") diff --git a/tests/pm/test_source_update_launch.py b/tests/pm/test_source_update_launch.py index cdde8c09e6..b512383632 100644 --- a/tests/pm/test_source_update_launch.py +++ b/tests/pm/test_source_update_launch.py @@ -23,7 +23,7 @@ from hermes_cli.runtime_paths import install_state_dir, runtime_facts_path, sele from pm import paths from pm.lock import Facts from pm.package import InstallError -from tests.pm.test_worker import isolated_python # noqa: F401 +from tests.pm._fixtures import isolated_python # noqa: F401 @pytest.fixture diff --git a/tests/pm/test_stage_only.py b/tests/pm/test_stage_only.py index 377b779c70..7a2243d7cf 100644 --- a/tests/pm/test_stage_only.py +++ b/tests/pm/test_stage_only.py @@ -200,7 +200,7 @@ def test_repin_failure_preserves_previous_staged_entry(sandbox, monkeypatch, fai raise InstallError("stage-test", "injected staging failure") if failure == "fetch": - monkeypatch.setattr(sandbox, "fetch", fail) + monkeypatch.setattr(sandbox, "fetch_many", fail) else: monkeypatch.setattr(sandbox, "publish", fail) with pytest.raises(InstallError, match="injected staging failure"): diff --git a/tests/pm/test_startup_activation.py b/tests/pm/test_startup_activation.py new file mode 100644 index 0000000000..ca20099747 --- /dev/null +++ b/tests/pm/test_startup_activation.py @@ -0,0 +1,114 @@ +"""Startup consumes one real PM verdict without activating a partial store.""" + +import importlib +import os +import sys +from pathlib import Path + +import pytest + +import pm +from pm import paths, registry +from pm.lock import Lockfile +from pm.packages import BinaryPackage +from tests.pm._fixtures import make_tar, served as served + + +@pytest.fixture +def checked_store(tmp_path, monkeypatch, served): + engine = importlib.import_module("pm.ensure") + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home")) + monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "store")) + monkeypatch.setattr(paths, "repo_root", lambda: tmp_path / "repo") + monkeypatch.setattr(paths, "lockfile_path", lambda: tmp_path / "lock.json") + monkeypatch.setattr(registry, "_packages", {}) + docroot, url = served + lock = Lockfile(paths.lockfile_path()) + binaries = [] + for name in ("first", "second"): + package = BinaryPackage() + package.name = name + package.probe_version = False + package.binary_rel = {"posix": f"bin/{name}", "win32": f"bin/{name}"} + registry._packages[name] = package + archive, digest = make_tar(docroot, f"{name}.tar.gz", {f"bin/{name}": "fixture tool"}) + lock.set_pin(name, "1.0", {"any": {"url": f"{url}/{archive}", "sha256": digest}}) + lock.save() + engine.ensure(name, explicit=True) + installed = pm.installed_package(name) + assert installed is not None and installed.binary is not None + binaries.append(installed.binary) + + checks = [] + real_check = engine.check + + def counted_check(): + problems = real_check() + checks.append(problems) + return problems + + monkeypatch.setattr(engine, "check", counted_check) + monkeypatch.setattr(pm, "check", counted_check) + return binaries, checks + + +def test_activate_returns_live_verdict_without_partial_environment(checked_store, monkeypatch): + binaries, checks = checked_store + original_path = os.environ["PATH"] + monkeypatch.setenv("PATH", original_path) + + assert pm.activate() == [] + assert checks == [[]] + active_path = os.environ["PATH"].split(os.pathsep) + assert all(str(binary.parent) in active_path for binary in binaries) + + # A second invocation must observe fresh damage, even after healthy activation. + monkeypatch.setenv("PATH", original_path) + binaries[1].unlink() + before = dict(os.environ) + problems = pm.activate() + assert problems == ["second: not installed or outdated"] + assert checks == [[], problems] + assert dict(os.environ) == before + + +@pytest.mark.parametrize("surface", ["gateway", "cli"]) +@pytest.mark.parametrize("damaged", [False, True]) +def test_startup_uses_one_verdict(checked_store, monkeypatch, capsys, caplog, surface, damaged): + binaries, checks = checked_store + if surface == "gateway": + from gateway.run import _run_pm_startup + start = _run_pm_startup + else: + from hermes_cli import main + + # Stop at --help: exercise the actual startup block, not an agent session. + monkeypatch.setattr("hermes_cli.boot_bootstrap.maybe_run_boot_bootstrap", lambda _root: None) + for name in ("_set_process_title", "_advertise_agent_env", + "_sweep_stale_bytecode_if_checkout_changed", + "_try_termux_fast_tui_launch", "_try_termux_fast_cli_launch", + "_try_fast_serve_launch", "_try_fast_chat_launch"): + monkeypatch.setattr(main, name, lambda: None) + monkeypatch.setattr(sys, "argv", ["hermes", "--help"]) + + def start(): + with pytest.raises(SystemExit) as exit_info: + main.main() + assert exit_info.value.code == 0 + + if damaged: + binaries[1].unlink() + original_path = os.environ["PATH"] + monkeypatch.setenv("PATH", original_path) + start() + + expected = ["second: not installed or outdated"] if damaged else [] + assert checks == [expected] + diagnostics = capsys.readouterr().err + caplog.text + if damaged: + assert os.environ["PATH"] == original_path + assert "install out of sync (second: not installed or outdated)" in diagnostics + else: + assert all(str(binary.parent) in os.environ["PATH"].split(os.pathsep) for binary in binaries) + assert "install out of sync" not in diagnostics \ No newline at end of file diff --git a/tests/pm/test_startup_recovery.py b/tests/pm/test_startup_recovery.py index f1824afd6c..a809a03b4f 100644 --- a/tests/pm/test_startup_recovery.py +++ b/tests/pm/test_startup_recovery.py @@ -14,7 +14,7 @@ import pytest from pm.lock import Facts, Lockfile from pm.runtime import runtime_environment from pm.store import current_target, sha256_file, tree_digest -from tests.pm.test_workspace_build_inputs import _wheel +from tests.pm._fixtures import _wheel @pytest.fixture(autouse=True) def isolated_machine_home(tmp_path, monkeypatch): @@ -39,7 +39,8 @@ def test_bootstrap_repairs_before_dependency_activation(tmp_path, monkeypatch, m 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"): + for name in ("__init__.py", "runtime_paths.py", "runtime_state.py", "_early_recovery.py", + "_parser.py", "venv_sync.py", "steward.py"): shutil.copy2(repo / "hermes_cli" / name, cli / name) wheels = tmp_path / "wheels" wheels.mkdir() diff --git a/tests/pm/test_takeover_preparation.py b/tests/pm/test_takeover_preparation.py new file mode 100644 index 0000000000..d4a9dfa536 --- /dev/null +++ b/tests/pm/test_takeover_preparation.py @@ -0,0 +1,119 @@ +"""Fresh takeover bootstrap prepares actual dependencies before completion.""" +from __future__ import annotations + +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys + +import pytest + +from pm.lock import Facts, Lockfile +from pm.store import current_target, tree_digest +from tests.pm._fixtures import _wheel + + +@pytest.mark.platforms("linux") +def test_fresh_takeover_prepares_generation_and_runs_selected_python(tmp_path): + source = Path(__file__).resolve().parents[2] + root = tmp_path / "source with spaces" + root.mkdir() + for name in ("pm", "hermes_cli"): + shutil.copytree(source / name, root / name, ignore=shutil.ignore_patterns("__pycache__", "*.pyc")) + for name in ("hermes_constants.py", "hermes_yaml.py", "utils.py", "hermes_bootstrap.py"): + shutil.copy2(source / name, root / name) + home, store, wheels = (tmp_path / name for name in ("home", "tools", "wheels")) + for directory in (home, store, wheels): + directory.mkdir() + _wheel(wheels, "takeover_dep", "1.0") + (root / "pyproject.toml").write_text( + '[project]\nname="takeover-fixture"\nversion="1"\nrequires-python=">=3.14"\n' + 'dependencies=["takeover-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") + (root / ".git").mkdir() + (root / "install-stamp.json").write_text('{"updateMechanism":"self"}', encoding="utf-8") + uv = shutil.which("uv") + assert uv + env = {key: value for key, value in os.environ.items() + if not key.startswith(("HERMES_", "PYTHON", "UV_"))} + env.update(HOME=str(home), HERMES_HOME=str(home), HERMES_RUNTIME_DIR=str(store), UV_PYTHON_DOWNLOADS="never") + subprocess.run([uv, "lock", "--offline", "--python", sys.executable], cwd=root, env=env, check=True, capture_output=True) + # Only the interpreter and private manager tool are needed by this tiny + # application. They are real host tools; PM's worker and resolver stay real. + (root / "pm/lock.json").write_text('{"schema":1,"packages":{}}', encoding="utf-8") + lock = Lockfile(root / "pm/lock.json") + target = current_target() + for name, executable, rel in (("python", sys._base_executable, "bin/python3"), ("uv", uv, "uv")): + entry = store / name + binary = entry / rel + binary.parent.mkdir(parents=True, exist_ok=True) + binary.symlink_to(Path(executable).resolve()) + if name == "uv": + (entry / "uvx").symlink_to(Path(uv).resolve().with_name("uvx")) + lock.set_pin(name, "fixture", {}) + Facts(store / "facts.json").record(name, "fixture", name, {"PATH": [str(binary.parent)]}, store, + target=target, digest=tree_digest(entry)) + lock.save() + # Probe the selected-interpreter boundary; app completion orchestration has + # its own contract tests. This script cannot import the dep from the parent. + (root / "hermes_cli/update_finish.py").write_text( + "import json, sys\nfrom pathlib import Path\n" + "request=json.loads(Path(sys.argv[1]).read_text())\n" + "sys.path.insert(0,request['root'])\n" + "from hermes_cli.runtime_paths import activate_dependencies\n" + "activate_dependencies(Path(request['root']))\nimport takeover_dep\n" + "assert request['pm_receipt']['update_id']==request['update_id']\n" + "Path(sys.argv[2]).write_text(json.dumps({'python':sys.executable,'dep':takeover_dep.__file__}))\n", + encoding="utf-8") + context, result = tmp_path / "context.json", tmp_path / "result.json" + context.write_text(json.dumps({"root": str(root), "home": str(home)}), encoding="utf-8") + completed = subprocess.run([sys.executable, "-I", "-S", "-B", str(root / "hermes_cli/_update_takeover.py"), str(context), str(result)], + env=env, cwd=tmp_path, capture_output=True, text=True, timeout=120) + assert completed.returncode == 0, completed.stdout + completed.stderr + output = json.loads(result.read_text()) + assert Path(output['python']).is_relative_to(store) + assert Path(output['dep']).is_relative_to(home / "installs") + assert (root / ".hermes/bin/hermes").is_file() + assert not (root / "venv").exists() + + # Repair restores the recorded graph; a checkout update must also advance + # that graph to the new source inputs before normal bootstrap checks it. + facts = next((home / "installs").glob("*/facts.json")) + first_stamp = json.loads(facts.read_text())["packages"]["venv"]["stamp"] + (facts.parent / ".repair-incomplete").write_text("{}", encoding="utf-8") + with (root / "uv.lock").open("a", encoding="utf-8") as changed: + changed.write("\n# changed source inputs\n") + repaired = subprocess.run([sys.executable, "-I", "-S", "-B", str(root / "hermes_cli/_update_takeover.py"), str(context), str(result)], + env=env, cwd=tmp_path, capture_output=True, text=True, timeout=120) + assert repaired.returncode == 0, repaired.stdout + repaired.stderr + assert json.loads(facts.read_text())["packages"]["venv"]["stamp"] != first_stamp + assert not (facts.parent / ".repair-incomplete").exists() + + # A child which dies before acknowledging receipt ownership must not + # leave the parent reporting success from its pre-handoff receipt. + (root / "hermes_cli/update_finish.py").write_text("raise SystemExit(7)\n", encoding="utf-8") + result.unlink() + crashed = subprocess.run([sys.executable, "-I", "-S", "-B", str(root / "hermes_cli/_update_takeover.py"), str(context), str(result)], + env=env, cwd=tmp_path, capture_output=True, text=True, timeout=120) + assert crashed.returncode == 7 + assert json.loads(result.read_text())["receipt_handled"] + crash_receipt = json.loads((home / "logs/update_receipts/latest.json").read_text()) + assert crash_receipt["exit_code"] == 7 and crash_receipt["outcome"] == "failed" + + # A failed preparation must not delegate to application completion or + # replace the working generation, and must leave a truthful receipt. + facts = next((home / "installs").glob("*/facts.json")) + before = facts.read_bytes() + (root / "uv.lock").write_text("not valid TOML [", encoding="utf-8") + result.unlink() + failed = subprocess.run([sys.executable, "-I", "-S", "-B", str(root / "hermes_cli/_update_takeover.py"), str(context), str(result)], + env=env, cwd=tmp_path, capture_output=True, text=True, timeout=120) + assert failed.returncode != 0 + assert facts.read_bytes() == before + failure = json.loads(result.read_text()) + assert failure["receipt_handled"] is True + latest = json.loads((home / "logs/update_receipts/latest.json").read_text()) + assert latest["outcome"] == "failed" diff --git a/tests/pm/test_union_installs_members.py b/tests/pm/test_union_installs_members.py deleted file mode 100644 index de1b017c9c..0000000000 --- a/tests/pm/test_union_installs_members.py +++ /dev/null @@ -1,113 +0,0 @@ -"""E2E: the workspace union must INSTALL member deps, not just lock them. - -Probed live 2026-09-03: plain `uv sync --frozen` installs only the ROOT -project's dependencies — workspace-member deps are locked by `uv lock` -but never reach site-packages. The union's whole contract is that plugin -deps ride the venv, so lock_and_sync must pass --all-packages. This test -builds a REAL mini-workspace and asserts the member's dep is importable -after lock_and_sync — the exact failure the probe caught (green-looking -lock, empty site-packages) can never silently return. -""" - -from __future__ import annotations - -import sys -from pathlib import Path - -import pytest - -import pm.workspace as ws - -@pytest.fixture(autouse=True) -def isolated_machine_home(tmp_path, monkeypatch): - monkeypatch.setattr(Path, "home", lambda: tmp_path) - monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) - - - -def _site_packages(venv: Path) -> Path: - """The fixture supplies this interpreter as the PM toolchain.""" - import sysconfig - - return Path(sysconfig.get_paths(vars={"base": str(venv)})["purelib"]) - - -def _uv_available() -> bool: - import shutil - - return shutil.which("uv") is not None - - -@pytest.fixture -def mini_workspace(tmp_path, monkeypatch): - """A real generated workspace: fake core pyproject + a plugin member - whose dep (pyfiglet) is NOT a root dep. Store paths pointed at tmp.""" - core = tmp_path / "core" - core.mkdir() - (core / "pyproject.toml").write_text( - "[project]\n" - 'name = "fake-core"\n' - 'version = "0.1.0"\n' - 'requires-python = ">=3.11"\n' - 'dependencies = []\n', - encoding="utf-8", - ) - plug = tmp_path / "plugins" / "member-plug" - plug.mkdir(parents=True) - (plug / "pyproject.toml").write_text( - "[project]\n" - 'name = "member-plug"\n' - 'version = "0.1.0"\n' - 'requires-python = ">=3.11"\n' - 'dependencies = ["pyfiglet==1.0.2"]\n', - encoding="utf-8", - ) - store = tmp_path / "store" - store.mkdir() - venv = tmp_path / "venv" - - import shutil - import pm.paths - - monkeypatch.setattr("pm._uv._toolchain", lambda **kwargs: (Path(shutil.which("uv")), Path(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) - monkeypatch.setattr(ws.paths, "store_root", lambda: store) - return tmp_path, plug, venv - - -@pytest.mark.skipif(_uv_available() is False, reason="uv not on PATH") -def test_lock_and_sync_installs_member_deps(mini_workspace): - """The probe scenario: after lock_and_sync, the member's dep MUST be - importable from the synced venv. Under plain `uv sync --frozen` the - lock contains the dep but site-packages does not — this fails.""" - _, plug, venv = mini_workspace - ws.lock_and_sync([plug], [], venv_dir=venv) - - sp = _site_packages(venv) - assert sp.is_dir(), "no site-packages in the synced venv" - - # the member's dep landed (this is the --all-packages contract) - assert (sp / "pyfiglet").is_dir() or any( - p.name.startswith("pyfiglet") for p in sp.iterdir() - ), ( - "member dep locked but NOT installed — lock_and_sync must pass " - "--all-packages (plain `uv sync --frozen` is root-only)" - ) - - -@pytest.mark.skipif(_uv_available() is False, reason="uv not on PATH") -def test_union_survives_a_resync(mini_workspace): - """The prune-proof contract: a second lock_and_sync (what an update - rebuild does) must NOT remove the member's deps — they are in the - union lock, not pip-guests.""" - _, plug, venv = mini_workspace - ws.lock_and_sync([plug], [], venv_dir=venv) - ws.lock_and_sync([plug], [], venv_dir=venv) - - sp = _site_packages(venv) - assert any(p.name.startswith("pyfiglet") for p in sp.iterdir()), ( - "member deps were stripped by a re-sync — the union lock must own " - "them across rebuilds" - ) diff --git a/tests/pm/test_update_auth_scope.py b/tests/pm/test_update_auth_scope.py index 4bec67d860..30fa29b809 100644 --- a/tests/pm/test_update_auth_scope.py +++ b/tests/pm/test_update_auth_scope.py @@ -9,12 +9,14 @@ import urllib.response import pytest from pm import update +from pm import packages class IndexTransport(urllib.request.HTTPHandler, urllib.request.HTTPSHandler): - def __init__(self, redirects=None): + def __init__(self, redirects=None, body=b'{}'): super().__init__() self.redirects = redirects or {} + self.body = body self.sent = [] def http_open(self, request): @@ -24,7 +26,7 @@ class IndexTransport(urllib.request.HTTPHandler, urllib.request.HTTPSHandler): if destination: headers["Location"] = destination response = urllib.response.addinfourl( - io.BytesIO(b'{}'), headers, request.full_url, 302 if destination else 200, + io.BytesIO(self.body), headers, request.full_url, 302 if destination else 200, ) response.msg = "Found" if destination else "OK" return response @@ -56,7 +58,7 @@ def test_index_credentials_match_the_exact_origin(monkeypatch, url, token): assert headers["user-agent"] == "hermes-pm" -@pytest.mark.parametrize("reader", [update._get_json, update._get_text], ids=["json", "text"]) +@pytest.mark.parametrize("reader", ["json", "text", "release-digest"]) @pytest.mark.parametrize("destination,credential_survives", [ ("https://api.github.com/second", True), ("https://unrelated.invalid/second", False), @@ -64,12 +66,22 @@ def test_index_credentials_match_the_exact_origin(monkeypatch, url, token): ("http://api.github.com/second", False), ]) def test_index_redirects_use_the_credential_safe_opener(monkeypatch, reader, destination, credential_survives): - origin = "https://api.github.com/first" - transport = IndexTransport({origin: destination}) + origin = "https://api.github.com/repos/ggml-org/llama.cpp/releases/tags/b123" + transport = IndexTransport( + {origin: destination}, + body=b'{"assets": [{"name": "tool.zip", "digest": "sha256:abc123"}]}', + ) monkeypatch.setattr(urllib.request, "_opener", urllib.request.build_opener(transport)) monkeypatch.setenv("GH_TOKEN", "dummy-gh") - reader(origin) + if reader == "release-digest": + monkeypatch.setattr(packages, "_release_digest_cache", {}) + package = packages.LlamaCpp() + assert package.known_sha256("123", "https://github.com/example/tool.zip") == "abc123" + # A cache hit must retain the parsed result without making another request. + assert package.known_sha256("123", "https://github.com/example/tool.zip") == "abc123" + else: + {"json": update._get_json, "text": update._get_text}[reader](origin) assert [url for url, _ in transport.sent] == [origin, destination] initial, redirected = [{key.lower(): value for key, value in headers.items()} for _, headers in transport.sent] diff --git a/tests/pm/test_worker.py b/tests/pm/test_worker.py index fc674cd9bd..5928b31526 100644 --- a/tests/pm/test_worker.py +++ b/tests/pm/test_worker.py @@ -15,33 +15,12 @@ from pm.package import InstallError from pm.runtime import runtime_python from tests.pm._range_server import RangeHandler, dl_server, url # noqa: F401 from tests.pm.test_runtime_wheelhouse import locked_wheelhouse # noqa: F401 - - -@pytest.fixture(scope="module") -def isolated_python(tmp_path_factory): - from pm.runtime_stage import stage_runtime - - root = tmp_path_factory.mktemp("pm-python") - uv = shutil.which("uv") - assert uv, "the worker contract requires real uv" - python = stage_runtime(Path(uv), Path(sys.executable), root) - probe = subprocess.run( - [str(python), "-I", "-c", "import importlib.util; assert importlib.util.find_spec('yaml') is None"], - capture_output=True, text=True, timeout=30, - ) - assert probe.returncode == 0, probe.stderr - return python - - -@pytest.fixture -def client(tmp_path, monkeypatch, isolated_python): - client = importlib.import_module("pm.client") - monkeypatch.setattr("pm.runtime.runtime_python", lambda **kwargs: isolated_python) - monkeypatch.setenv("HOME", str(tmp_path)) - monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home")) - monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "store")) - monkeypatch.setattr(paths, "lockfile_path", lambda: tmp_path / "lock.json") - return client +from tests.pm._fixtures import ( + _wheel, + build_worker as build_worker, + client as client, + isolated_python as isolated_python, +) def test_isolated_worker_preserves_install_error(client, monkeypatch): @@ -55,28 +34,18 @@ def test_isolated_worker_preserves_install_error(client, monkeypatch): assert not paths.facts_path().exists() -def test_worker_build_owns_creation_and_preserves_parent_environment(client, tmp_path, monkeypatch, isolated_python): +def test_worker_build_owns_creation_and_preserves_parent_environment(build_worker, tmp_path, monkeypatch): import json - from pm import operations - - uv = shutil.which("uv") - assert uv - worker = Path(client.__file__).with_name("worker.py") - script = ( - "import runpy, sys; " - f"sys.path.insert(0, {str(worker.parent.parent)!r}); " - "import pm._uv; " - f"pm._uv._toolchain = lambda **kwargs: (__import__('pathlib').Path({uv!r}), " - f"__import__('pathlib').Path({sys.executable!r})); " - f"runpy.run_path({str(worker)!r}, run_name='__main__')" - ) - monkeypatch.setattr(client, "runtime_command", lambda path, **kwargs: [str(isolated_python), "-I", "-B", "-c", script]) - monkeypatch.setattr(operations, "build_environment", lambda **kwargs: pytest.fail("build ran in caller")) + client = build_worker source = tmp_path / "project with spaces" source.mkdir() + wheels = tmp_path / "wheels" + wheels.mkdir() + _wheel(wheels, "worker_dep") (source / "pyproject.toml").write_text( '[project]\nname="worker-proof"\nversion="1"\nrequires-python=">=3.11"\n' - '[tool.uv]\npackage=false\n', encoding="utf-8", + 'dependencies=["worker-dep==1.0"]\n[tool.uv]\npackage=false\nno-index=true\n' + f'find-links=[{json.dumps(wheels.as_posix())}]\n', encoding="utf-8", ) monkeypatch.setenv("UV_PYTHON", "/not-the-interpreter") monkeypatch.setenv("UV_PROJECT_ENVIRONMENT", str(tmp_path / "wrong-environment")) @@ -84,10 +53,11 @@ def test_worker_build_owns_creation_and_preserves_parent_environment(client, tmp before = dict(os.environ) python = client.build_environment(source=source, out=tmp_path / "dependency tree", frozen=False, offline=True, explicit=True) - result = subprocess.run([str(python), "-I", "-c", "import json,sys; print(json.dumps(sys.prefix))"], + result = subprocess.run([str(python), "-I", "-c", "import json,sys,worker_dep; print(json.dumps([sys.prefix, worker_dep.__version__]))"], capture_output=True, text=True, timeout=30) assert result.returncode == 0, result.stderr - assert Path(json.loads(result.stdout)) == python.parent.parent + assert json.loads(result.stdout) == [str(python.parent.parent), "1.0"] + assert "worker_dep" not in sys.modules assert not (tmp_path / "wrong-environment").exists() assert dict(os.environ) == before with pytest.raises(OSError, match="already exists"): @@ -333,8 +303,10 @@ def _node_archive(server, body=b"#!/bin/sh\nexit 0\n"): @pytest.mark.platforms("posix") @pytest.mark.parametrize("operation", ["ensure", "stage_only"]) -def test_worker_installs_real_archive_and_relays_progress(client, dl_server, operation): - from pm.lock import Facts +def test_worker_artifact_lifecycle_keeps_identity_and_relays_progress(client, dl_server, operation): + import hashlib + from pm.lock import Facts, Lockfile + from pm.store import tree_digest target, relative, body = _node_archive(dl_server) stages, downloads = [], [] @@ -362,6 +334,35 @@ def test_worker_installs_real_archive_and_relays_progress(client, dl_server, ope assert (entry / relative).read_bytes() == body assert stages and stages[0][0] == "download" + def install(): + if operation == "stage_only": + return client.stage_only("node", target) + client.ensure("node", explicit=True) + return entry + + first_tree = tree_digest(entry) + RangeHandler.payloads.clear() + assert install() == entry + assert tree_digest(entry) == first_tree + _, _, replacement = _node_archive(dl_server, b"#!/bin/sh\nexit 0\n# repinned\n") + assert install() == entry + assert (entry / relative).read_bytes() == replacement + assert not list(paths.store_root().glob("fetch-*")) + good_tree = tree_digest(entry) + previous_facts = paths.facts_path().read_bytes() if paths.facts_path().exists() else None + lock = Lockfile(paths.lockfile_path()) + RangeHandler.payloads["/node.zip"] = b"not an archive" + lock.set_pin("node", "1", {target: {"url": url(dl_server, "/node.zip"), + "sha256": hashlib.sha256(b"not an archive").hexdigest()}}) + lock.save() + with pytest.raises(RuntimeError if operation == "stage_only" else InstallError, match="zip"): + install() + assert tree_digest(entry) == good_tree + if operation == "stage_only": + assert not paths.facts_path().exists(), "foreign staging cannot publish host identity" + else: + assert paths.facts_path().read_bytes() == previous_facts + @pytest.mark.platforms("posix") @pytest.mark.parametrize("from_progress", [False, True]) @@ -584,7 +585,6 @@ def test_cold_manager_build_does_not_bootstrap_a_worker(client, tmp_path, monkey def test_worker_side_environment_reuses_and_keeps_selection_on_failed_tool(client, tmp_path, monkeypatch, isolated_python): import zipfile from pm import environment_python, python_tool - from tests.pm.test_environment_build import _wheel uv = shutil.which("uv") assert uv diff --git a/tests/pm/test_workspace.py b/tests/pm/test_workspace.py index 5db887213d..7a8a001490 100644 --- a/tests/pm/test_workspace.py +++ b/tests/pm/test_workspace.py @@ -31,7 +31,7 @@ def isolated_machine_home(tmp_path, monkeypatch): @pytest.fixture def layout(tmp_path, monkeypatch): """A fake install: core repo with pyproject, plugin dirs, store.""" - from tests.pm.test_workspace_build_inputs import _wheel + from tests.pm._fixtures import _wheel wheels = tmp_path / "wheels" wheels.mkdir() @@ -159,7 +159,7 @@ def test_member_stamp_missing_pyproject_does_not_crash(tmp_path): """A member dir whose pyproject vanished mid-scan hashes on path only.""" plug = tmp_path / "ghost" plug.mkdir() - (plug / "pyproject.toml").write_text("x\n", encoding="utf-8") + (plug / "pyproject.toml").write_text("[project]\n", encoding="utf-8") first = ws.members_stamp([plug]) (plug / "pyproject.toml").unlink() second = ws.members_stamp([plug]) diff --git a/tests/pm/test_workspace_build_inputs.py b/tests/pm/test_workspace_build_inputs.py index dd6a6b4b7d..f315041e42 100644 --- a/tests/pm/test_workspace_build_inputs.py +++ b/tests/pm/test_workspace_build_inputs.py @@ -8,6 +8,7 @@ import sys import pytest from pm import workspace +from tests.pm import _fixtures @pytest.fixture(autouse=True) @@ -73,7 +74,7 @@ def test_legacy_member_is_generated_only_inside_workspace(tmp_path, monkeypatch) core.mkdir(); plugin.mkdir() wheels = tmp_path / "wheels" wheels.mkdir() - _wheel(wheels, "example", "1.0") + _fixtures._wheel(wheels, "example", "1.0") (core / "pyproject.toml").write_text( '[project]\nname="core"\nversion="1"\nrequires-python=">=3.14"\n' '[tool.uv]\npackage=false\nno-index=true\n' @@ -96,22 +97,6 @@ def test_legacy_member_is_generated_only_inside_workspace(tmp_path, monkeypatch) assert workspace.members_stamp([plugin]) != stamp -def _wheel(directory, name, version, requirements=()): - import zipfile - - metadata = f"{name}-{version}.dist-info" - entries = { - f"{name}/__init__.py": f"__version__ = {version!r}\n", - f"{metadata}/METADATA": f"Metadata-Version: 2.1\nName: {name}\nVersion: {version}\n" - + "".join(f"Requires-Dist: {requirement}\n" for requirement in requirements), - f"{metadata}/WHEEL": "Wheel-Version: 1.0\nGenerator: fixture\nRoot-Is-Purelib: true\nTag: py3-none-any\n", - } - entries[f"{metadata}/RECORD"] = "".join(f"{path},,\n" for path in entries) - with zipfile.ZipFile(directory / f"{name}-{version}-py3-none-any.whl", "w") as archive: - for path, body in entries.items(): - archive.writestr(path, body) - - @pytest.mark.parametrize("exact", [False, True]) def test_plugin_can_move_compatible_transitive_but_not_exact_requirement(tmp_path, monkeypatch, exact): import os @@ -121,8 +106,8 @@ def test_plugin_can_move_compatible_transitive_but_not_exact_requirement(tmp_pat core.mkdir() wheels = tmp_path / "wheels" wheels.mkdir() - _wheel(wheels, "pkga", "1.0", ["pkgb>=1.2,<2"]) - _wheel(wheels, "pkgb", "1.2") + _fixtures._wheel(wheels, "pkga", "1.0", ["pkgb>=1.2,<2"]) + _fixtures._wheel(wheels, "pkgb", "1.2") core_requirement = '["pkga==1.0", "pkgb==1.2"]' if exact else '["pkga==1.0"]' (core / "pyproject.toml").write_text( '[project]\nname="core-proof"\nversion="1"\nrequires-python=">=3.11"\n' @@ -137,7 +122,7 @@ def test_plugin_can_move_compatible_transitive_but_not_exact_requirement(tmp_pat workspace.lock_and_sync([], [], root=baseline, venv_dir=first_env) first_lock = (baseline / "uv.lock").read_bytes() assert next(p["version"] for p in tomllib.loads(first_lock.decode())["package"] if p["name"] == "pkgb") == "1.2" - _wheel(wheels, "pkgb", "1.3") + _fixtures._wheel(wheels, "pkgb", "1.3") plugin = tmp_path / "plugin" plugin.mkdir() (plugin / "pyproject.toml").write_text( diff --git a/tests/pm/test_workspace_output_encoding.py b/tests/pm/test_workspace_output_encoding.py index 5a21c2d5f3..861193ef77 100644 --- a/tests/pm/test_workspace_output_encoding.py +++ b/tests/pm/test_workspace_output_encoding.py @@ -91,7 +91,15 @@ def test_node_sidecar_retains_output_and_exit_status( completed.append(result) return result - error = ws.install_node_sidecar(tmp_path, npm_bin=sys.executable, runner=run_npm) + from pm.package import Runner + + npm = tmp_path / ("npm.cmd" if os.name == "nt" else "npm") + npm.write_text("process-boundary fixture", encoding="utf-8") + npm.chmod(0o755) + runner = Runner("npm", {**os.environ, "PATH": str(tmp_path)}) + monkeypatch.setattr("pm.ensure", lambda *args, **kwargs: runner) + monkeypatch.setattr("pm.package.subprocess.run", run_npm) + error = ws.install_node_sidecar(tmp_path, explicit=True) expected = diagnostic + "�" if returncode: diff --git a/tests/scripts/test_agent_build.py b/tests/scripts/test_agent_build.py index 33192d1355..e357e57951 100644 --- a/tests/scripts/test_agent_build.py +++ b/tests/scripts/test_agent_build.py @@ -233,6 +233,56 @@ def test_fixed_launcher_accepts_explicit_external_python_without_path_guessing(t assert run.returncode == 7, run.stderr +@pytest.mark.platforms("posix") +def test_payload_smoke_uses_relocated_manifest_commands(tmp_path): + out, data = inputs_fixture(tmp_path) + source = Path(data["code"]) + (source / "pyproject.toml").write_text( + '[project]\nname="smoke-fixture"\nversion="1"\n' + '[project.scripts]\nhermes="entry:main"\n', encoding="utf-8") + (source / "entry.py").write_text( + "import json, os, sys\nfrom pathlib import Path\n" + "def main():\n" + f" assert not Path({str(out)!r}).exists(), 'payload was not moved'\n" + " assert 'HERMES_PYTHON' not in os.environ\n" + " assert not Path.cwd().is_relative_to(Path(__file__).parent)\n" + " if sys.argv[1:] == ['tools', 'list']:\n" + " import dependency\n" + " print(json.dumps([sys.argv[1:], dependency.VALUE]))\n" + " else: print('fast path')\n", encoding="utf-8") + data["bin_dir"] = "libexec" + assert build_cli(data, out, tmp_path).returncode == 0 + shutil.rmtree(source) + env = dict(os.environ, PYTHONPATH="/foreign", PYTHONHOME="/foreign", HERMES_PYTHON="/foreign") + command = ["bash", str(ROOT / "scripts/smoke-payload.sh"), str(out)] + result = subprocess.run(command, cwd=tmp_path, env=env, capture_output=True, text=True, timeout=30) + assert result.returncode == 0, result.stdout + result.stderr + assert '"prepared"' in result.stdout + assert out.is_dir(), "smoke must restore the artifact for packaging" + dependency = Path(data['site_packages']) / 'dependency.py' + dependency.unlink() + missing_dep = subprocess.run(command, cwd=tmp_path, env=env, capture_output=True, text=True, timeout=30) + assert missing_dep.returncode != 0 + assert 'SMOKE OK' not in missing_dep.stdout + # A broken published command cannot be rescued by importing raw Python. + (out / "libexec/hermes").unlink() + failed = subprocess.run(command, cwd=tmp_path, env=env, capture_output=True, text=True, timeout=30) + assert failed.returncode != 0 + assert "SMOKE OK" not in failed.stdout + assert out.is_dir(), "failed smoke must also restore the artifact" + # Even an executable reporting success is not a payload command if it escapes. + manifest = json.loads((out / 'manifest.json').read_text()) + external = shutil.which('true') + assert external + (out / 'libexec/hermes').symlink_to(external) + for path in (external, 'libexec/hermes'): + manifest['runtime']['commands']['hermes'] = path + (out / 'manifest.json').write_text(json.dumps(manifest), encoding='utf-8') + escaped = subprocess.run(command, cwd=tmp_path, env=env, capture_output=True, text=True, timeout=30) + assert escaped.returncode != 0, escaped.stdout + assert 'SMOKE OK' not in escaped.stdout + + @pytest.mark.platforms("posix") def test_stage_passes_explicit_products_to_the_single_assembler(tmp_path, monkeypatch): from scripts.bundles import stage diff --git a/tests/scripts/test_bundle_native.py b/tests/scripts/test_bundle_native.py index 194e636a22..d4ec324552 100644 --- a/tests/scripts/test_bundle_native.py +++ b/tests/scripts/test_bundle_native.py @@ -198,7 +198,7 @@ def test_staged_cache_installs_built_wheel_without_unsigned_zip(tmp_path): def test_native_dispatch_reuses_pm_cache_offline(tmp_path, monkeypatch): import pm from pm.packages import uv_cache_dir - from tests.pm.test_workspace_build_inputs import _wheel + from tests.pm._fixtures import _wheel monkeypatch.setattr(Path, "home", lambda: tmp_path / "home") monkeypatch.setenv("HERMES_HOME", str(tmp_path / "setup-pm")) diff --git a/tests/scripts/test_bundle_npm.py b/tests/scripts/test_bundle_npm.py new file mode 100644 index 0000000000..3164b1ef67 --- /dev/null +++ b/tests/scripts/test_bundle_npm.py @@ -0,0 +1,33 @@ +"""Bundle commands run the prepared npm, including Nix's shell wrapper.""" +import json +import os +from pathlib import Path +import shutil +import subprocess + +from scripts.bundles.desktop import npm_command + + +def test_bundle_npm_runs_workspace_from_unrelated_directory(tmp_path): + node = shutil.which("node") + assert node, "the build acceptance lane must provide Node/npm" + workspace = tmp_path / "workspace with spaces" + workspace.mkdir() + (workspace / "package.json").write_text(json.dumps({ + "name": "bundle-command-fixture", "version": "1.0.0", + "scripts": {"build": "node probe.cjs"}, + }), encoding="utf-8") + (workspace / "probe.cjs").write_text( + "console.log(JSON.stringify({args:process.argv.slice(2),npm:process.env.npm_execpath}));" + "process.exitCode=7", encoding="utf-8") + env = {key: value for key, value in os.environ.items() + if key.lower() != "npm_execpath"} + env.update(HOME=str(tmp_path), npm_config_cache=str(tmp_path / "cache"), + npm_config_offline="true") + result = subprocess.run([*npm_command(node), "--prefix", str(workspace), + "run", "--silent", "build", "--", "two words", "$(literal)"], + cwd=tmp_path, env=env, capture_output=True, text=True, timeout=30) + assert result.returncode == 7, result.stdout + result.stderr + child = json.loads(result.stdout) + assert child["args"] == ["two words", "$(literal)"] + assert Path(child["npm"]).name == "npm-cli.js" \ No newline at end of file diff --git a/tests/scripts/test_bundle_store_cleanup.py b/tests/scripts/test_bundle_store_cleanup.py index 76331b0d94..26f37f8f75 100644 --- a/tests/scripts/test_bundle_store_cleanup.py +++ b/tests/scripts/test_bundle_store_cleanup.py @@ -1,6 +1,4 @@ """Reusing a bundle cache must not ship packages removed from its selection.""" -from types import SimpleNamespace - from pm.lock import Facts, Lockfile from scripts.bundles import native @@ -31,18 +29,9 @@ def test_cached_bundle_prunes_unselected_facts_and_entries(tmp_path, monkeypatch lock.set_pin(name, "fixture", {}) lock.save() monkeypatch.setattr(native, "_lockfile", lambda: lock) - from pathlib import Path - import shutil - staged_lock = output / "hermes-agent/pm/lock.json" - staged_lock.parent.mkdir(parents=True) - shutil.copy2(Path(__file__).resolve().parents[2] / "pm/lock.json", staged_lock) - monkeypatch.setattr("scripts.bundles.payload.snapshot", lambda *args: None) - monkeypatch.setattr(native, "_install_names", lambda names: 0) - # Stop after cleanup, before invoking any venv/build subprocesses. - monkeypatch.setattr(native, "pm_uv", lambda: (None, {})) monkeypatch.setenv("HERMES_RUNTIME_DIR", str(user_store)) - assert native._stage_native(SimpleNamespace(out=str(output), ref="HEAD")) == 1 + native.prune_staged_store(staged_store, native._bundle_package_names()) remaining = Facts(staged_store / "facts.json") for name in stale: diff --git a/tests/scripts/test_pm_build_consumers.py b/tests/scripts/test_pm_build_consumers.py index 95c96138ec..7dc9cdd487 100644 --- a/tests/scripts/test_pm_build_consumers.py +++ b/tests/scripts/test_pm_build_consumers.py @@ -55,7 +55,7 @@ def test_ci_setup_exports_no_installer_path_or_policy(tmp_path, monkeypatch, loc def test_ci_packages_exports_python_and_preserves_real_child_exit(tmp_path, monkeypatch, local_toolchain): import pm from scripts.ci import python_packages - from tests.pm.test_workspace_build_inputs import _wheel + from tests.pm._fixtures import _wheel wheels = tmp_path / "wheels" wheels.mkdir() @@ -87,7 +87,7 @@ def test_ci_packages_exports_python_and_preserves_real_child_exit(tmp_path, monk def test_termux_gate_checks_real_offline_wheels_and_application_uses_same_graph(tmp_path, local_toolchain): from pm.package import InstallError from scripts.termux import build_wheels, build_environment - from tests.pm.test_workspace_build_inputs import _wheel + from tests.pm._fixtures import _wheel wheels = tmp_path / "wheelhouse" wheels.mkdir() diff --git a/tests/scripts/test_setup_toolchain.py b/tests/scripts/test_setup_toolchain.py index e14568e20a..6306055ab5 100644 --- a/tests/scripts/test_setup_toolchain.py +++ b/tests/scripts/test_setup_toolchain.py @@ -21,7 +21,7 @@ def test_development_setup_keeps_test_groups_out_of_the_runtime(tmp_path, monkey from scripts.ci import setup_toolchain from pm import lock_project - from tests.pm.test_workspace_build_inputs import _wheel + from tests.pm._fixtures import _wheel monkeypatch.setattr(Path, "home", lambda: tmp_path / "home") core = tmp_path / "core" diff --git a/tests/scripts/test_source_driver.py b/tests/scripts/test_source_driver.py new file mode 100644 index 0000000000..0ecfaf7d7c --- /dev/null +++ b/tests/scripts/test_source_driver.py @@ -0,0 +1,275 @@ +"""Cheap source-driver checks: real shells, disposable installs, no installer.""" +import hashlib +import json +import os +from pathlib import Path +import runpy +import shutil +import subprocess +import sys +import sysconfig + +import pytest + + +ASSETS = Path(__file__).resolve().parents[1] / "install/e2e-assets" + + +@pytest.mark.platforms("posix") +def test_shell_selects_the_installed_command_not_the_phase_or_path(tmp_path): + root = tmp_path / "installed source" + legacy = root / "venv/bin/hermes" + published = root / ".hermes/bin/hermes" + for command, label in ((legacy, "legacy"), (published, "published")): + command.parent.mkdir(parents=True) + command.write_text(f'#!/bin/sh\nprintf "{label}:%s\\n" "$*"\n', encoding="utf-8") + command.chmod(0o755) + env = dict(os.environ, INSTALL_DIR=str(root), ASSETS=str(ASSETS), HOME=str(tmp_path)) + result = subprocess.run(["bash", "-euc", ''' +source "$ASSETS/source-driver.sh" +command=$(source_hermes "$INSTALL_DIR") +"$command" 'literal argument' +rm "$INSTALL_DIR/venv/bin/hermes" +test "$(source_hermes "$INSTALL_DIR")" = "$command" +printf '#!/bin/sh\nexit 23\n' > "$command" +"$command" || test "$?" = 23 +rm "$command" +if source_hermes "$INSTALL_DIR"; then exit 91; fi +printf '#!/bin/sh\nprintf legacy\n' > "$INSTALL_DIR/venv/bin/hermes" +chmod +x "$INSTALL_DIR/venv/bin/hermes" +"$(source_hermes "$INSTALL_DIR")" +# A PM checkout cannot hide missing publication behind its old venv. +mkdir -p "$INSTALL_DIR/pm" +printf '{}' > "$INSTALL_DIR/pm/lock.json" +if source_hermes "$INSTALL_DIR"; then exit 92; fi +'''], env=env, cwd=tmp_path, capture_output=True, text=True, timeout=30) + assert result.returncode == 0, result.stdout + result.stderr + assert result.stdout.splitlines() == ["published:literal argument", "legacy"] + + +@pytest.mark.platforms("windows", "posix") +def test_powershell_selects_exact_exe_or_cmd_and_legacy_fallback(tmp_path): + # These are file-selection rules, not Windows execution emulation. Run + # them in real PowerShell on any host that supplies it (native CI included). + path = os.pathsep.join(p for p in os.get_exec_path() if ".hermes" not in Path(p).parts) + pwsh = shutil.which("pwsh", path=path) or shutil.which("powershell", path=path) + if not pwsh: + if os.name == "nt": + pytest.fail("native Windows acceptance requires PowerShell") + pytest.skip("PowerShell is not available on this host") + root = tmp_path / "installed source" + root.mkdir() + harness = tmp_path / "probe.ps1" + harness.write_text('''$ErrorActionPreference = 'Stop' +. (Join-Path $env:ASSETS 'source-driver.ps1') +$root = $env:INSTALL_DIR +$legacy = Join-Path $root 'venv/Scripts/hermes.exe' +$exe = Join-Path $root '.hermes/bin/hermes.exe' +$cmd = Join-Path $root '.hermes/bin/hermes.cmd' +New-Item -ItemType Directory -Path (Split-Path $legacy), (Split-Path $exe) -Force | Out-Null +Set-Content $legacy 'legacy' +if ((Get-SourceHermes $root) -ne $legacy) { throw 'legacy fallback' } +Set-Content $cmd 'cmd' +if ((Get-SourceHermes $root) -ne $cmd) { throw 'published cmd' } +Set-Content $exe 'exe' +if ((Get-SourceHermes $root) -ne $exe) { throw 'exe precedence' } +Remove-Item $legacy +if ((Get-SourceHermes $root) -ne $exe) { throw 'requires legacy venv' } +Remove-Item $exe, $cmd +$refused = $false +try { Get-SourceHermes $root } catch { $refused = $true } +if (-not $refused) { throw 'missing command accepted' } +Set-Content $legacy 'stale legacy' +New-Item -ItemType Directory -Path (Join-Path $root 'pm') | Out-Null +Set-Content (Join-Path $root 'pm/lock.json') '{}' +$refused = $false +try { Get-SourceHermes $root } catch { $refused = $true } +if (-not $refused) { throw 'missing PM launcher accepted' } +Write-Output 'selection verified' +''', encoding="utf-8") + result = subprocess.run([pwsh, "-NoProfile", "-NonInteractive", "-File", str(harness)], + env=dict(os.environ, INSTALL_DIR=str(root), ASSETS=str(ASSETS), HOME=str(tmp_path)), + cwd=tmp_path, capture_output=True, text=True, timeout=30) + assert result.returncode == 0, result.stdout + result.stderr + assert result.stdout.strip() == "selection verified" + + +def test_observer_preserves_no_desktop_and_refuses_incomplete_app(tmp_path): + verify = runpy.run_path(str(ASSETS / "source_driver.py"))["verify_products"] + root = tmp_path / "source" + root.mkdir() + verify(root, "absent") + assert list(root.iterdir()) == [] + with pytest.raises(RuntimeError, match="desktop"): + verify(root, "present") + release = root / "apps/desktop/release/linux-unpacked" + release.mkdir(parents=True) + (release / "hermes").write_bytes(b"incomplete fixture") + with pytest.raises(RuntimeError, match="desktop"): + verify(root, "absent") + with pytest.raises(RuntimeError, match="desktop"): + verify(root, "present") + assert (release / "hermes").read_bytes() == b"incomplete fixture" + + +@pytest.mark.platforms("posix") +def test_observer_checks_real_compiler_receipts_without_repairing(tmp_path): + verify = runpy.run_path(str(ASSETS / "source_driver.py"))["verify_products"] + path = os.pathsep.join(p for p in os.get_exec_path() if ".hermes" not in Path(p).parts) + node = shutil.which("node", path=path) + assert node, "source-driver tests require the prepared Node tool" + root = tmp_path / "source" + build = root / "scripts/build" + build.mkdir(parents=True) + repo = ASSETS.parents[2] + for name in ("freshness.mjs", "frontend-common.mjs"): + shutil.copy2(repo / "scripts/build" / name, build / name) + outputs = {"tui": root / "ui-tui/dist", "web": root / "hermes_cli/web_dist", + "desktop": root / "apps/desktop/release/linux-unpacked/resources/app.asar.unpacked/dist"} + for out in outputs.values(): + out.mkdir(parents=True) + (out / "index.html").write_text("fixture renderer", encoding="utf-8") + (root / "apps/desktop/release/linux-unpacked/hermes").write_text("fixture executable", encoding="utf-8") + record = '''import { buildInputs, recordProduct } from './scripts/build/freshness.mjs'; +const source = process.cwd(); +for (const [product, out] of Object.entries(JSON.parse(process.argv[1]))) { + recordProduct({ source, product, out, inputs: buildInputs(source, product) }); +} +''' + subprocess.run([node, "--input-type=module", "-e", record, json.dumps({k: str(v) for k, v in outputs.items()})], + cwd=root, check=True, timeout=30) + def snapshot(): + return {str(p.relative_to(root)): (p.read_bytes(), p.stat().st_mtime_ns) + for p in root.rglob("*") if p.is_file()} + before = snapshot() + verify(root, "present", Path(node)) + assert snapshot() == before + # The successful receipts cannot bless damaged or missing outputs. + damaged = outputs["web"] / "index.html" + damaged.write_text("damaged renderer", encoding="utf-8") + before = snapshot() + with pytest.raises(RuntimeError, match="web output"): + verify(root, "present", Path(node)) + assert snapshot() == before + damaged.unlink() + with pytest.raises(RuntimeError, match="web output"): + verify(root, "present", Path(node)) + assert not damaged.exists() + + +@pytest.mark.platforms("posix") +def test_pm_probe_rejects_foreign_launcher_before_any_bootstrap(tmp_path, monkeypatch): + from hermes_cli import _launchers + + root = tmp_path / "installed source" + foreign = tmp_path / "other source" + repo = ASSETS.parents[2] + # Real published-launcher protocol, with only the stdlib pre-boot closure. + for tree in (root, foreign): + for name in ("hermes_constants.py", "hermes_cli/__init__.py", "hermes_cli/_launchers.py", + "hermes_cli/runtime_paths.py"): + dest = tree / name + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(repo / name, dest) + (tree / "pm").mkdir() + (tree / "pm/lock.json").write_text("{}", encoding="utf-8") + (tree / "hermes_bootstrap.py").write_text("raise RuntimeError('bootstrap must not run')", encoding="utf-8") + home = tmp_path / "home" + store = home / "tools" + store.mkdir(parents=True) + interpreter = Path(sys._base_executable).resolve() + (store / "facts.json").write_text(json.dumps({"packages": {"python": { + "entry": str(interpreter.parents[1])}}}), encoding="utf-8") + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setenv("HERMES_RUNTIME_DIR", str(store)) + bin_dir = root / ".hermes/bin" + bin_dir.mkdir(parents=True) + launcher = _launchers.mint_launcher("hermes", foreign, bin_dir, interpreter, None) + assert launcher is not None + result = subprocess.run([sys.executable, "-B", str(ASSETS / "source_driver.py"), + "--root", str(root), "--launcher", str(launcher), "--desktop", "absent"], + cwd=tmp_path, env=dict(os.environ, HOME=str(tmp_path)), + capture_output=True, text=True, timeout=30) + assert result.returncode != 0 + assert "belongs to another installation" in result.stderr + assert "bootstrap must not run" not in result.stderr + assert not (root / "venv").exists() + + +@pytest.mark.platforms("posix") +def test_pm_observer_accepts_ready_fixture_and_leaves_failed_fixture_untouched(tmp_path): + # This exercises the complete observer process, not a whole install. The + # fixture borrows prepared test dependencies and records real PM input + # stamps / compiler receipts. It never resolves or acquires a package. + repo = ASSETS.parents[2] + root = tmp_path / "source" + for directory in ("pm", "hermes_cli"): + shutil.copytree(repo / directory, root / directory, ignore=shutil.ignore_patterns("__pycache__")) + shutil.copy2(repo / "hermes_constants.py", root / "hermes_constants.py") + (root / "hermes_bootstrap.py").write_text("raise RuntimeError('bootstrap must not run')", encoding="utf-8") + (root / "uv.lock").write_text("fixture locked graph", encoding="utf-8") + home = tmp_path / "home" + store = tmp_path / "store" + env = dict(os.environ, HOME=str(home), HERMES_HOME=str(home), HERMES_RUNTIME_DIR=str(store), + PYTHONDONTWRITEBYTECODE="1", HERMES_DISABLE_LAZY_INSTALLS="1") + env.pop("HERMES_INSTALL_ROOT", None) + path = os.pathsep.join(p for p in os.get_exec_path() if ".hermes" not in Path(p).parts) + node = shutil.which("node", path=path) + assert node + setup = '''import sys +from pathlib import Path +root, store, node, deps = map(Path, sys.argv[1:]) +sys.path.insert(0, str(root)) +from pm.lock import Facts, Lockfile +from pm.packages import Venv +from hermes_cli.runtime_paths import install_state_dir, runtime_facts_path, site_packages +from hermes_cli._launchers import ensure_install_launchers +lock = Lockfile(root / 'pm/lock.json') +lock.set_pin('node', 'fixture', {}) +lock.save() +for name, executable in [('python', Path(sys.executable)), ('node', node)]: + binary = store / name / 'bin' / ('python3' if name == 'python' else name) + binary.parent.mkdir(parents=True) + binary.symlink_to(executable) + Facts(store / 'facts.json').record(name, 'fixture', name, {}, store) +selected = install_state_dir(root) / 'environments/fixture/venv' +selected.mkdir(parents=True) +(selected / 'pyvenv.cfg').write_text('home = fixture') +site = site_packages(selected) +site.parent.mkdir(parents=True) +site.symlink_to(deps, target_is_directory=True) +Facts(runtime_facts_path(root)).record_state('venv', Venv(root).expected_stamp([]), [], environment=selected) +ensure_install_launchers(root, root / '.hermes/bin') +''' + subprocess.run([sys.executable, "-I", "-B", "-c", setup, str(root), str(store), node, + sysconfig.get_path("purelib")], env=env, cwd=tmp_path, check=True, timeout=60) + build = root / "scripts/build" + build.mkdir(parents=True) + for name in ("freshness.mjs", "frontend-common.mjs"): + shutil.copy2(repo / "scripts/build" / name, build / name) + record = '''import { mkdirSync, writeFileSync } from 'node:fs'; +import { buildInputs, recordProduct } from './scripts/build/freshness.mjs'; +const source = process.cwd(); +for (const [product, out] of [['tui', 'ui-tui/dist'], ['web', 'hermes_cli/web_dist']]) { + mkdirSync(out, { recursive: true }); writeFileSync(out + '/index.html', 'fixture product'); + recordProduct({source, product, out, inputs: buildInputs(source, product)}); +} +''' + subprocess.run([node, "--input-type=module", "-e", record], cwd=root, env=env, check=True, timeout=30) + command = [sys.executable, "-B", str(ASSETS / "source_driver.py"), "--root", str(root), + "--launcher", str(root / ".hermes/bin/hermes"), "--desktop", "absent"] + # The passive query must not write even import caches: -I ignores the + # environment's bytecode switch, so the published query enforces it. + def snapshot(): + return {p: (hashlib.sha256(p.read_bytes()).hexdigest(), p.stat().st_mtime_ns) for tree in (root, home, store) + for p in tree.rglob("*") if p.is_file()} + before = snapshot() + result = subprocess.run(command, env=env, cwd=tmp_path, capture_output=True, text=True, timeout=60) + assert result.returncode == 0, result.stdout + result.stderr + assert snapshot() == before + (root / "uv.lock").write_text("changed graph without preparation", encoding="utf-8") + before = snapshot() + result = subprocess.run(command, env=env, cwd=tmp_path, capture_output=True, text=True, timeout=60) + assert result.returncode != 0 + assert "dependency generation is not current" in result.stderr + assert snapshot() == before \ No newline at end of file diff --git a/tests/test_audit_old_updater_imports.py b/tests/test_audit_old_updater_imports.py index 257baef9a6..c2aa3ff0ad 100644 --- a/tests/test_audit_old_updater_imports.py +++ b/tests/test_audit_old_updater_imports.py @@ -155,13 +155,13 @@ def test_shallow_clone_refuses_freeze_without_overwriting_and_tree_still_works(a output = clone / "frozen.json" output.write_text("do not truncate this", encoding="utf-8") with pytest.raises(SystemExit) as failure: - audit.main(["--freeze", str(output)]) + audit.main(["--ref", "origin/main", "--freeze", str(output)]) assert failure.value.code != 0 assert "--unshallow" in capsys.readouterr().err assert output.read_text() == "do not truncate this" -def test_union_keeps_old_and_uncommitted_loads_and_bare_wins(audit, tmp_path): +def test_freeze_requires_cutoff_and_keeps_only_shipped_loads(audit, tmp_path, capsys): root = audit.REPO_ROOT put(root, "hermes_cli/update_cmd.py", """def cmd_update(): from hermes_constants import old_only, bare_then_guarded @@ -172,22 +172,34 @@ def test_union_keeps_old_and_uncommitted_loads_and_bare_wins(audit, tmp_path): """) commit(root, "not a tag, still a shipped updater") put(root, "hermes_cli/update_cmd.py", """def cmd_update(): - from hermes_constants import new_only, guarded_then_bare + from hermes_constants import guarded_then_bare try: from hermes_constants import bare_then_guarded, always_guarded except ImportError: pass """) - output = tmp_path / "union.json" - assert audit.main(["--freeze", str(output)]) == 0 + cutoff = commit(root, "last updater before the PM migration") + put(root, "hermes_cli/update_cmd.py", "def cmd_update():\n from hermes_constants import after_cutoff\n") + commit(root, "origin/main has advanced beyond the contract cutoff") + put(root, "hermes_cli/update_cmd.py", "def cmd_update():\n from hermes_constants import uncommitted\n") + output = tmp_path / "history.json" + output.write_text("do not overwrite without a cutoff", encoding="utf-8") + with pytest.raises(SystemExit) as failure: + audit.main(["--freeze", str(output)]) + assert failure.value.code != 0 + assert "--ref" in capsys.readouterr().err + assert output.read_text() == "do not overwrite without a cutoff" + + assert audit.main(["--ref", cutoff, "--freeze", str(output)]) == 0 frozen = json.loads(output.read_text()) assert set(frozen["bare"]) == { - "hermes_constants::old_only", "hermes_constants::new_only", + "hermes_constants::old_only", "hermes_constants::bare_then_guarded", "hermes_constants::guarded_then_bare", } assert frozen["guarded_only"] == ["hermes_constants::always_guarded"] - assert frozen["stats"]["mode"] == "union" - assert frozen["stats"]["history"]["complete_history"] is True + assert frozen["stats"]["mode"] == "history" + assert frozen["stats"]["history_ref"] == cutoff + assert frozen["stats"]["complete_history"] is True @pytest.mark.parametrize("malformed", [b"def cmd_update(:\n", b"def cmd_update():\n # \xff\n pass\n"]) @@ -329,15 +341,23 @@ def cmd_update(): } -def test_reviewed_dynamic_loads_survive_union_regeneration(audit, monkeypatch): +def test_reviewed_dynamic_loads_survive_history_freeze_within_cutoff(audit, monkeypatch): root = audit.REPO_ROOT put(root, "hermes_cli/update_cmd.py", "def cmd_update():\n from hermes_constants import anchor\n") witness = commit(root, "module-object call manually reviewed") + put(root, "hermes_cli/update_cmd.py", "def cmd_update():\n from hermes_constants import later\n") + later = commit(root, "module-object call after the contract cutoff") monkeypatch.setattr(audit, "REVIEWED_DYNAMIC_LOADS", ( ("hermes_constants", "dynamic", witness[:12], "hermes_cli/update_cmd.py:cmd_update"), - ("hermes_constants", "unshipped", "not-a-witness", "other.py:cmd_update"), + ("hermes_constants", "after_cutoff", later[:12], "hermes_cli/update_cmd.py:cmd_update"), )) - surface = audit.audit_union() - assert ("hermes_constants", "dynamic") in surface.required + surface = audit.audit_history(witness) + assert surface.required[("hermes_constants", "dynamic")] == {witness[:12]} + assert surface.kinds[("hermes_constants", "dynamic")] == {"reviewed-module-call"} + assert surface.sites[("hermes_constants", "dynamic")] == {"hermes_cli/update_cmd.py:cmd_update"} assert ("hermes_constants", "dynamic") not in surface.guarded_only - assert ("hermes_constants", "unshipped") not in surface.required + assert ("hermes_constants", "after_cutoff") not in surface.required + output = root / "history.json" + assert audit.main(["--ref", witness, "--history", "--freeze", str(output)]) == 0 + frozen = json.loads(output.read_text()) + assert set(frozen["bare"]) == {"hermes_constants::anchor", "hermes_constants::dynamic"} diff --git a/tests/test_bitwarden_secrets.py b/tests/test_bitwarden_secrets.py index ef197e79d4..43e8391533 100644 --- a/tests/test_bitwarden_secrets.py +++ b/tests/test_bitwarden_secrets.py @@ -1,22 +1,17 @@ """Hermetic tests for the Bitwarden Secrets Manager integration. -We never hit GitHub or Bitwarden in tests — subprocess + urllib are -mocked so the suite stays fast and offline-safe. The "live" pull and -binary download are exercised manually by `hermes secrets bitwarden -setup` outside of pytest. +Secret/cache behavior stays offline. PM acquisition and executable invocation +are exercised by tests/pm/test_security_consumers.py. """ from __future__ import annotations -import hashlib -import io import json import os import stat import subprocess import sys import time -import zipfile from pathlib import Path from unittest import mock @@ -51,121 +46,6 @@ def hermes_home(tmp_path, monkeypatch): return home -# --------------------------------------------------------------------------- -# _platform_asset_name -# --------------------------------------------------------------------------- - - -@pytest.mark.parametrize( - "system,machine,libc_text,expected", - [ - ("Darwin", "x86_64", "", - f"bws-macos-universal-{bw._BWS_VERSION}.zip"), - ("Darwin", "arm64", "", - f"bws-macos-universal-{bw._BWS_VERSION}.zip"), - ("Linux", "x86_64", "glibc", - f"bws-x86_64-unknown-linux-gnu-{bw._BWS_VERSION}.zip"), - ("Linux", "x86_64", "musl libc", - f"bws-x86_64-unknown-linux-musl-{bw._BWS_VERSION}.zip"), - ("Linux", "aarch64", "", - f"bws-aarch64-unknown-linux-gnu-{bw._BWS_VERSION}.zip"), - ("Windows", "AMD64", "", - f"bws-x86_64-pc-windows-msvc-{bw._BWS_VERSION}.zip"), - ("Windows", "ARM64", "", - f"bws-aarch64-pc-windows-msvc-{bw._BWS_VERSION}.zip"), - ], -) -def test_platform_asset_name(system, machine, libc_text, expected): - with mock.patch.object(bw.platform, "system", return_value=system), \ - mock.patch.object(bw.platform, "machine", return_value=machine), \ - mock.patch.object( - bw.subprocess, - "run", - return_value=mock.Mock(stdout=libc_text, stderr=libc_text), - ): - assert bw._platform_asset_name() == expected - - -# --------------------------------------------------------------------------- -# install_bws — fully mocked HTTP -# --------------------------------------------------------------------------- - - -def _make_fake_zip(binary_bytes: bytes) -> bytes: - buf = io.BytesIO() - with zipfile.ZipFile(buf, "w") as zf: - zf.writestr("bws", binary_bytes) - return buf.getvalue() - - -# --------------------------------------------------------------------------- -# _safe_extract_member — zip-slip containment -# --------------------------------------------------------------------------- - - - - -@pytest.mark.parametrize( - "evil_name", - [ - "../escape", - "../../escape", - "sub/../../escape", - ], -) -def test_safe_extract_member_rejects_traversal(tmp_path, evil_name): - buf = io.BytesIO() - with zipfile.ZipFile(buf, "w") as zf: - zf.writestr(evil_name, b"pwned") - buf.seek(0) - - dest = tmp_path / "extract" - dest.mkdir() - outside = tmp_path / "escape" - - with zipfile.ZipFile(buf) as zf: - with pytest.raises(RuntimeError, match="unsafe archive member"): - bw._safe_extract_member(zf, evil_name, dest) - - # The traversal target must not have been written. - assert not outside.exists() - - - - - - -@pytest.mark.platforms("linux") -def test_install_bws_happy_path(hermes_home, monkeypatch): - fake_binary = b"#!/bin/sh\necho 'bws fake 2.0.0'\n" - zip_bytes = _make_fake_zip(fake_binary) - asset_name = bw._platform_asset_name() - checksum_text = ( - f"{hashlib.sha256(zip_bytes).hexdigest()} {asset_name}\n" - "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff other-file\n" - ) - - def fake_download(url, dest): - if url.endswith(".zip"): - Path(dest).write_bytes(zip_bytes) - elif url.endswith(".txt"): - Path(dest).write_text(checksum_text) - else: - raise AssertionError(f"unexpected download url: {url}") - - monkeypatch.setattr(bw, "_http_download", fake_download) - - path = bw.install_bws() - assert path.exists() - assert path.read_bytes() == fake_binary - # Executable bit set - assert path.stat().st_mode & stat.S_IXUSR - - - - - - # --------------------------------------------------------------------------- # fetch_bitwarden_secrets # --------------------------------------------------------------------------- @@ -199,7 +79,7 @@ def test_fetch_server_url_sets_env(monkeypatch, tmp_path): captured_env.update(kwargs["env"]) return mock.Mock(returncode=0, stdout=payload, stderr="") - monkeypatch.setattr(bw.subprocess, "run", fake_run) + monkeypatch.setattr(subprocess, "run", fake_run) bw.fetch_bitwarden_secrets( access_token="0.t", @@ -309,7 +189,7 @@ def test_disk_cache_key_mismatch_triggers_refetch(monkeypatch, tmp_path): def fake_run(*a, **kw): call_count["n"] += 1 return mock.Mock(returncode=0, stdout=payload, stderr="") - monkeypatch.setattr(bw.subprocess, "run", fake_run) + monkeypatch.setattr(subprocess, "run", fake_run) bw._reset_cache_for_tests(home) # Write a cache entry for a DIFFERENT token/project pair @@ -345,7 +225,7 @@ def test_encrypted_cache_writes_without_plaintext(monkeypatch, tmp_path): payload = _fake_bws_payload([{"key": "K1", "value": "secret-value"}]) monkeypatch.setattr( - bw.subprocess, + subprocess, "run", lambda *a, **kw: mock.Mock(returncode=0, stdout=payload, stderr=""), ) @@ -408,7 +288,7 @@ def test_encrypted_cache_falls_back_on_network_error(monkeypatch, tmp_path): stderr="Error: network is unreachable", ) - monkeypatch.setattr(bw.subprocess, "run", fake_run) + monkeypatch.setattr(subprocess, "run", fake_run) bw._reset_cache_for_tests(home) first, _ = bw.fetch_bitwarden_secrets( @@ -476,7 +356,7 @@ def test_stale_disk_cache_returned_when_bws_fails(monkeypatch, tmp_path): def fail_run(*a, **kw): return mock.Mock(returncode=1, stdout="", stderr="Error: dns resolution failed") - monkeypatch.setattr(bw.subprocess, "run", fail_run) + monkeypatch.setattr(subprocess, "run", fail_run) secrets, warnings = bw.fetch_bitwarden_secrets( access_token="0.t", project_id="proj-1", binary=fake_binary, @@ -509,7 +389,7 @@ def test_stale_fallback_skipped_on_auth_failure(monkeypatch, tmp_path): _seed_stale_disk_cache(home, secrets={"K1": "v1"}, age_seconds=3600) monkeypatch.setattr( - bw.subprocess, "run", + subprocess, "run", lambda *a, **kw: mock.Mock(returncode=1, stdout="", stderr="Error: unauthorized (401)"), ) diff --git a/tests/test_desktop_update_windows_timestamp.py b/tests/test_desktop_update_windows_timestamp.py index 5efd0cc04b..a8cc5685d7 100644 --- a/tests/test_desktop_update_windows_timestamp.py +++ b/tests/test_desktop_update_windows_timestamp.py @@ -1,34 +1,38 @@ -"""Regression tests for Windows desktop-update Unix timestamps. - -PowerShell's ``Get-Date -UFormat %s`` is locale-sensitive. Under an ``es-ES`` -culture it can produce a comma decimal separator, and parsing that string with -``InvariantCulture`` turns a ten-digit Unix timestamp plus fractional seconds -into a value too large for ``System.Int32``. The detached Windows updater must -use a locale-independent Unix timestamp API for both marker and result files. - -The updater script is not executable on the Linux CI lane, so these tests lock -the source-level contract and reject the exact broken conversion. -""" - -from __future__ import annotations +"""The Windows handoff writes numeric Unix seconds under comma-decimal locales.""" +import json +import os from pathlib import Path +import shutil +import subprocess +import time + +import pytest -REPO_ROOT = Path(__file__).resolve().parent.parent -WINDOWS_UPDATE_PS1 = REPO_ROOT / "scripts" / "desktop-update" / "windows.ps1" - - -def test_windows_update_uses_locale_independent_unix_seconds() -> None: - source = WINDOWS_UPDATE_PS1.read_text(encoding="utf-8") - safe_expression = "[DateTimeOffset]::UtcNow.ToUnixTimeSeconds()" - unsafe_expression = "[int][double]::Parse((Get-Date -UFormat %s)" - - assert source.count(safe_expression) == 2, ( - "windows.ps1 must use DateTimeOffset Unix seconds for both the " - "update marker and the finished result timestamp" - ) - assert unsafe_expression not in source, ( - "windows.ps1 must not parse locale-sensitive Get-Date -UFormat %s " - "output through System.Int32" +@pytest.mark.platforms("windows") +def test_windows_update_writes_locale_independent_marker_and_result(tmp_path, monkeypatch): + shell = shutil.which("powershell.exe") + assert shell, "native Windows acceptance requires PowerShell" + script = Path(__file__).resolve().parents[1] / "scripts/desktop-update/windows.ps1" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.delenv("HERMES_UPDATE_STARTED_AT", raising=False) + # -SelfTestMarker runs the actual claim and finally/result publication, + # but never updates a checkout, waits on Desktop, or launches processes. + command = ( + "[Threading.Thread]::CurrentThread.CurrentCulture = [Globalization.CultureInfo]::GetCultureInfo('es-ES'); " + "& $env:HERMES_TIMESTAMP_TEST_SCRIPT -InstallRoot $env:HERMES_HOME -NoUi -NoMarkerCleanup -SelfTestMarker" ) + started = int(time.time()) + result = subprocess.run([shell, "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", command], + env={**os.environ, "HERMES_TIMESTAMP_TEST_SCRIPT": str(script)}, + capture_output=True, text=True, timeout=60) + finished = int(time.time()) + assert result.returncode == 0, result.stdout + result.stderr + marker = (tmp_path / ".hermes-update-in-progress").read_text(encoding="utf-8-sig").splitlines() + receipt = json.loads((tmp_path / ".hermes-update-result.json").read_text(encoding="utf-8-sig")) + assert len(marker) == 2 and int(marker[0]) > 0 + assert started <= int(marker[1]) <= finished + assert type(receipt["finished_at"]) is int + assert started <= receipt["finished_at"] <= finished + assert receipt["exit_code"] == 0 \ No newline at end of file diff --git a/tests/test_fresh_source_install.py b/tests/test_fresh_source_install.py new file mode 100644 index 0000000000..8cd8b3827e --- /dev/null +++ b/tests/test_fresh_source_install.py @@ -0,0 +1,146 @@ +"""Whole current installer, real PM worker and local application dependency. + +The bootstrap toolchain is prepared from this host's real Python/uv; this is +not a zero-Python download test. PM acquires real tool archives on loopback, +builds its unchanged runtime recipe and the tiny app, then publishes launchers. +""" +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import sysconfig +import tarfile +import tomllib + +import pytest + +from pm.store import current_target +from tests.pm._fixtures import _wheel, served as served + +ROOT = Path(__file__).resolve().parents[1] + + +@pytest.mark.platforms("linux") +@pytest.mark.parametrize("fault", [None, "missing-wheel", "bad-hash"]) +def test_current_installer_publishes_real_dependencies_and_warm_path(tmp_path, served, fault): + uv = shutil.which("uv") + assert uv, "fresh-install acceptance requires real uv" + python = Path(sys._base_executable).resolve() + version = ".".join(map(str, sys.version_info[:3])) + minor = ".".join(map(str, sys.version_info[:2])) + home = tmp_path / "home" + home.mkdir() + managed = tmp_path / "bootstrap-python" + arch = {"x64": "x86_64", "arm64": "aarch64"}[current_target().split("-")[1]] + bootstrap = managed / f"cpython-{version}-linux-{arch}-gnu" + (bootstrap / "bin").mkdir(parents=True) + shutil.copytree(sysconfig.get_path("stdlib"), bootstrap / f"lib/python{minor}", + ignore=shutil.ignore_patterns("site-packages", "__pycache__")) + for path in bootstrap.rglob("*"): + path.chmod(0o755 if path.is_dir() else 0o644) + shutil.copy2(python, bootstrap / f"bin/python{minor}") + (bootstrap / "bin/python3").symlink_to(f"python{minor}") + env = {"PATH": os.environ["PATH"], "HOME": str(home), "LANG": "C.UTF-8", + "HERMES_HOME": str(home / ".hermes"), "UV_PYTHON_INSTALL_DIR": str(managed), + "UV_PYTHON_DOWNLOADS": "never", "UV_CACHE_DIR": str(tmp_path / "cache")} + for key in ("SSL_CERT_FILE", "SSL_CERT_DIR", "NIX_SSL_CERT_FILE"): + if key in os.environ: + env[key] = os.environ[key] + + def run(argv, *, cwd=tmp_path, expected=0): + result = subprocess.run(argv, cwd=cwd, env=env, capture_output=True, text=True, timeout=180) + assert result.returncode == expected, result.stdout + result.stderr + return result + + # Make the bootstrap layout complete using real uv, including its aliases. + run([uv, "python", "install", "--no-bin", minor]) + source = tmp_path / "fixture source" + source.mkdir() + for name in ("pm", "hermes_cli"): + shutil.copytree(ROOT / name, source / name, ignore=shutil.ignore_patterns("__pycache__")) + for name in ("utils.py", "hermes_constants.py", "hermes_yaml.py", "hermes_bootstrap.py", "setup-hermes.sh"): + shutil.copy2(ROOT / name, source / name) + wheels = source / "wheels" + wheels.mkdir() + _wheel(wheels, "installer_probe", "1.0") + recipe = tomllib.loads((source / "pm/pyproject.toml").read_text()) + yaml_dep = next(d for d in recipe["project"]["dependencies"] if d.startswith("ruamel.yaml")) + (source / "pyproject.toml").write_text( + '[project]\nname="installer-fixture"\nversion="1"\nrequires-python=">=3.14"\n' + f'dependencies=["installer-probe==1.0",{json.dumps(yaml_dep)}]\n' + '[project.optional-dependencies]\nall=[]\n[tool.uv]\npackage=false\n' + '[tool.uv.sources]\ninstaller-probe={path="wheels/installer_probe-1.0-py3-none-any.whl"}\n', + encoding="utf-8") + run([uv, "lock", "--python", str(python)], cwd=source) + # Only the application is a fixture; the shell, PM, bootstrap and writer run unchanged. + (source / "hermes_cli/main.py").write_text( + "import installer_probe, json, sys\n" + "def main():\n print(json.dumps({'module':installer_probe.__file__, 'argv':sys.argv[1:]}))\n" + "if __name__ == '__main__': main()\n", encoding="utf-8") + docroot, url = served + (source / "pm/artifact-mirror.json").write_text( + json.dumps({"origin": url, "prefix": "mirror/"}, indent=2), encoding="utf-8") + pins = {} + for name, files in { + "python": [(bootstrap, "python")], + "uv": [(Path(uv).resolve(), "uv-dist/uv"), (Path(uv).resolve().with_name("uvx"), "uv-dist/uvx")], + }.items(): + archive = docroot / f"{name}.tar.gz" + with tarfile.open(archive, "w:gz", compresslevel=1, dereference=True) as tar: + for path, destination in files: + tar.add(path, arcname=destination) + pins[name] = {"version": version if name == "python" else run([uv, "--version"]).stdout.split()[1], + "artifacts": {current_target(): {"url": f"{url}/{archive.name}", + "sha256": hashlib.sha256(archive.read_bytes()).hexdigest()}}} + if fault == "bad-hash": + pins["python"]["artifacts"][current_target()]["sha256"] = "0" * 64 + if fault == "missing-wheel": + next(wheels.glob("*.whl")).unlink() + (source / "pm/lock.json").write_text(json.dumps({"schema": 1, "packages": pins}, indent=2), encoding="utf-8") + run(["git", "init", "-b", "fixture"], cwd=source) + run(["git", "add", "."], cwd=source) + run(["git", "-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", + "commit", "-m", "fixture"], cwd=source) + commit = run(["git", "rev-parse", "HEAD"], cwd=source).stdout.strip() + install = tmp_path / "installed source" + env["HERMES_REPO_URL"] = str(source) + command = ["bash", str(ROOT / "scripts/install.sh"), "--dir", str(install), + "--branch", "fixture", "--commit", commit, "--non-interactive", "--json"] + result = run(command, expected=1 if fault else 0) + if fault: + assert not (install / ".hermes-bootstrap-complete").exists() + assert not (home / ".local/bin/hermes").exists() + for facts in (home / ".hermes/installs").glob("*/facts.json"): + assert "venv" not in json.loads(facts.read_text())["packages"] + assert '"stage":"python-deps"' in result.stdout + return + assert '"stage":"python-deps"' in result.stdout + assert json.loads((install / ".hermes-bootstrap-complete").read_text())["pinnedCommit"] == commit + facts = next((home / ".hermes/installs").glob("*/facts.json")) + selection = json.loads(facts.read_text())["packages"]["venv"] + launcher = home / ".local/bin/hermes" + child = json.loads(run([str(launcher), "from elsewhere"]).stdout) + assert child["argv"] == ["from elsewhere"] + assert Path(child["module"]).is_relative_to(Path(selection["environment"])) + assert not (install / "venv").exists() + # The developer setup path publishes through the same writer after real PM. + launcher.unlink() + run(["bash", str(install / "setup-hermes.sh")]) + assert json.loads(run([str(launcher), "from setup"]).stdout)["argv"] == ["from setup"] + # Warm path publication must work with all acquisition inputs unavailable. + shutil.rmtree(docroot) + shutil.rmtree(install / "wheels") + env["UV_OFFLINE"] = "1" + # A completed bootstrap can be read-only. Finding it must not reinstall it. + marker = bootstrap / f"lib/python{minor}/EXTERNALLY-MANAGED" + marker.chmod(0o444) + before = facts.read_bytes() + try: + run([*command, "--stage", "path"]) + finally: + marker.chmod(0o644) + assert facts.read_bytes() == before + assert json.loads(run([str(launcher), "offline"]).stdout)["argv"] == ["offline"] \ No newline at end of file diff --git a/tests/test_install_sh_python_pin_indent.py b/tests/test_install_sh_python_pin_indent.py index 4dd24c1b5c..5dafb729ce 100644 --- a/tests/test_install_sh_python_pin_indent.py +++ b/tests/test_install_sh_python_pin_indent.py @@ -21,13 +21,13 @@ pytestmark = pytest.mark.platforms("posix") @pytest.mark.parametrize("indent,blank_lines", [(2, False), (4, False), (0, False), ("\t", False), (4, True)], ids=["two-spaces", "four-spaces", "no-indent", "tabs", "blank-lines"]) -@pytest.mark.parametrize("entrypoint", ["bootstrap_python", "stage_venv"]) -def test_bootstrap_python_reads_pin_independent_of_indentation(tmp_path, indent, blank_lines, entrypoint): +def test_bootstrap_python_reads_pin_independent_of_indentation(tmp_path, indent, blank_lines): bash = shutil.which("bash") assert bash, "the shell bootstrap contract requires Bash" core = tmp_path / "checkout" (core / "pm").mkdir(parents=True) - py_version = f"{sys.version_info.major}.{sys.version_info.minor}" + # Deliberately differs from the shell's fallback and this host's interpreter. + py_version = "3.77" interpreter = str(Path(sys._base_executable).resolve()) calls = tmp_path / "uv-calls" uv = tmp_path / "uv" @@ -56,15 +56,12 @@ def test_bootstrap_python_reads_pin_independent_of_indentation(tmp_path, indent, f'source "{(ROOT / "scripts/install.sh").as_posix()}" --manifest\n' f'ensure_uv() {{ UV_CMD="{uv.as_posix()}"; }}\n' f'INSTALL_DIR="{core.as_posix()}"\n' - f"{entrypoint}\n" + "bootstrap_python\n" ) env = dict(os.environ, HOME=str(tmp_path), HERMES_HOME=str(tmp_path / ".hermes")) result = subprocess.run(["bash", "-c", script], env=env, capture_output=True, text=True, timeout=30) assert result.returncode == 0, result.stdout + result.stderr # A layout-sensitive reader resolves no version, falls back to "3.14", and - # the fake uv exits 91 on the unexpected argument — so the recorded calls - # pinning THIS interpreter's version are the contract. - assert calls.read_text().splitlines() == [ - f"python install --no-bin {py_version}", - f"python find --managed-python {py_version}", - ] + # the argv witness exits 91 on an unexpected pin. Cold/warm acquisition is + # exercised with real tools in test_fresh_source_install, not duplicated here. + assert calls.read_text().splitlines()[-1] == f"python find --managed-python {py_version}" diff --git a/tests/test_iron_proxy.py b/tests/test_iron_proxy.py index 1d0c937989..eb6ce58f4f 100644 --- a/tests/test_iron_proxy.py +++ b/tests/test_iron_proxy.py @@ -1,11 +1,11 @@ """Hermetic tests for the iron-proxy egress integration. Covers the pure-function surface (token mint, mapping discovery, config build, -config + mappings I/O), the binary install path (HTTP downloads + tar -extraction + checksum verification fully mocked), the subprocess lifecycle +config + mappings I/O), the subprocess lifecycle (spawn / PID / pid_alive / stop, with subprocess.Popen mocked), and the docker backend's egress arg builder. +PM acquisition has real loopback/worker coverage in test_security_consumers. Live network and the real ``iron-proxy`` binary are NEVER touched. See ``tests/test_iron_proxy_e2e.py`` (gated behind a marker) for the real-binary smoke test. @@ -13,10 +13,8 @@ smoke test. from __future__ import annotations -import io import os import sys -import tarfile from pathlib import Path from unittest.mock import MagicMock, patch @@ -212,16 +210,6 @@ def test_load_mappings_handles_corrupt_json(hermes_home): -def _make_fake_tar(binary_name: str, payload: bytes = b"#!/bin/sh\necho ok\n") -> bytes: - """Build a tar.gz with one file at the root, named ``binary_name``.""" - - buf = io.BytesIO() - with tarfile.open(fileobj=buf, mode="w:gz") as tf: - info = tarfile.TarInfo(name=binary_name) - info.size = len(payload) - info.mode = 0o755 - tf.addfile(info, io.BytesIO(payload)) - return buf.getvalue() @@ -248,19 +236,6 @@ def test_verify_checksums_signature_skips_without_gpg(hermes_home, monkeypatch, -def test_pick_tar_member_rejects_path_traversal(): - """A malicious tar that escapes via '..' must be refused.""" - - buf = io.BytesIO() - with tarfile.open(fileobj=buf, mode="w:gz") as tf: - info = tarfile.TarInfo(name="../iron-proxy") - info.size = 1 - info.mode = 0o755 - tf.addfile(info, io.BytesIO(b"x")) - buf.seek(0) - with tarfile.open(fileobj=buf, mode="r:gz") as tf: - with pytest.raises(RuntimeError, match="Could not find iron-proxy"): - ip._pick_tar_member(tf, "iron-proxy") # --------------------------------------------------------------------------- diff --git a/tests/test_node_resolution.py b/tests/test_node_resolution.py index 512d123766..9864f9aa71 100644 --- a/tests/test_node_resolution.py +++ b/tests/test_node_resolution.py @@ -258,6 +258,10 @@ def test_npm_consumers_execute_with_pm_node(npm_probe, npm_consumers, consumer): result = json.loads((output_dir / "called.json").read_text()) assert Path(result["argv"][1]) == binary assert shutil.which("node", path=result["env"]["PATH"]) == str(node) + if consumer == "lsp": + assert result["argv"][2:] == [ + "install", "--prefix", str(output_dir), "--silent", "--no-fund", "--no-audit", "test-pkg", + ] @pytest.mark.platforms("posix") diff --git a/tests/test_old_updater_additional_shims.py b/tests/test_old_updater_additional_shims.py index 112566bec0..f7be4abe65 100644 --- a/tests/test_old_updater_additional_shims.py +++ b/tests/test_old_updater_additional_shims.py @@ -1,36 +1,18 @@ -"""Retired import names suppress old updater work without claiming success.""" +"""Retired import names hand off old updater work without claiming success.""" -import importlib +from contextvars import ContextVar +from copy import deepcopy +from dataclasses import dataclass import os -from pathlib import Path -import socket -import subprocess -import urllib.request +import sys +from types import SimpleNamespace import pytest - -@pytest.fixture -def no_external_work(monkeypatch): - import pm - - def forbidden(*args, **kwargs): - pytest.fail("old-updater shim attempted external work") - - for name in ("sync_venv", "ensure_environment", "build_environment", "ensure_import"): - monkeypatch.setattr(pm, name, forbidden) - monkeypatch.setattr(subprocess, "Popen", forbidden) - monkeypatch.setattr(os, "system", forbidden) - monkeypatch.setattr(os, "kill", forbidden) - monkeypatch.setattr(socket, "create_connection", forbidden) - monkeypatch.setattr(urllib.request, "urlopen", forbidden) - monkeypatch.setattr(urllib.request, "urlretrieve", forbidden) - before_env = {k: v for k, v in os.environ.items() if k != "PYTEST_CURRENT_TEST"} - home = Path(os.environ["HERMES_HOME"]) - before_files = {p: p.read_bytes() for p in home.rglob("*") if p.is_file()} - yield forbidden - assert {k: v for k, v in os.environ.items() if k != "PYTEST_CURRENT_TEST"} == before_env - assert {p: p.read_bytes() for p in home.rglob("*") if p.is_file()} == before_files +from tests.compat.old_updater_support import ( + fresh_child as fresh_child, + no_external_work as no_external_work, +) @pytest.mark.parametrize("command,profile", [ @@ -55,43 +37,36 @@ def test_retired_code_identity_is_unknown(refresh, no_external_work, monkeypatch assert get_code_identity(refresh)["sha"] is None -def test_retired_constants_reload_stops_old_gateway_recovery(no_external_work, monkeypatch, capsys): +def test_retired_constants_reload_handoffs_old_gateway_recovery(fresh_child, monkeypatch): import hermes_constants # Shipped get_python_path uses this fallback when its constants module is stale. monkeypatch.delattr(hermes_constants, "venv_python_path") before = dict(vars(hermes_constants)) - monkeypatch.setattr(importlib, "reload", no_external_work) - with pytest.raises(SystemExit) as exc: + with fresh_child.exits(): try: from hermes_constants import venv_python_path except ImportError: from hermes_cli.managed_uv import _reload_hermes_constants venv_python_path = _reload_hermes_constants().venv_python_path pytest.fail(f"old recovery continued with {venv_python_path}") - assert exc.value.code == 0 - assert "run `hermes` again" in capsys.readouterr().err.lower() assert vars(hermes_constants) == before @pytest.mark.parametrize("kwargs", [{}, {"timeout": 120, "capture_output": False}]) -def test_retired_pip_install_stops_before_reporting_success(kwargs, no_external_work, capsys): +def test_retired_pip_install_handoffs_before_reporting_success(kwargs, fresh_child): from hermes_cli.tools_config import _pip_install - with pytest.raises(SystemExit) as exc: + with fresh_child.exits(): result = _pip_install(["--quiet", "honcho-ai"], **kwargs) pytest.fail(f"retired installer returned a result: {result}") - assert exc.value.code == 0 - assert "run `hermes` again" in capsys.readouterr().err.lower() -def test_retired_root_stops_before_inventing_portable_git_path(no_external_work, capsys): +def test_retired_root_handoffs_before_inventing_portable_git_path(fresh_child): from hermes_cli.update_cmd import get_default_hermes_root - with pytest.raises(SystemExit) as exc: + with fresh_child.exits(): get_default_hermes_root() / "git" / "mingw64" / "libexec" / "git-core" / "git.exe" - assert exc.value.code == 0 - assert "run `hermes` again" in capsys.readouterr().err.lower() @pytest.mark.parametrize("prompt", [True, False]) @@ -120,23 +95,69 @@ def test_live_dingtalk_dependencies_use_pm_not_retired_installer(monkeypatch): @pytest.mark.parametrize("specs", [[], ["honcho-ai"]]) -def test_retired_install_specs_stops_before_reporting_success(specs, no_external_work, capsys): +def test_retired_install_specs_handoffs_before_reporting_success(specs, fresh_child): from tools.lazy_deps import install_specs - with pytest.raises(SystemExit) as exc: + before = list(specs) + with fresh_child.exits(): result = install_specs(specs, timeout=120) pytest.fail(f"retired installer returned a result: {result}") - assert exc.value.code == 0 - assert "run `hermes` again" in capsys.readouterr().err.lower() + assert specs == before -def test_retired_subprocess_run_stops_powershell_installer(no_external_work, capsys): +@pytest.mark.parametrize("handled", [False, True], ids=["unacknowledged", "child-completed"]) +def test_historical_payload_survives_bridge_and_cleanup_requires_ack(handled, fresh_child, monkeypatch): + from hermes_cli import update_receipt + from hermes_cli.managed_uv import ensure_uv + + @dataclass + class HistoricalPlan: + profiles: list[str] + snapshots: dict[str, str] + + # These names belong to real old updater frames, not parameters added to the + # shim. Preserve opaque data and arguments, including spaces and Unicode. + had_desktop_app_before_update = True + pre_update_snapshot_id = "snapshot before pull" + pre_update_version = "old-version" + gateway_mode = True + assume_yes = False # Old frame state takes precedence over argv's --yes. + _pre_update_plan = HistoricalPlan(["ops team", "日本"], {"ops team": "snapshot before pull"}) + _windows_gateway_resume = {"resume_needed": True, "profiles": {"ops team": "old-pid"}} + receipt = SimpleNamespace(data={"update_id": "original-id", "steps": [{"name": "pull", "ok": True}]}) + slot = ContextVar("test_historical_receipt", default=receipt) + monkeypatch.setattr(update_receipt, "_current", slot) + argv = ["hermes", "--profile", "ops team", "update", "--gateway", "--yes"] + monkeypatch.setattr(sys, "argv", argv) + expected = deepcopy({ + "desktop": had_desktop_app_before_update, + "pre_update_snapshot_id": pre_update_snapshot_id, + "pre_update_version": pre_update_version, + "gateway_mode": gateway_mode, + "assume_yes": assume_yes, + "windows_resume": _windows_gateway_resume, + "plan": {"profiles": _pre_update_plan.profiles, "snapshots": _pre_update_plan.snapshots}, + "receipt": receipt.data, + "argv": argv, + }) + fresh_child.result = {"resume_handled": handled, "receipt_handled": handled} + with fresh_child.exits(): + ensure_uv() + request = fresh_child.requests[0] + assert {key: request[key] for key in expected} == expected + assert _pre_update_plan.profiles == expected["plan"]["profiles"] + assert _pre_update_plan.snapshots == expected["plan"]["snapshots"] + assert receipt.data == expected["receipt"] + assert sys.argv == expected["argv"] + assert slot.get() is (None if handled else receipt) + assert _windows_gateway_resume == {**expected["windows_resume"], "resume_needed": not handled} + + +def test_retired_subprocess_run_handoffs_instead_of_running_powershell(fresh_child): from hermes_cli import _subprocess_compat - with pytest.raises(SystemExit) as exc: + with fresh_child.exits(): _subprocess_compat.run( ["powershell", "-ExecutionPolicy", "Bypass", "-c", "irm https://astral.sh/uv/install.ps1 | iex"], env=dict(os.environ), check=True, capture_output=True, ) - assert exc.value.code == 0 - assert "run `hermes` again" in capsys.readouterr().err.lower() diff --git a/tests/test_old_updater_compat_surface.py b/tests/test_old_updater_compat_surface.py index d4fea54bf6..b2d2edf401 100644 --- a/tests/test_old_updater_compat_surface.py +++ b/tests/test_old_updater_compat_surface.py @@ -3,14 +3,17 @@ `hermes update` replaces the checkout underneath a RUNNING process. The old process then lazy-imports from the new tree: whatever it asks for must still exist, or the user's update dies half-applied. The names it -can ask for were collected from EVERY commit reachable from origin/main, -back to the first cmd_update, UNION the current working tree, and frozen -into tests/compat/old_updater_surface.json. Moving the implementation to -pm does not retire already-running old updaters. Historical entrypoints, -extractions, renamed and deleted helpers remain part of the contract. +can ask for are the contract of updaters shipped BEFORE the PM migration: +EVERY commit reachable from an explicit pre-PM cutoff, back to the first +cmd_update. The checked-in tests/compat/old_updater_surface.json already +contains a history-plus-tree superset; keep enforcing those names, but do +not continually expand it with new PM updater imports. Moving the +implementation to pm does not retire already-running old updaters. +Historical entrypoints, extractions, renamed and deleted helpers remain +part of the contract. Regeneration requires a full clone. These tests deliberately consume the -checked-in history and only re-audit the current tree, so shallow CI can +checked-in names and resolve them against the current tree, so shallow CI can enforce the contract without reconstructing (or silently truncating) it. `managed_uv._reload_hermes_constants` is the scar proving the failure @@ -19,11 +22,14 @@ from 'hermes_constants'`` while the NEW file on disk plainly held it. If this test fails you have two honest options: * restore the name (a stub with the old signature is fine), or -* prove no shipped release still loads it, then REGENERATE the frozen - file: - python scripts/audit-old-updater-imports.py --freeze \ +* prove no updater in the pre-PM history loads it, then review a full + history audit at the same cutoff (the existing JSON records it under + stats.history.history_ref): + python scripts/audit-old-updater-imports.py --ref PRE_PM_COMMIT --freeze \ tests/compat/old_updater_surface.json -Hand-trimming the JSON is how someone's install bricks mid-update. +New updater imports are not grounds for regeneration or for advancing the +cutoff to a later origin/main. Hand-trimming the JSON is how someone's +install bricks mid-update. The test resolves names statically (AST over the files) rather than importing: importing would execute module side effects and — worse — @@ -70,7 +76,7 @@ def _pairs(entries: list[str]) -> list[tuple[str, str | None]]: class TestTheFrozenSurfaceStillResolves: - """Every bare name a shipped updater loads must exist in THIS tree.""" + """Every bare name in the retained frozen superset must exist in THIS tree.""" @pytest.mark.parametrize( "module,symbol", @@ -80,8 +86,8 @@ class TestTheFrozenSurfaceStillResolves: def test_bare_name_exists(self, module: str, symbol: str | None): ok, why = _audit.resolve_in_tree(module, symbol, REPO_ROOT) assert ok, ( - f"{why} — but a shipped `hermes update` imports it AFTER the " - f"checkout swap. Restore the name (a compat stub is fine) or " + f"{why} — but the retained old-updater surface requires it AFTER " + f"the checkout swap. Restore the name (a compat stub is fine) or " f"regenerate the frozen surface on a full clone; see this " f"file's docstring." ) @@ -90,18 +96,19 @@ class TestTheFrozenSurfaceStillResolves: class TestTheFrozenFileIsSane: """Catch a corrupted or hand-trimmed freeze before it lies to us.""" - def test_has_the_load_bearing_names(self): - # Spot-check names that are KNOWN load-bearing today. If any of + def test_has_historical_load_bearing_names(self): + # Spot-check names loaded by pre-PM updaters. If any of # these fall out of the frozen file, the freeze itself went wrong # (shallow clone, wrong branch) — the resolver test above would # pass vacuously. bare = set(_load_surface()["bare"]) for anchor in ( "hermes_constants::with_hermes_node_path", - "pm.ensure::sync_venv", + "hermes_constants::venv_python_path", "hermes_cli.gitlock::clear_stale_git_locks", "hermes_cli.managed_uv::ensure_uv", "hermes_cli.managed_uv::rebuild_venv", + "hermes_cli._subprocess_compat::run", ): assert anchor in bare, ( f"{anchor} missing from the frozen surface — the freeze " @@ -109,14 +116,16 @@ class TestTheFrozenFileIsSane: f"entrypoints); regenerate and eyeball the diff." ) - def test_audit_saw_the_whole_update_flow(self): - stats = _load_surface()["stats"] - assert stats.get("mode") == "union", ( - "frozen surface must include history AND the current tree — " - "regenerate on a full clone with scripts/" - "audit-old-updater-imports.py --freeze (no --history)." + def test_frozen_history_is_complete_including_the_retained_union(self): + frozen = _load_surface() + stats = frozen["stats"] + assert stats.get("mode") in {"history", "union"}, ( + "frozen surface must include the complete pre-PM history; " + "a current-tree-only audit cannot establish that contract." ) - history = stats["history"] + # The existing union remains a safe superset, not a requirement to + # add today's updater imports or regenerate its tree half. + history = stats["history"] if stats["mode"] == "union" else stats assert history.get("complete_history") is True assert history["commits"] > 0 assert history["roots"] and history["entrypoint_paths"] @@ -124,31 +133,12 @@ class TestTheFrozenFileIsSane: "the freeze omitted the original inline cmd_update history" ) assert history["history_ref"] - analyzed = set(stats["tree"]["files_analyzed"]) - for must_see in ("hermes_cli/update_cmd.py", "pm/ensure.py"): + analyzed = set(history["files_analyzed"]) + for must_see in ("hermes_cli/update_cmd.py", "hermes_cli/managed_uv.py"): assert must_see in analyzed, ( f"{must_see} was not analyzed for the freeze — the audit " f"lost part of the update flow; a vacuously small surface " f"cannot guard anything." ) - def test_frozen_contains_a_fresh_tree_audit(self): - # Shallow CI checks a subset, NOT equality: deleted historical loads - # must survive even when today's updater no longer imports them. - fresh = _audit.audit_tree() - fresh_bare = { - f"{m}::{s}" - for (m, s) in fresh.required - if (m, s) not in fresh.guarded_only - } - frozen = _load_surface() - frozen_bare = set(frozen["bare"]) - assert fresh_bare <= frozen_bare, ( - f"new bare updater loads are unfrozen: {sorted(fresh_bare - frozen_bare)}. " - "Run scripts/audit-old-updater-imports.py --freeze " - "tests/compat/old_updater_surface.json on a full clone." - ) - fresh_all = {f"{m}::{s}" for m, s in fresh.required} - frozen_all = frozen_bare | set(frozen["guarded_only"]) - assert fresh_all <= frozen_all - assert not frozen_bare & set(frozen["guarded_only"]) + assert not set(frozen["bare"]) & set(frozen["guarded_only"]) diff --git a/tests/test_old_updater_main_shims.py b/tests/test_old_updater_main_shims.py index 00071d0b92..28fb7ddfb5 100644 --- a/tests/test_old_updater_main_shims.py +++ b/tests/test_old_updater_main_shims.py @@ -1,43 +1,25 @@ """Historical main imports must not restart pre-PM updater work after a swap.""" -import builtins from copy import deepcopy -import importlib -import io -import os from pathlib import Path -import socket -import subprocess -import urllib.request import pytest +from tests.compat.old_updater_support import ( + fresh_child as fresh_child, + no_external_work as no_external_work, +) + @pytest.fixture -def inert_main(monkeypatch): - # Import real current modules before guarding work: CLI startup is not a shim. +def historical_main(no_external_work): from hermes_cli import main - import pm - def forbidden(*args, **kwargs): - pytest.fail("historical main shim attempted updater work") - - for name in ("ensure", "sync_venv", "ensure_environment", "build_environment", "ensure_python_tool"): - monkeypatch.setattr(pm, name, forbidden) - for name in ("Popen", "run"): - monkeypatch.setattr(subprocess, name, forbidden) - for name in ("system", "kill", "rename", "replace", "unlink", "mkdir"): - monkeypatch.setattr(os, name, forbidden) - for name in ("write_text", "write_bytes", "touch", "rename", "replace", "unlink", "mkdir"): - monkeypatch.setattr(Path, name, forbidden) - monkeypatch.setattr(socket, "create_connection", forbidden) - monkeypatch.setattr(urllib.request, "urlopen", forbidden) - monkeypatch.setattr(urllib.request, "urlretrieve", forbidden) - return main, forbidden + return main -def test_historical_main_data_and_skipped_probes_preserve_caller_shapes(inert_main, tmp_path): - main, _ = inert_main +def test_historical_main_data_and_skipped_probes_preserve_caller_shapes(historical_main, tmp_path): + main = historical_main from hermes_cli import main_web_build # Old recorders compose this name with PROJECT_ROOT. It remains data only. @@ -53,6 +35,7 @@ def test_historical_main_data_and_skipped_probes_preserve_caller_shapes(inert_ma assert exc.failed_shims is not failed assert failed[0] in str(exc) + before_files = set(tmp_path.rglob("*")) prefix = ["uv", "pip"] env = {"VIRTUAL_ENV": str(tmp_path)} # None means indeterminate to the historical repair caller, NOT healthy []. @@ -65,8 +48,22 @@ def test_historical_main_data_and_skipped_probes_preserve_caller_shapes(inert_ma assert main._write_web_ui_build_stamp(tmp_path, tmp_path / "web") is None assert prefix == ["uv", "pip"] assert env == {"VIRTUAL_ENV": str(tmp_path)} + assert set(tmp_path.rglob("*")) == before_files +def test_historical_marker_cleanup_preserves_path_and_is_idempotent(historical_main, tmp_path, monkeypatch): + # The retired writer now hands off. Cleanup of an existing legacy marker + # remains supported; PM's recovery lifecycle is covered in test_early_recovery. + monkeypatch.setattr(historical_main, "PROJECT_ROOT", tmp_path) + marker = historical_main._update_marker_path() + assert marker == tmp_path / ".update-incomplete" + marker.write_text("started=1\npid=0\n", encoding="utf-8") + assert historical_main._clear_update_incomplete_marker() is None + assert not marker.exists() + assert historical_main._clear_update_incomplete_marker() is None + + +@pytest.mark.parametrize("cached", [False, True], ids=["cold-lookup", "cached-export"]) @pytest.mark.parametrize( "name,args,kwargs", [ @@ -81,76 +78,52 @@ def test_historical_main_data_and_skipped_probes_preserve_caller_shapes(inert_ma ("_reload_updated_runtime_modules", (), {}), ], ) -def test_historical_main_lazy_dependency_hooks_stop_without_work( - name, args, kwargs, inert_main, monkeypatch, capsys, -): - main, forbidden = inert_main - before_args = deepcopy((args, kwargs)) - before_env = dict(os.environ) - with monkeypatch.context() as guard: - # Exercise PEP 562 even if an earlier test already cached this export. - # The temporary slot also makes monkeypatch restore an absent attribute. - guard.setitem(main.__dict__, name, None) - guard.delitem(main.__dict__, name) - guard.setattr(importlib, "reload", forbidden) - guard.setattr(builtins, "open", forbidden) - guard.setattr(io, "open", forbidden) - for _ in range(2): # both the cold lookup and cached historical caller - with pytest.raises(SystemExit) as exc: - try: - getattr(main, name)(*args, **kwargs) - except Exception: - forbidden() - forbidden() - assert exc.value.code == 0 - assert "run `hermes` again" in capsys.readouterr().err.lower() - assert (args, kwargs) == before_args - assert dict(os.environ) == before_env +def test_historical_main_lazy_hooks_handoff(name, args, kwargs, cached, historical_main, fresh_child, monkeypatch): + main = historical_main + before = deepcopy((args, kwargs)) + # Exercise PEP 562 even if an earlier test cached this export. Register the + # temporary slot with monkeypatch so it also restores an absent attribute. + monkeypatch.setitem(main.__dict__, name, None) + monkeypatch.delitem(main.__dict__, name) + if cached: + getattr(main, name) + with fresh_child.exits(): + getattr(main, name)(*args, **kwargs) + assert (args, kwargs) == before -def test_historical_main_entrypoints_stop_before_install_or_success_fallback( - inert_main, tmp_path, capsys, -): - main, forbidden = inert_main - cmd = ["uv", "pip", "install", "-e", "."] - env = {"VIRTUAL_ENV": str(tmp_path)} - failed = [] - calls = [ +@pytest.mark.parametrize( + "name,args,kwargs", + [ ("_desktop_stamp_path", (), {}), ("_expected_windows_pe_machines", (), {}), - ("_hermes_exe_shims", (tmp_path,), {}), - ("_insert_python_pin", (cmd,), {}), + ("_hermes_exe_shims", (Path("venv"),), {}), + ("_insert_python_pin", (["uv", "pip", "install", "-e", "."],), {}), ("_interpreter_scripts_dir", (), {}), ("_load_installable_optional_extras", (), {"group": "termux-all"}), - ("_parse_pe_machine", (tmp_path / "Hermes.exe",), {}), - ("_quarantine_running_hermes_exe", (tmp_path,), {"max_attempts": 1, "failed_out": failed}), - ("_repair_broken_lazy_refresh_imports", (cmd[:2], ["certifi"]), {"env": env}), - ("_run_install_with_heartbeat", (cmd,), {"env": env, "heartbeat_interval_seconds": 1}), - ("_run_package_only_install", (cmd,), {"env": env}), - ("_run_quarantined_install", (cmd,), {"env": env, "scripts_dir": tmp_path, "strict_quarantine": True}), - ("_run_quarantined_install", (cmd,), {}), - ("_run_with_idle_timeout", (cmd, tmp_path), {"env": env, "idle_timeout_seconds": 1, "indent": ""}), + ("_parse_pe_machine", (Path("Hermes.exe"),), {}), + ("_quarantine_running_hermes_exe", (Path("venv"),), {"max_attempts": 1, "failed_out": []}), + ("_repair_broken_lazy_refresh_imports", (["uv", "pip"], ["certifi"]), {"env": {"VIRTUAL_ENV": "venv"}}), + ("_run_install_with_heartbeat", (["uv", "pip", "install", "-e", "."],), + {"env": {"VIRTUAL_ENV": "venv"}, "heartbeat_interval_seconds": 1}), + ("_run_package_only_install", (["uv", "pip", "install", "-e", "."],), {"env": {"VIRTUAL_ENV": "venv"}}), + ("_run_quarantined_install", (["uv", "pip", "install", "-e", "."],), + {"env": {"VIRTUAL_ENV": "venv"}, "scripts_dir": Path("venv"), "strict_quarantine": True}), + ("_run_quarantined_install", (["uv", "pip", "install", "-e", "."],), {}), + ("_run_with_idle_timeout", (["uv", "pip", "install", "-e", "."], Path("venv")), + {"env": {"VIRTUAL_ENV": "venv"}, "idle_timeout_seconds": 1, "indent": ""}), ("_self", (), {}), - ("_verify_console_scripts_installed", (cmd[:2],), {"env": env}), - ("_verify_core_dependencies_installed", (cmd[:2],), {"env": env, "group": "all"}), - ("_web_ui_build_needed", (tmp_path / "web",), {}), + ("_verify_console_scripts_installed", (["uv", "pip"],), {"env": {"VIRTUAL_ENV": "venv"}}), + ("_verify_core_dependencies_installed", (["uv", "pip"],), {"env": {"VIRTUAL_ENV": "venv"}, "group": "all"}), + ("_web_ui_build_needed", (Path("web"),), {}), ("_windows_native_machine", (), {}), ("_windows_shim_in_process_chain", (), {}), - ] - before_env = dict(os.environ) - for name, args, kwargs in calls: - shim = getattr(main, name) - with pytest.raises(SystemExit) as exc: - try: - shim(*args, **kwargs) - except Exception: - # Historical installers catch ordinary failures to retry pip/uv. - forbidden() - # Returning also lets old callers claim completion or try a fallback. - forbidden() - assert exc.value.code == 0, name - assert "run `hermes` again" in capsys.readouterr().err.lower(), name - assert cmd == ["uv", "pip", "install", "-e", "."] - assert env == {"VIRTUAL_ENV": str(tmp_path)} - assert failed == [] - assert dict(os.environ) == before_env + ], +) +def test_historical_main_entrypoints_handoff_without_install_or_success_fallback( + name, args, kwargs, historical_main, fresh_child, +): + before = deepcopy((args, kwargs)) + with fresh_child.exits(): + getattr(historical_main, name)(*args, **kwargs) + assert (args, kwargs) == before diff --git a/tests/test_old_updater_shims.py b/tests/test_old_updater_shims.py index d2436dff1c..eb3b1fa625 100644 --- a/tests/test_old_updater_shims.py +++ b/tests/test_old_updater_shims.py @@ -1,107 +1,41 @@ """The post-swap import boundary must never revive retired installers.""" -import builtins from copy import deepcopy -import io import importlib import importlib.util -import os from pathlib import Path -import socket import subprocess import sys -import urllib.request +from types import SimpleNamespace import pytest - -@pytest.fixture -def no_external_work(monkeypatch): - """Fail at real I/O and PM boundaries, not at the shim under test.""" - import pm - - def forbidden(*args, **kwargs): - pytest.fail("old-updater shim attempted external work") - - for name in ("ensure", "sync_venv", "ensure_environment", "build_environment", "ensure_python_tool"): - monkeypatch.setattr(pm, name, forbidden) - monkeypatch.setattr(subprocess, "Popen", forbidden) - monkeypatch.setattr(os, "system", forbidden) - monkeypatch.setattr(os, "kill", forbidden) - monkeypatch.setattr(socket, "create_connection", forbidden) - monkeypatch.setattr(urllib.request, "urlopen", forbidden) - monkeypatch.setattr(urllib.request, "urlretrieve", forbidden) - return forbidden - - -@pytest.mark.parametrize( - "name,args,kwargs", - [ - ("ensure_uv", (), {}), - ("ensure_uv", (), {"repair_observer": lambda result: pytest.fail("repair observer ran")}), - ("update_managed_uv", (), {}), - ("update_managed_uv", (), {"force": True}), - ("resolve_uv", (), {}), - ("managed_python_env", (), {}), - ("managed_python_env", (Path("checkout"),), {"install_dir": Path("python"), "base_env": {}}), - ("rebuild_venv", ("uv", Path("venv")), {}), - ("rebuild_venv", ("uv", Path("venv"), "3.11"), {}), - ], +from tests.compat.old_updater_support import ( + fresh_child as fresh_child, + no_external_work as no_external_work, ) -def test_retired_managed_uv_stops_before_fallback(name, args, kwargs, no_external_work, capsys): - module = importlib.import_module("hermes_cli.managed_uv") - before_env = dict(os.environ) - before_home = set(Path(os.environ["HERMES_HOME"]).rglob("*")) - with pytest.raises(SystemExit) as exc: - getattr(module, name)(*args, **kwargs) - assert exc.value.code == 0 - output = capsys.readouterr() - assert "run `hermes` again" in (output.out + output.err).lower() - assert dict(os.environ) == before_env - assert set(Path(os.environ["HERMES_HOME"]).rglob("*")) == before_home - - -@pytest.mark.parametrize("unpack", [False, True], ids=["path-era", "tuple-era"]) -def test_ensure_uv_stops_both_historical_return_contracts(unpack, no_external_work, capsys): - from hermes_cli.managed_uv import ensure_uv - - with pytest.raises(SystemExit) as exc: - if unpack: - uv, fresh_bootstrap = ensure_uv() - else: - uv = ensure_uv() - # A falsy result is NOT inert: old callers install through pip instead. - subprocess.run([uv, "pip", "install"] if uv else [sys.executable, "-m", "pip", "install"]) - assert exc.value.code == 0 - assert "run `hermes` again" in capsys.readouterr().err.lower() @pytest.mark.parametrize( "module,name,args,kwargs", [ - ("hermes_cli.psutil_android", "prepare_patched_psutil_sdist", (Path("psutil.tar.gz"), Path("src")), {}), - ("hermes_cli.update_cmd", "_ensure_uv_for_termux", (["python", "-m", "pip"],), {}), - ("hermes_cli.update_cmd", "_ensure_venv_pip", (["python", "-m", "pip"], "python"), {}), - ("hermes_cli.update_cmd", "_pip_install_prefix", (None,), {}), - ("hermes_cli.update_cmd", "_pip_install_prefix", ("uv",), {}), - ("hermes_cli.update_cmd", "_refuse_update_for_contended_shims", (RuntimeError("locked"),), {}), - ("hermes_cli.tools_config", "install_cua_driver", (), + ("managed_uv", "ensure_uv", (), {}), + ("managed_uv", "ensure_uv", (), {"repair_observer": lambda result: pytest.fail("repair observer ran")}), + ("managed_uv", "update_managed_uv", (), {}), + ("managed_uv", "update_managed_uv", (), {"force": True}), + ("managed_uv", "resolve_uv", (), {}), + ("managed_uv", "managed_python_env", (), {}), + ("managed_uv", "managed_python_env", (Path("checkout"),), {"install_dir": Path("python"), "base_env": {}}), + ("managed_uv", "rebuild_venv", ("uv", Path("venv")), {}), + ("managed_uv", "rebuild_venv", ("uv", Path("venv"), "3.11"), {}), + ("psutil_android", "prepare_patched_psutil_sdist", (Path("psutil.tar.gz"), Path("src")), {}), + ("update_cmd", "_ensure_uv_for_termux", (["python", "-m", "pip"],), {}), + ("update_cmd", "_ensure_venv_pip", (["python", "-m", "pip"], "python"), {}), + ("update_cmd", "_pip_install_prefix", (None,), {}), + ("update_cmd", "_pip_install_prefix", ("uv",), {}), + ("update_cmd", "_refuse_update_for_contended_shims", (RuntimeError("locked"),), {}), + ("tools_config", "install_cua_driver", (), {"upgrade": True, "require_confirmed_update": True, "show_installer_progress": False}), - ], -) -def test_other_dependency_entrypoints_stop_cleanly(module, name, args, kwargs, no_external_work, capsys): - before_home = set(Path(os.environ["HERMES_HOME"]).rglob("*")) - with pytest.raises(SystemExit) as exc: - shim = getattr(importlib.import_module(module), name) - shim(*args, **kwargs) - assert exc.value.code == 0 - assert "run `hermes` again" in capsys.readouterr().err.lower() - assert set(Path(os.environ["HERMES_HOME"]).rglob("*")) == before_home - - -@pytest.mark.parametrize( - "module,name,args,kwargs", - [ ("update_cmd", "_capture_active_lazy_features", (), {}), ("update_cmd", "_refresh_active_lazy_features", (), {}), ("update_cmd", "_refresh_active_lazy_features", (["browser"],), {}), @@ -121,35 +55,32 @@ def test_other_dependency_entrypoints_stop_cleanly(module, name, args, kwargs, n ("update_cmd_maint", "_reload_updated_runtime_modules", (), {}), ], ) -def test_retired_dependency_hooks_stop_before_fallback_or_completion( - module, name, args, kwargs, no_external_work, monkeypatch, capsys, -): - # Resolve real exports, including imports moved out of update_cmd_deps. - shim = getattr(importlib.import_module(f"hermes_cli.{module}"), name) - before_args = deepcopy((args, kwargs)) - before_env = dict(os.environ) - before_modules = dict(sys.modules) - with monkeypatch.context() as guard: - for attr in ("write_text", "write_bytes", "touch", "rename", "replace", "unlink", "mkdir"): - guard.setattr(Path, attr, no_external_work) - for attr in ("rename", "replace", "unlink", "mkdir"): - guard.setattr(os, attr, no_external_work) - guard.setattr(importlib, "reload", no_external_work) - guard.setattr(builtins, "open", no_external_work) - guard.setattr(io, "open", no_external_work) - with pytest.raises(SystemExit) as exc: - try: - shim(*args, **kwargs) - except Exception: - # Old dependency callers retry on ordinary exceptions. Returning - # either false or true can install again or report false success. - no_external_work() - no_external_work() - assert exc.value.code == 0 - assert "run `hermes` again" in capsys.readouterr().err.lower() - assert (args, kwargs) == before_args - assert dict(os.environ) == before_env - assert all(sys.modules.get(name) is module for name, module in before_modules.items()) +def test_retired_dependency_entrypoints_handoff_without_fallback(module, name, args, kwargs, fresh_child): + # Some boundaries (notably psutil_android) hand off during import itself. + # Resolve ordinary modules before the guard: their CLI startup is not a shim. + if module != "psutil_android": + importlib.import_module(f"hermes_cli.{module}") + # Exceptions have identity equality; preserve the caller's instance too. + memo = {id(arg): arg for arg in args if isinstance(arg, BaseException)} + before = deepcopy((args, kwargs), memo) + with fresh_child.exits(): + getattr(importlib.import_module(f"hermes_cli.{module}"), name)(*args, **kwargs) + assert (args, kwargs) == before + + +@pytest.mark.parametrize("unpack", [False, True], ids=["path-era", "tuple-era"]) +@pytest.mark.parametrize("status", [0, 19]) +def test_ensure_uv_stops_both_historical_return_contracts(unpack, status, fresh_child): + from hermes_cli.managed_uv import ensure_uv + + fresh_child.returncode = status + with fresh_child.exits(): + if unpack: + uv, fresh_bootstrap = ensure_uv() + else: + uv = ensure_uv() + # A falsy result is NOT inert: old callers install through pip instead. + subprocess.run([uv, "pip", "install"] if uv else [sys.executable, "-m", "pip", "install"]) def test_retired_probes_and_refreshes_do_no_work(no_external_work, tmp_path): @@ -189,21 +120,15 @@ def old_updater(): return old -def test_old_android_updater_stops_before_download(old_updater, no_external_work, capsys): - with pytest.raises(SystemExit) as exc: +def test_old_android_updater_handoffs_before_download(old_updater, fresh_child): + with fresh_child.exits(): old_updater._install_psutil_android_compat(["uv", "pip"]) - assert exc.value.code == 0 - assert "run `hermes` again" in capsys.readouterr().err.lower() -def test_old_updater_retains_its_code_but_loads_new_managed_uv(old_updater, no_external_work, capsys, tmp_path): - """The real pre-PM post-pull function must stop at its new lazy import.""" - from types import SimpleNamespace - +def test_old_updater_retains_its_code_but_loads_new_managed_uv(old_updater, fresh_child, no_external_work, tmp_path): + """The real pre-PM post-pull function must hand off at its new lazy import.""" old = old_updater - # These are the already-imported pre-swap collaborators. Record the prefix - # without touching an installation; do not replace any lazy imports in the - # frozen function (the managed_uv import is the boundary being exercised). + # Already-imported pre-swap collaborators. Leave frozen lazy imports real. prefix = [] old._refuse_update_if_venv_foreign_owned = lambda root: prefix.append("ownership") old_main = SimpleNamespace( @@ -215,24 +140,25 @@ def test_old_updater_retains_its_code_but_loads_new_managed_uv(old_updater, no_e old._editable_install_is_current = lambda *args: False old._ensure_venv_pip = no_external_work old._ensure_uv_for_termux = no_external_work - before = set(tmp_path.rglob("*")) - - with pytest.raises(SystemExit) as exc: + resume = {"resume_needed": True, "profiles": {"work": "old-pid"}} + before = deepcopy(resume) + before_files = set(tmp_path.rglob("*")) + with fresh_child.exits(): old._sync_python_dependencies_after_pull( ["git"], "main", "before-pull", active_lazy_features=[], - active_tool_dependencies=[], _windows_gateway_resume=None, + active_tool_dependencies=[], _windows_gateway_resume=resume, ) - assert exc.value.code == 0 assert prefix == ["ownership", "self-lock", "old-marker"] - assert "run `hermes` again" in capsys.readouterr().err.lower() - assert set(tmp_path.rglob("*")) == before + assert fresh_child.requests[0]["windows_resume"] == before + # No acknowledgement means the historical caller still owns recovery. + assert resume == before + assert set(tmp_path.rglob("*")) == before_files def test_live_windows_scan_does_not_use_the_retired_main_alias(monkeypatch, no_external_work): from hermes_cli import main, process_identity, update_cmd_windows - # This is routing, not OS emulation: the lifecycle fallback accepts holder - # rows on any host. The real scanner remains owned by update_cmd_windows. + # Routing, not OS emulation: lifecycle fallback accepts rows on any host. monkeypatch.setattr(main, "_detect_venv_python_processes", no_external_work) monkeypatch.setattr(process_identity, "ledger_entries", lambda: []) monkeypatch.setattr(update_cmd_windows, "_psutil", lambda: None) diff --git a/tests/test_source_launcher_stages.py b/tests/test_source_launcher_stages.py index c88b2d3470..fd1d084af3 100644 --- a/tests/test_source_launcher_stages.py +++ b/tests/test_source_launcher_stages.py @@ -1,131 +1,36 @@ -"""Bootstrap stages publish launchers through the shared writer after PM setup.""" +"""Native whole-script publication; POSIX setup/install lives in the fresh E2E.""" import json import os from pathlib import Path import shutil import subprocess -import sys import pytest from hermes_cli.runtime_paths import install_state_dir, site_packages -from pm.lock import Lockfile -from pm.store import current_target from tests.hermes_cli.test_source_launcher_publication import fixture_tree ROOT = Path(__file__).resolve().parents[1] -def selected_environment(repo, value=11): +@pytest.mark.platforms("windows") +@pytest.mark.parametrize("shell", ["powershell", "pwsh"]) +def test_powershell_stage_publishes_without_a_checkout_venv(tmp_path, monkeypatch, shell): + repo, home, interpreter = fixture_tree(tmp_path, monkeypatch) selected = install_state_dir(repo) / 'environments/ready/venv' site = site_packages(selected) site.mkdir(parents=True) (selected / 'pyvenv.cfg').write_text('home = fixture\n', encoding='utf-8') - (site / 'selected_probe.py').write_text(f'VALUE = {value}\n', encoding='utf-8') + (site / 'selected_probe.py').write_text('VALUE = 11\n', encoding='utf-8') (install_state_dir(repo) / 'facts.json').write_text( json.dumps({'packages': {'venv': {'environment': str(selected)}}}), encoding='utf-8') - - -@pytest.mark.platforms("windows", "posix") -def test_developer_setup_publishes_without_a_checkout_venv(tmp_path, monkeypatch): - repo, home, interpreter = fixture_tree(tmp_path, monkeypatch) - shutil.copy2(ROOT / 'setup-hermes.sh', repo / 'setup-hermes.sh') - store = home / 'tools' - uv = Path(shutil.which('uv') or pytest.fail('canonical test environment needs uv')) - version = Lockfile(ROOT / 'pm/lock.json').version('uv') - entry = store / f'uv-{version}-{current_target()}' - entry.mkdir() - shutil.copy2(uv, entry / ('uv.exe' if os.name == 'nt' else 'uv')) - (repo / 'pm').mkdir() - (repo / 'pm/__init__.py').write_text('', encoding='utf-8') - # Stop at PM's install boundary; the launcher writer and boot selection stay real. - (repo / 'pm/cli.py').write_text('', encoding='utf-8') - lock = Lockfile(repo / 'pm/lock.json') - lock.set_pin('uv', version, {}) - lock.set_pin('python', '.'.join(map(str, sys.version_info[:2])), {}) - lock.save() - selected_environment(repo) - env = dict(os.environ, HOME=str(tmp_path / 'shell-home'), HERMES_HOME=str(home), - HERMES_RUNTIME_DIR=str(store), UV_OFFLINE='1', UV_PYTHON_DOWNLOADS='never') - env.pop('UV_PYTHON_INSTALL_DIR', None) - env.pop('PYTHONHOME', None) - env.pop('PYTHONPATH', None) - Path(env['HOME']).mkdir() - result = subprocess.run(['bash', str(repo / 'setup-hermes.sh')], cwd=tmp_path, env=env, - capture_output=True, timeout=90) - if result.returncode: - print(result.stdout.decode('utf-8', errors='replace')) - print(result.stderr.decode('utf-8', errors='backslashreplace')) - assert result.returncode == 0, result.stdout + result.stderr - out = home / 'bin' if os.name == 'nt' else Path(env['HOME']) / '.local/bin' - launchers = list(out.glob('hermes*')) if out.exists() else [] - assert launchers, result.stdout + result.stderr - command = out / ('hermes.exe' if (out / 'hermes.exe').is_file() else 'hermes.cmd' if os.name == 'nt' else 'hermes') - child_env = dict(env) - child_env.pop('HERMES_HOME') - child_env.pop('HERMES_RUNTIME_DIR') - child = subprocess.run([str(command), 'literal input'], cwd=tmp_path, env=child_env, - capture_output=True, text=True, encoding='utf-8', timeout=30) - assert child.returncode == 7, child.stdout + child.stderr - witness = json.loads(child.stdout) - assert witness['value'] == 11 and witness['argv'] == ['literal input'] - assert Path(witness['home']) == home and Path(witness['exe']).samefile(interpreter) - assert not (repo / 'venv').exists() - - -@pytest.mark.platforms("windows", "posix") -def test_shell_installer_path_stage_uses_shared_publication(tmp_path, monkeypatch): - repo, home, interpreter = fixture_tree(tmp_path, monkeypatch) - (repo / 'pm').mkdir() - lock = Lockfile(repo / 'pm/lock.json') - lock.set_pin('python', '.'.join(map(str, sys.version_info[:2])), {}) - lock.save() - selected_environment(repo) - shell_home = tmp_path / 'shell-home' - shell_home.mkdir() - env = dict(os.environ, PROBE_SCRIPT=(ROOT / 'scripts/install.sh').as_posix(), - PROBE_REPO=repo.as_posix(), PROBE_HOME=home.as_posix(), - PROBE_SHELL_HOME=shell_home.as_posix(), UV_OFFLINE='1', UV_PYTHON_DOWNLOADS='never') - env.pop('UV_PYTHON_INSTALL_DIR', None) - script = '''source "$PROBE_SCRIPT" --manifest --dir "$PROBE_REPO" --hermes-home "$PROBE_HOME" -HOME="$PROBE_SHELL_HOME" -JSON=true -run_stage path -''' - # Source the stage to test its transport on Windows without pretending it is POSIX. - result = subprocess.run(['bash', '-c', script], cwd=tmp_path, env=env, - capture_output=True, timeout=90) - assert result.returncode == 0, result.stdout + result.stderr - frames = [json.loads(line) for line in result.stdout.splitlines() if line.startswith(b'{')] - assert frames == [{'ok': True, 'stage': 'path', 'skipped': False}] - out = shell_home / '.local/bin' - for name in ('hermes', 'hermes-acp'): - command = out / (name + '.exe' if (out / (name + '.exe')).is_file() - else name + '.cmd' if os.name == 'nt' else name) - child_env = dict(env) - child_env.pop('HERMES_HOME', None) - child = subprocess.run([str(command), 'from-stage'], cwd=tmp_path, env=child_env, - capture_output=True, text=True, encoding='utf-8', timeout=30) - assert child.returncode == 7, child.stdout + child.stderr - witness = json.loads(child.stdout) - assert witness['value'] == 11 and witness['argv'] == ['from-stage'] - assert Path(witness['home']) == home and Path(witness['exe']).samefile(interpreter) - assert not (repo / 'venv').exists() - - -@pytest.mark.platforms("windows") -def test_powershell_stage_publishes_without_a_checkout_venv(tmp_path, monkeypatch): - repo, home, interpreter = fixture_tree(tmp_path, monkeypatch) - (repo / 'pm').mkdir() - lock = Lockfile(repo / 'pm/lock.json') - lock.set_pin('python', '.'.join(map(str, sys.version_info[:2])), {}) - lock.save() - selected_environment(repo) wrapper = tmp_path / 'stage.ps1' wrapper.write_text('''$ErrorActionPreference = 'Stop' . $env:PROBE_INSTALLER -InstallDir $env:PROBE_REPO -HermesHome $env:PROBE_HOME Initialize-ResolvedPaths -# Replace only the registry publication edge, never mutate the actual user PATH. +# This tests publication, not acquisition or the real user's Python cache. +function Get-BootstrapPython { return $env:PROBE_PYTHON } +# Keep registry writes inside this process, never the real user PATH. function Set-LauncherUserPath([string]$binDir) { if ($binDir -ne (Join-Path $env:PROBE_HOME 'bin')) { throw 'wrong user PATH target' } $script:publishedPath = $binDir @@ -135,14 +40,13 @@ Stage-Path if (-not $script:publishedPath) { throw 'registry-publication seam was bypassed' } exit 0 ''', encoding='utf-8-sig') - powershell = Path(os.environ['SystemRoot']) / 'System32/WindowsPowerShell/v1.0/powershell.exe' + powershell = (Path(os.environ['SystemRoot']) / 'System32/WindowsPowerShell/v1.0/powershell.exe' + if shell == 'powershell' else Path(shutil.which('pwsh') or pytest.fail('native lane requires PowerShell 7'))) env = dict(os.environ, PROBE_INSTALLER=str(ROOT / 'scripts/install.ps1'), - PROBE_REPO=str(repo), PROBE_HOME=str(home), HERMES_HOME=str(tmp_path / 'other-home'), - UV_OFFLINE='1', UV_PYTHON_DOWNLOADS='never') - uv = Path(shutil.which('uv') or pytest.fail('canonical test environment needs uv')) - env['PATH'] = os.pathsep.join([str(uv.parent), str(powershell.parent), str(Path(os.environ['SystemRoot']) / 'System32')]) + PROBE_REPO=str(repo), PROBE_HOME=str(home), PROBE_PYTHON=str(interpreter), + HERMES_HOME=str(tmp_path / 'other-home'), UV_OFFLINE='1', UV_PYTHON_DOWNLOADS='never') + env['PATH'] = os.pathsep.join([str(powershell.parent), str(Path(os.environ['SystemRoot']) / 'System32')]) env['PATHEXT'] = '.COM;.EXE;.BAT;.CMD' - env.pop('UV_PYTHON_INSTALL_DIR', None) result = subprocess.run([str(powershell), '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', str(wrapper)], cwd=tmp_path, env=env, capture_output=True, timeout=90) assert result.returncode == 0, result.stdout + result.stderr @@ -157,4 +61,4 @@ exit 0 witness = json.loads(child.stdout) assert witness['value'] == 11 and witness['argv'] == ['from-powershell'] assert Path(witness['home']) == home and Path(witness['exe']).samefile(interpreter) - assert not (repo / 'venv').exists() + assert not (repo / 'venv').exists() \ No newline at end of file diff --git a/tests/tools/test_dockerfile_node_modules_perms.py b/tests/tools/test_dockerfile_node_modules_perms.py deleted file mode 100644 index 7329a5ce6b..0000000000 --- a/tests/tools/test_dockerfile_node_modules_perms.py +++ /dev/null @@ -1,22 +0,0 @@ -"""Contract test: Docker TUI must not require writable node_modules. - -Older images made /opt/hermes/ui-tui and /opt/hermes/node_modules writable so a -runtime npm install could repair stale dependencies. The hosted install tree is -now immutable, so the Docker image must take the prebuilt TUI bundle path -instead of writing to node_modules at runtime. -""" -from __future__ import annotations - -from pathlib import Path - -REPO_ROOT = Path(__file__).resolve().parents[2] -DOCKERFILE = REPO_ROOT / "Dockerfile" - - -def test_dockerfile_uses_prebuilt_tui_instead_of_writable_node_modules() -> None: - text = DOCKERFILE.read_text() - - assert "ENV HERMES_TUI_DIR=/opt/hermes/ui-tui" in text - assert "cd ../ui-tui && npm run build" in text - assert "chown -R hermes:hermes /opt/hermes/ui-tui" not in text - assert "chown -R hermes:hermes /opt/hermes/node_modules" not in text diff --git a/tests/tools/test_multiplex_tool_cache_scope.py b/tests/tools/test_multiplex_tool_cache_scope.py index 7eb0d3691e..a2d086db01 100644 --- a/tests/tools/test_multiplex_tool_cache_scope.py +++ b/tests/tools/test_multiplex_tool_cache_scope.py @@ -94,8 +94,6 @@ def test_home_keyed_caches_serve_each_profile_its_own_config(tmp_path, monkeypat import tools.tirith_security as tir from tools import mcp_tool_loop - monkeypatch.setattr(tir, "_resolved_path", None) - monkeypatch.setattr(tir, "_resolved_path_by_home", {}) ac._reset_aux_semaphores() cu._AUX_VISION_ROUTE_CACHE.clear() @@ -156,29 +154,21 @@ def test_debounced_sync_push_fires_in_the_scheduling_profiles_context(two_homes, def test_endpoint_model_catalog_memo_is_keyed_by_credential(two_homes, monkeypatch): """Two profiles, same base_url, different api_key: a per-key gateway's catalog fetched with A's key must not be served to B from the in-memory memo (the disk memo already lives per home).""" + from contextlib import contextmanager + import httpx import agent.model_metadata as mm a, b = two_homes mm._endpoint_model_metadata_cache.clear() mm._endpoint_model_metadata_cache_time.clear() - mm._ensure_requests() - class _Resp: - status_code, ok = 200, True + @contextmanager + def stream(url, headers=None, **kwargs): + who = headers.get("Authorization", "").rsplit("-", 1)[-1] + yield httpx.Response(200, request=httpx.Request("GET", url), + json={"data": [{"id": f"model-for-{who}", "context_length": 1}]}) - def __init__(self, headers): - self._who = headers.get("Authorization", "").rsplit("-", 1)[-1] - - def raise_for_status(self): - pass - - def json(self): - return {"data": [{"id": f"model-for-{self._who}", "context_length": 1}]} - - def close(self): - pass - - monkeypatch.setattr(mm.requests, "get", lambda url, headers=None, **k: _Resp(headers or {})) + monkeypatch.setattr(mm.model_metadata_http, "stream", stream) with _scoped(a): assert set(mm.fetch_endpoint_model_metadata("http://gw.example/v1", api_key="key-A")) == {"model-for-A"} with _scoped(b): diff --git a/tests/tools/test_tirith_security.py b/tests/tools/test_tirith_security.py index a2dd36da94..ee32ea09b6 100644 --- a/tests/tools/test_tirith_security.py +++ b/tests/tools/test_tirith_security.py @@ -1,36 +1,21 @@ """Tests for the tirith security scanning subprocess wrapper.""" -import io import json -import os import subprocess -import tarfile -import time from unittest.mock import MagicMock, patch import pytest import tools.tirith_security as _tirith_mod -from tools.tirith_security import check_command_security, ensure_installed +from tools.tirith_security import check_command_security @pytest.fixture(autouse=True) -def _reset_resolved_path(): - """Pre-set cached path to skip auto-install in scan tests. - Tests that specifically test ensure_installed / resolve behavior - reset this to None themselves. - """ - _tirith_mod._resolved_path = "tirith" - _tirith_mod._install_thread = None - _tirith_mod._install_failure_reason = "" - _tirith_mod._crash_count = 0 - _tirith_mod._circuit_open = False - yield - _tirith_mod._resolved_path = None - _tirith_mod._install_thread = None - _tirith_mod._install_failure_reason = "" - _tirith_mod._crash_count = 0 - _tirith_mod._circuit_open = False +def _scan_boundary(monkeypatch): + """Acquisition is exercised by test_security_consumers; isolate verdict rules.""" + monkeypatch.setattr(_tirith_mod, "_resolve_tirith_path", lambda configured: "tirith") + monkeypatch.setattr(_tirith_mod, "_crash_count", 0) + monkeypatch.setattr(_tirith_mod, "_circuit_open", False) # --------------------------------------------------------------------------- @@ -217,382 +202,6 @@ class TestProgrammingErrors: check_command_security("cmd") -# --------------------------------------------------------------------------- -# ensure_installed -# --------------------------------------------------------------------------- - -class TestEnsureInstalled: - @patch("tools.tirith_security._load_security_config") - def test_disabled_returns_none(self, mock_cfg): - mock_cfg.return_value = {"tirith_enabled": False, "tirith_path": "tirith", - "tirith_timeout": 5, "tirith_fail_open": True} - _tirith_mod._resolved_path = None - assert ensure_installed() is None - - @pytest.mark.platforms("linux") - @patch("tools.tirith_security.shutil.which", return_value="/usr/local/bin/tirith") - @patch("tools.tirith_security._load_security_config") - def test_found_on_path_returns_immediately(self, mock_cfg, mock_which): - mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith", - "tirith_timeout": 5, "tirith_fail_open": True} - _tirith_mod._resolved_path = None - with patch("os.path.isfile", return_value=True), \ - patch("os.access", return_value=True): - result = ensure_installed() - assert result == "/usr/local/bin/tirith" - _tirith_mod._resolved_path = None - - -# --------------------------------------------------------------------------- -# Unsupported platform (Windows etc.) — silent fast-path everywhere -# --------------------------------------------------------------------------- - -class TestUnsupportedPlatform: - """When _detect_target() returns None (no tirith binary for this OS+arch), - the entire subsystem must stay silent: no PATH probes, no download thread, - no disk failure marker, no spawn attempts, no CLI banner. Pattern-matching - guards still cover the gap; tirith content scanning is just absent.""" - - @pytest.mark.parametrize("system, machine, expected", [ - ("Linux", "x86_64", True), - ("Windows", "AMD64", False), - ("Linux", "riscv64", False), - ]) - def test_is_platform_supported(self, system, machine, expected): - # The patched (system, machine) pairs are table inputs, not a host - # fake: is_platform_supported() is a pure string mapping that touches - # no OS facility beneath the check, so there is nothing for a real - # host to falsify. Two of the rows (Windows/AMD64, Linux/riscv64) - # could never execute honestly anyway — the second has no CI runner - # on any lane. - with patch("tools.tirith_security.platform.system", return_value=system), \ - patch("tools.tirith_security.platform.machine", return_value=machine): - assert _tirith_mod.is_platform_supported() is expected - - - @patch("tools.tirith_security._load_security_config") - def test_check_command_security_unsupported_allows_silently(self, mock_cfg): - """Windows: skip the resolver and spawn entirely — return allow with - an empty summary so callers can't accidentally surface 'tirith - unavailable' messaging to the user.""" - mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith", - "tirith_timeout": 5, "tirith_fail_open": True} - with patch("tools.tirith_security.is_platform_supported", return_value=False), \ - patch("tools.tirith_security.subprocess.run") as mock_run, \ - patch("tools.tirith_security._resolve_tirith_path") as mock_resolve: - result = check_command_security("rm -rf /") - assert result == {"action": "allow", "findings": [], "summary": ""} - mock_run.assert_not_called() - mock_resolve.assert_not_called() - - @patch("tools.tirith_security._load_security_config") - def test_explicit_path_still_honored_on_unsupported_platform(self, mock_cfg): - """If a user explicitly configured a tirith_path (e.g. they built it - themselves under WSL), the unsupported-platform short-circuit must - NOT override that — explicit config wins.""" - mock_cfg.return_value = {"tirith_enabled": True, - "tirith_path": "/opt/custom/tirith", - "tirith_timeout": 5, "tirith_fail_open": True} - _tirith_mod._resolved_path = None - with patch("tools.tirith_security.is_platform_supported", return_value=False), \ - patch("os.path.isfile", return_value=True), \ - patch("os.access", return_value=True): - result = _tirith_mod._resolve_tirith_path("/opt/custom/tirith") - assert result == "/opt/custom/tirith" - assert _tirith_mod._resolved_path == "/opt/custom/tirith" - - -# --------------------------------------------------------------------------- -# Failed download caches the miss (Finding #1) -# --------------------------------------------------------------------------- - -class TestFailedDownloadCaching: - @pytest.mark.platforms("linux") - @patch("tools.tirith_security._mark_install_failed") - @patch("tools.tirith_security._is_install_failed_on_disk", return_value=False) - @patch("tools.tirith_security._install_tirith", return_value=(None, "download_failed")) - @patch("tools.tirith_security.shutil.which", return_value=None) - def test_failed_install_cached_no_retry(self, mock_which, mock_install, - mock_disk_check, mock_mark): - """After a failed download, subsequent resolves must not retry.""" - from tools.tirith_security import _resolve_tirith_path, _INSTALL_FAILED - _tirith_mod._resolved_path = None - - # First call: tries install, fails - _resolve_tirith_path("tirith") - assert mock_install.call_count == 1 - assert _tirith_mod._resolved_path is _INSTALL_FAILED - mock_mark.assert_called_once_with("download_failed") # reason persisted - - # Second call: hits the cache, does NOT call _install_tirith again - _resolve_tirith_path("tirith") - assert mock_install.call_count == 1 # still 1, not 2 - - _tirith_mod._resolved_path = None - - -# --------------------------------------------------------------------------- -# Explicit path must not auto-download (Finding #2) -# --------------------------------------------------------------------------- - -class TestExplicitPathNoAutoDownload: - @patch("tools.tirith_security._install_tirith") - @patch("tools.tirith_security.shutil.which", return_value=None) - def test_tilde_explicit_path_missing_no_download(self, mock_which, mock_install): - """An explicit ~/path that doesn't exist must NOT trigger download.""" - from tools.tirith_security import _resolve_tirith_path, _INSTALL_FAILED - _tirith_mod._resolved_path = None - - result = _resolve_tirith_path("~/bin/tirith") - mock_install.assert_not_called() - assert _tirith_mod._resolved_path is _INSTALL_FAILED - assert "~" not in result # tilde still expanded - - _tirith_mod._resolved_path = None - - @pytest.mark.platforms("linux") - @patch("tools.tirith_security._mark_install_failed") - @patch("tools.tirith_security._is_install_failed_on_disk", return_value=False) - @patch("tools.tirith_security._install_tirith", return_value=("/auto/tirith", "")) - @patch("tools.tirith_security.shutil.which", return_value=None) - def test_default_path_does_auto_download(self, mock_which, mock_install, - mock_disk_check, mock_mark): - """The default bare 'tirith' SHOULD trigger auto-download.""" - from tools.tirith_security import _resolve_tirith_path - _tirith_mod._resolved_path = None - - result = _resolve_tirith_path("tirith") - mock_install.assert_called_once() - assert result == "/auto/tirith" - - _tirith_mod._resolved_path = None - - -# --------------------------------------------------------------------------- -# Cosign provenance verification (P1) -# --------------------------------------------------------------------------- - -class TestCosignVerification: - @patch("tools.tirith_security.subprocess.run") - @patch("tools.tirith_security.shutil.which", return_value="/usr/bin/cosign") - def test_cosign_identity_pinned_to_release_workflow(self, mock_which, mock_run): - """Identity regexp must pin to the release workflow, not the whole repo.""" - from tools.tirith_security import _verify_cosign - mock_run.return_value = _mock_run(0, "Verified OK") - _verify_cosign("/tmp/checksums.txt", "/tmp/sig", "/tmp/cert") - args = mock_run.call_args[0][0] - # Find the value after --certificate-identity-regexp - idx = args.index("--certificate-identity-regexp") - identity = args[idx + 1] - # The identity contains regex-escaped dots - assert "workflows/release" in identity - assert "refs/tags/v" in identity - - - @patch("tools.tirith_security.tarfile.open") - @patch("tools.tirith_security._verify_checksum", return_value=True) - @patch("tools.tirith_security.shutil.which", return_value=None) - @patch("tools.tirith_security._download_file") - @patch("tools.tirith_security._detect_target", return_value="aarch64-apple-darwin") - def test_install_proceeds_without_cosign(self, mock_target, mock_dl, - mock_which, mock_checksum, - mock_tarfile): - """_install_tirith proceeds with SHA-256 only when cosign is not on PATH.""" - from tools.tirith_security import _install_tirith - mock_tar = MagicMock() - mock_tar.__enter__ = MagicMock(return_value=mock_tar) - mock_tar.__exit__ = MagicMock(return_value=False) - mock_tar.getmembers.return_value = [] - mock_tarfile.return_value = mock_tar - - path, reason = _install_tirith() - # Reaches extraction (no binary in mock archive), but got past cosign - assert path is None - assert reason == "binary_not_in_archive" - assert mock_checksum.called # SHA-256 verification ran - - -class TestInstallArchiveMemberValidation: - def _write_archive(self, tmp_path, member: tarfile.TarInfo, data: bytes | None = None): - archive = tmp_path / "tirith-aarch64-apple-darwin.tar.gz" - checksums = tmp_path / "checksums.txt" - with tarfile.open(archive, "w:gz") as tar: - if data is None: - tar.addfile(member) - else: - tar.addfile(member, io.BytesIO(data)) - checksums.write_text( - "ignored tirith-aarch64-apple-darwin.tar.gz\n", - encoding="utf-8", - ) - return archive, checksums - - def _download_side_effect(self, archive, checksums): - def _download(url, dest, timeout=10): - del timeout - if url.endswith(".tar.gz"): - with open(archive, "rb") as src, open(dest, "wb") as dst: - dst.write(src.read()) - return - if url.endswith("checksums.txt"): - with open(checksums, "rb") as src, open(dest, "wb") as dst: - dst.write(src.read()) - return - raise AssertionError(f"unexpected download URL: {url}") - - return _download - - @patch("tools.tirith_security._verify_checksum", return_value=True) - @patch("tools.tirith_security.shutil.which", return_value=None) - @patch("tools.tirith_security._detect_target", return_value="aarch64-apple-darwin") - def test_install_extracts_regular_tirith_member(self, mock_target, mock_which, - mock_checksum, tmp_path, monkeypatch): - """A valid regular-file tirith member is installed as a plain file.""" - del mock_target, mock_which, mock_checksum - from tools.tirith_security import _install_tirith - - payload = b"#!/bin/sh\nexit 0\n" - member = tarfile.TarInfo("bin/tirith") - member.mode = 0o755 - member.size = len(payload) - archive, checksums = self._write_archive(tmp_path, member, payload) - - hermes_home = tmp_path / "hermes-home" - monkeypatch.setenv("HERMES_HOME", str(hermes_home)) - with patch("tools.tirith_security._download_file", - side_effect=self._download_side_effect(archive, checksums)): - path, reason = _install_tirith(log_failures=False) - - assert reason == "" - assert path == str(hermes_home / "bin" / "tirith") - assert os.path.isfile(path) - assert not os.path.islink(path) - with open(path, "rb") as f: - assert f.read() == payload - - @patch("tools.tirith_security._verify_checksum", return_value=True) - @patch("tools.tirith_security.shutil.which", return_value=None) - @patch("tools.tirith_security._detect_target", return_value="aarch64-apple-darwin") - def test_install_rejects_non_regular_tirith_member(self, mock_target, mock_which, - mock_checksum, tmp_path, monkeypatch): - """Symlink or hardlink tar members must not be installed as tirith.""" - del mock_target, mock_which, mock_checksum - from tools.tirith_security import _install_tirith - - member = tarfile.TarInfo("bin/tirith") - member.type = tarfile.SYMTYPE - member.linkname = "/bin/sh" - archive, checksums = self._write_archive(tmp_path, member) - - hermes_home = tmp_path / "hermes-home" - monkeypatch.setenv("HERMES_HOME", str(hermes_home)) - with patch("tools.tirith_security._download_file", - side_effect=self._download_side_effect(archive, checksums)): - path, reason = _install_tirith(log_failures=False) - - assert path is None - assert reason == "binary_not_regular_file" - assert not os.path.lexists(hermes_home / "bin" / "tirith") - - -# --------------------------------------------------------------------------- -# Background install / non-blocking startup (P2) -# --------------------------------------------------------------------------- - -class TestBackgroundInstall: - @pytest.mark.platforms("linux") - def test_ensure_installed_non_blocking(self): - """ensure_installed must return immediately when download needed.""" - _tirith_mod._resolved_path = None - - with patch("tools.tirith_security._load_security_config", - return_value={"tirith_enabled": True, "tirith_path": "tirith", - "tirith_timeout": 5, "tirith_fail_open": True}), \ - patch("tools.tirith_security.shutil.which", return_value=None), \ - patch("tools.tirith_security._hermes_bin_dir", return_value="/nonexistent"), \ - patch("tools.tirith_security._is_install_failed_on_disk", return_value=False), \ - patch("tools.tirith_security.threading.Thread") as MockThread: - mock_thread = MagicMock() - mock_thread.is_alive.return_value = False - MockThread.return_value = mock_thread - - result = ensure_installed() - assert result is None # not available yet - MockThread.assert_called_once() - mock_thread.start.assert_called_once() - - _tirith_mod._resolved_path = None - - def test_resolve_returns_default_when_thread_alive(self): - """_resolve_tirith_path returns default while background thread runs.""" - from tools.tirith_security import _resolve_tirith_path - _tirith_mod._resolved_path = None - mock_thread = MagicMock() - mock_thread.is_alive.return_value = True - _tirith_mod._install_thread = mock_thread - - with patch("tools.tirith_security.shutil.which", return_value=None), \ - patch("tools.tirith_security._hermes_bin_dir", return_value="/nonexistent"): - result = _resolve_tirith_path("tirith") - assert result == "tirith" # returns configured default, doesn't block - - _tirith_mod._install_thread = None - _tirith_mod._resolved_path = None - - -# --------------------------------------------------------------------------- -# Disk failure marker persistence (P2) -# --------------------------------------------------------------------------- - -class TestDiskFailureMarker: - def test_expired_marker_ignored(self): - """Marker older than TTL should be ignored.""" - import tempfile - tmpdir = tempfile.mkdtemp() - marker = os.path.join(tmpdir, ".tirith-install-failed") - with patch("tools.tirith_security._failure_marker_path", return_value=marker): - from tools.tirith_security import _mark_install_failed, _is_install_failed_on_disk - assert not _is_install_failed_on_disk() - _mark_install_failed("download_failed") - assert _is_install_failed_on_disk() - # Backdate the file past 24h TTL - old_time = time.time() - 90000 # 25 hours ago - os.utime(marker, (old_time, old_time)) - assert not _is_install_failed_on_disk() - - - def test_in_memory_cosign_exec_failed_not_retried(self): - """In-memory _INSTALL_FAILED with cosign_exec_failed is NOT retried.""" - from tools.tirith_security import _resolve_tirith_path, _INSTALL_FAILED - _tirith_mod._resolved_path = _INSTALL_FAILED - _tirith_mod._install_failure_reason = "cosign_exec_failed" - - with patch("tools.tirith_security.shutil.which", return_value=None), \ - patch("tools.tirith_security._hermes_bin_dir", return_value="/nonexistent"), \ - patch("tools.tirith_security._install_tirith") as mock_install: - result = _resolve_tirith_path("tirith") - assert result == "tirith" # fallback - mock_install.assert_not_called() - - _tirith_mod._resolved_path = None - - -# --------------------------------------------------------------------------- -# HERMES_HOME isolation -# --------------------------------------------------------------------------- - -class TestHermesHomeIsolation: - def test_hermes_bin_dir_respects_hermes_home(self): - """_hermes_bin_dir must use HERMES_HOME, not hardcoded ~/.hermes.""" - from tools.tirith_security import _hermes_bin_dir - import tempfile - tmpdir = tempfile.mkdtemp() - with patch.dict(os.environ, {"HERMES_HOME": tmpdir}): - result = _hermes_bin_dir() - assert result == os.path.join(tmpdir, "bin") - assert os.path.isdir(result) - - # --------------------------------------------------------------------------- # Warn-once dedupe (issue: tirith spawn failed spamming on Windows) # --------------------------------------------------------------------------- @@ -705,32 +314,3 @@ class TestIsAppTldFinding: # --------------------------------------------------------------------------- # mkdtemp OSError → no_space (disk-full leak prevention) -# --------------------------------------------------------------------------- - -@pytest.mark.platforms("linux") -class TestMkdtempOSErrorNoSpace: - """When tempfile.mkdtemp raises OSError (e.g. disk full), _install_tirith - must return (None, "no_space") instead of propagating the exception. - This prevents the unbounded retry + temp-dir leak described in #51826. - """ - - def test_mkdtemp_oserror_returns_no_space(self): - from tools.tirith_security import _install_tirith - - with patch("tools.tirith_security.tempfile.mkdtemp", - side_effect=OSError(28, "No space left on device")): - result, reason = _install_tirith(log_failures=False) - assert result is None - assert reason == "no_space" - - def test_mkdtemp_oserror_does_not_leak_tempdir(self): - """No temp directory should remain after a mkdtemp failure.""" - import glob - from tools.tirith_security import _install_tirith - - before = set(glob.glob("/tmp/tirith-install-*")) - with patch("tools.tirith_security.tempfile.mkdtemp", - side_effect=OSError(28, "No space left on device")): - _install_tirith(log_failures=False) - after = set(glob.glob("/tmp/tirith-install-*")) - assert after - before == set() diff --git a/tools/tirith_security.py b/tools/tirith_security.py index f80332bf60..2d6a0ebfed 100644 --- a/tools/tirith_security.py +++ b/tools/tirith_security.py @@ -1,31 +1,20 @@ -"""Tirith pre-exec security scanning wrapper: runs the tirith binary as a subprocess to scan -commands for content-level threats (homograph URLs, pipe-to-interpreter, terminal injection). -The exit code is the verdict source of truth (0 allow, 1 block, 2 warn); JSON stdout only -enriches findings. Operational failures (spawn error, timeout, unknown exit) respect -``fail_open``; programming errors propagate. Auto-install: a missing tirith is downloaded from -GitHub releases to $HERMES_HOME/bin/tirith in a background thread -- SHA-256 always verified, -cosign provenance when cosign is on PATH.""" +"""Tirith pre-exec scanning. PM owns its optional pinned binary; exit codes +remain the verdict authority and operational failures obey fail_open.""" -import hashlib import json import logging import os -import platform import shutil -import stat import subprocess -import tarfile -import tempfile import threading -import time -import urllib.request -from contextlib import suppress +from contextvars import copy_context +from pathlib import Path -from hermes_constants import get_hermes_home, get_hermes_home_override, hermes_home_key +from hermes_constants import hermes_home_key logger = logging.getLogger(__name__) _REPO = "sheeki03/tirith" -# Cosign provenance pinned to the release workflow, not the whole repo. +# Only the release workflow may attest checksums, not arbitrary repo workflows. _COSIGN_IDENTITY_REGEXP = f"^https://github.com/{_REPO}/\\.github/workflows/release\\.yml@refs/tags/v" _COSIGN_ISSUER = "https://token.actions.githubusercontent.com" @@ -56,16 +45,6 @@ def _load_security_config() -> dict: "tirith_fail_open": _env_bool("TIRITH_FAIL_OPEN", cfg.get("tirith_fail_open", True))} -# --- Module state --- -# Cached path after first resolution. _INSTALL_FAILED means "tried and failed" (distinct -# from None = "not yet tried") so a failed install is not retried per command. -_resolved_path: str | None | bool = None -_INSTALL_FAILED = False -_install_failure_reason: str = "" # reason tag when _resolved_path is _INSTALL_FAILED -# Routed profiles (multiplexed gateway) resolve their own binary: ``security.tirith_path`` and -# ``/bin/tirith`` are per profile, so the launch profile's slot above must not answer for them. -_resolved_path_by_home: dict[str, str] = {} - # Circuit breaker: after _CRASH_LIMIT consecutive spawn/execution failures tirith is disabled # for the rest of the process so a broken binary can't turn every tool call into a fail-open # retry loop. Reset on success. Lock-free on purpose: a racing double-increment only opens the @@ -80,17 +59,11 @@ _CRASH_LIMIT = 3 _crash_count: int = 0 _circuit_open: bool = False -_install_lock = threading.Lock() -_install_thread: threading.Thread | None = None - # Warn-once: spawn/path warnings sit in the hot path and would otherwise repeat once per # terminal command while tirith is unavailable (e.g. install thread still running). _warned_messages: set[str] = set() _warned_lock = threading.Lock() -_MARKER_TTL = 86400 # disk failure marker validity (24h) -- avoids retry across restarts - - def _record_tirith_crash() -> None: global _crash_count, _circuit_open _crash_count += 1 @@ -109,119 +82,6 @@ def _warn_once(key: str, message: str, *args) -> None: logger.warning(message, *args) -def _cached_path() -> str | None: - """The path resolved on a previous call, or None if unresolved (None) / failed (_INSTALL_FAILED).""" - if get_hermes_home_override() is not None: - return _resolved_path_by_home.get(hermes_home_key()) - return _resolved_path or None - - -def _store_resolved(path: str) -> None: - global _resolved_path - if get_hermes_home_override() is not None: - _resolved_path_by_home[hermes_home_key()] = path - else: - _resolved_path = path - - -def _set_resolved(path: str) -> None: - global _install_failure_reason - _store_resolved(path) - _install_failure_reason = "" - - -def _set_failed(reason: str) -> None: - global _resolved_path, _install_failure_reason - _resolved_path, _install_failure_reason = _INSTALL_FAILED, reason - - -# --- Disk failure marker --- -def _failure_marker_path() -> str: - return os.path.join(str(get_hermes_home()), ".tirith-install-failed") - - -def _read_failure_reason() -> str | None: - """The marker's reason, or None if absent or older than _MARKER_TTL.""" - try: - p = _failure_marker_path() - if (time.time() - os.path.getmtime(p)) >= _MARKER_TTL: - return None - with open(p, "r", encoding="utf-8-sig") as f: - return f.read().strip() - except OSError: - return None - - -def _is_install_failed_on_disk() -> bool: - """True if a recent install failure was persisted and is still non-retryable. - A 'cosign_missing' marker is auto-cleared once cosign appears on PATH.""" - reason = _read_failure_reason() - if reason == "cosign_missing" and shutil.which("cosign"): - _clear_install_failed() - return False - return reason is not None - - -def _mark_install_failed(reason: str = ""): - """Persist install failure to disk; ``reason`` is a short retryability tag.""" - with suppress(OSError): - p = _failure_marker_path() - os.makedirs(os.path.dirname(p), exist_ok=True) - with open(p, "w", encoding="utf-8") as f: - f.write(reason) - - -def _clear_install_failed(): - """Remove the failure marker and reset warn-once state (so a failure after a reinstall surfaces again).""" - with _warned_lock: - _warned_messages.clear() - with suppress(OSError): - os.unlink(_failure_marker_path()) - - -def _disk_marker_blocks_install() -> bool: - """Apply a still-valid disk marker to module state; True if install must be skipped. - Keeps the marker's real reason so in-process retry can detect cosign_missing.""" - if (disk_reason := _read_failure_reason()) is None or not _is_install_failed_on_disk(): - return False - _set_failed(disk_reason) - return True - - -# --- Auto-install --- -def _hermes_bin_dir() -> str: - """$HERMES_HOME/bin, created if needed.""" - os.makedirs(d := os.path.join(str(get_hermes_home()), "bin"), exist_ok=True) - return d - - -# Rust target triple components. Android (Termux) is ABI-compatible with Linux. Windows is -# absent on purpose (no tirith build): None = "never available here", pattern guards still run. -_TARGET_PLATFORMS = {"Darwin": "apple-darwin", "Linux": "unknown-linux-gnu", "Android": "unknown-linux-gnu"} -_TARGET_ARCHES = {"x86_64": "x86_64", "amd64": "x86_64", "aarch64": "aarch64", "arm64": "aarch64"} - - -def _detect_target() -> str | None: - """Rust target triple for this platform, or None if tirith has no build for it.""" - plat = _TARGET_PLATFORMS.get(platform.system()) - arch = _TARGET_ARCHES.get(platform.machine().lower()) - return f"{arch}-{plat}" if plat and arch else None - - -def is_platform_supported() -> bool: - """True when tirith ships a prebuilt binary for this OS+arch (CLI banner uses this).""" - return _detect_target() is not None - - -def _download_file(url: str, dest: str, timeout: int = 10): - from agent.secret_scope import get_secret - req = urllib.request.Request(url) - if token := get_secret("GITHUB_TOKEN"): - req.add_header("Authorization", f"token {token}") - with urllib.request.urlopen(req, timeout=timeout) as resp, open(dest, "wb") as f: - shutil.copyfileobj(resp, f) - - def _verify_cosign(checksums_path: str, sig_path: str, cert_path: str) -> bool | None: """Cosign provenance of checksums.txt: True verified, False rejected, None if cosign absent/failed.""" if not (cosign := shutil.which("cosign")): @@ -243,230 +103,126 @@ def _verify_cosign(checksums_path: str, sig_path: str, cert_path: str) -> bool | return True -def _verify_release_provenance(base_url: str, tmpdir: str, checksums_path: str, log) -> tuple[bool, str]: - """Cosign step of the install -> ``(cosign_verified, failure_reason)``. Only an explicit - cosign rejection aborts; missing/broken cosign or artifacts fall back to SHA-256 only.""" +def _verify_release_provenance(directory: Path, log) -> tuple[bool, str]: + """Verify PM-acquired checksum provenance; this function never downloads. + + Missing/broken cosign is optional; an explicit rejection is fatal. + """ if not shutil.which("cosign"): - logger.info("cosign not on PATH — installing tirith with SHA-256 verification only " - "(install cosign for full supply chain verification)") + logger.info("cosign not on PATH — installing tirith with SHA-256 verification only") return False, "" - sig_path, cert_path = os.path.join(tmpdir, "checksums.txt.sig"), os.path.join(tmpdir, "checksums.txt.pem") - try: - _download_file(f"{base_url}/checksums.txt.sig", sig_path) - _download_file(f"{base_url}/checksums.txt.pem", cert_path) - except Exception as exc: - logger.info("cosign artifacts unavailable (%s), proceeding with SHA-256 only", exc) + checksums = directory / "checksums.txt" + signature, certificate = directory / "checksums.txt.sig", directory / "checksums.txt.pem" + if not signature.is_file() or not certificate.is_file(): + logger.info("cosign artifacts unavailable, proceeding with SHA-256 only") return False, "" - verified = _verify_cosign(checksums_path, sig_path, cert_path) + verified = _verify_cosign(str(checksums), str(signature), str(certificate)) if verified is False: log("tirith install aborted: cosign provenance verification failed") return False, "cosign_verification_failed" - if verified is None: - logger.info("cosign execution failed, proceeding with SHA-256 only") return verified is True, "" -def _verify_checksum(archive_path: str, checksums_path: str, archive_name: str) -> bool: - """Verify SHA-256 of the archive against checksums.txt (" " lines).""" - with open(checksums_path, encoding="utf-8-sig") as f: - parsed = (line.strip().split(" ", 1) for line in f) - expected = next((h for h, *n in parsed if n == [archive_name]), None) - if not expected: - logger.warning("No checksum entry for %s", archive_name) +# One non-blocking startup attempt per routed home. Durable selection, failure +# recovery, download locks and publication belong to PM, not disk markers here. +_install_lock = threading.Lock() +_install_threads: dict[str, threading.Thread] = {} +_install_attempted: set[str] = set() + + +def _claim_install_attempt() -> bool: + """Share the one-attempt budget between cold scans and startup threads.""" + with _install_lock: + home = hermes_home_key() + if home in _install_attempted: + return False + _install_attempted.add(home) + return True + + +def is_platform_supported() -> bool: + """Whether PM has a managed Tirith build for this host.""" + import pm + + try: + return pm.get_package("tirith").missing_reason(pm.current_target()) is None + except RuntimeError: return False - sha = hashlib.sha256() - with open(archive_path, "rb") as f: - for chunk in iter(lambda: f.read(8192), b""): - sha.update(chunk) - actual = sha.hexdigest() - if actual != expected: - logger.warning("Checksum mismatch: expected %s, got %s", expected, actual) - return actual == expected -def _extract_tirith_binary(tar: tarfile.TarFile, dest_dir: str, log) -> tuple[str | None, str]: - """Extract the tirith binary from a release archive into dest_dir -> ``(path, reason)``.""" - for member in tar.getmembers(): - if member.name.rsplit("/", 1)[-1] != "tirith" or ".." in member.name: - continue - if not member.isfile(): - log("tirith archive member is not a regular file: %s", member.name) - return None, "binary_not_regular_file" - if (src_file := tar.extractfile(member)) is None: - log("tirith binary could not be read from archive") - return None, "binary_extract_failed" - dest_path = os.path.join(dest_dir, "tirith") - with src_file, open(dest_path, "wb") as out: - shutil.copyfileobj(src_file, out) - return dest_path, "" - log("tirith binary not found in archive") - return None, "binary_not_in_archive" - - -def _install_tirith(*, log_failures: bool = True) -> tuple[str | None, str]: - """Download and install tirith to $HERMES_HOME/bin/tirith -> ``(installed_path, - failure_reason)``; the reason ("" on success) is the disk marker's retryability tag.""" - log = logger.warning if log_failures else logger.debug - if not (target := _detect_target()): - logger.info("tirith auto-install: unsupported platform %s/%s", platform.system(), platform.machine()) - return None, "unsupported_platform" - archive_name = f"tirith-{target}.tar.gz" - base_url = f"https://github.com/{_REPO}/releases/latest/download" - try: - tmpdir = tempfile.mkdtemp(prefix="tirith-install-") - except OSError as exc: - log("tirith install failed: cannot create temp dir: %s", exc) - return None, "no_space" - try: - archive_path, checksums_path = os.path.join(tmpdir, archive_name), os.path.join(tmpdir, "checksums.txt") - logger.info("tirith not found — downloading latest release for %s...", target) - try: - _download_file(f"{base_url}/{archive_name}", archive_path) - _download_file(f"{base_url}/checksums.txt", checksums_path) - except Exception as exc: - log("tirith download failed: %s", exc) - return None, "download_failed" - cosign_verified, reason = _verify_release_provenance(base_url, tmpdir, checksums_path, log) - if reason: - return None, reason - if not _verify_checksum(archive_path, checksums_path, archive_name): - return None, "checksum_failed" - with tarfile.open(archive_path, "r:gz") as tar: - src, reason = _extract_tirith_binary(tar, tmpdir, log) - if src is None: - return None, reason - dest = os.path.join(_hermes_bin_dir(), "tirith") - try: - shutil.move(src, dest) - except OSError: - # Cross-device move (Docker, NFS): copy2's metadata step can raise PermissionError, - # so fall back to plain copy + chmod; a partial dest is removed to avoid a - # non-executable retry loop. - try: - shutil.copy(src, dest) - except OSError: - with suppress(OSError): - os.unlink(dest) - return None, "cross_device_copy_failed" - os.chmod(dest, os.stat(dest).st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) - logger.info("tirith installed to %s (%s)", dest, "cosign + SHA-256" if cosign_verified else "SHA-256 only") - return dest, "" - finally: - shutil.rmtree(tmpdir, ignore_errors=True) - - -# --- Path resolution --- -def _is_executable(path: str) -> bool: - return os.path.isfile(path) and os.access(path, os.X_OK) - - -def _find_local_tirith() -> str | None: - """Cheap local lookup for the default "tirith": PATH, then $HERMES_HOME/bin.""" - hermes_bin = os.path.join(_hermes_bin_dir(), "tirith") - return shutil.which("tirith") or (hermes_bin if _is_executable(hermes_bin) else None) - - -def _resolve_locally(configured_path: str, *, warn_missing: bool) -> tuple[str | None, bool]: - """Network-free resolution -> ``(path, may_install)``: ``path`` set = resolved (module state - updated); else ``may_install`` False = terminal miss (explicit path missing, cached non-retryable - failure), True = the disk marker / install step may proceed.""" - global _resolved_path, _install_failure_reason +def _local_tirith(configured_path: str) -> str | None: expanded = os.path.expanduser(configured_path) - # An explicit (non-"tirith") path is authoritative: never auto-download a replacement. if configured_path != "tirith": - if found := (expanded if _is_executable(expanded) else shutil.which(expanded)): - _store_resolved(found) - return found, False - if warn_missing: - logger.warning("Configured tirith path %r not found; scanning disabled", configured_path) - _set_failed("explicit_path_missing") - return None, False - # Always re-run the cheap local checks so a manual install is picked up even after a - # previous network failure (a long-lived gateway recovers without restart). - if found := _find_local_tirith(): - _set_resolved(found) - _clear_install_failed() - return found, False - # Previous install failed: skip the network retry unless the retryable cosign_missing - # cause has been resolved in-process. - if _resolved_path is _INSTALL_FAILED: - if _install_failure_reason != "cosign_missing" or not shutil.which("cosign"): - return None, False - _resolved_path, _install_failure_reason = None, "" - _clear_install_failed() - return None, True + return (expanded if os.path.isfile(expanded) and os.access(expanded, os.X_OK) + else shutil.which(expanded)) + if external := shutil.which("tirith"): + return external + import pm - -def _record_install_result(installed: str | None, reason: str) -> str | None: - """Cache an install outcome in module state + disk marker; returns *installed*.""" - if installed: - _set_resolved(installed) - _clear_install_failed() - else: - _set_failed(reason) - _mark_install_failed(reason) - return installed + selected = pm.installed_package("tirith") + return str(selected.binary) if selected and selected.binary else None def _resolve_tirith_path(configured_path: str) -> str: - """Resolve the tirith path, auto-installing synchronously if needed (default "tirith": PATH → - $HERMES_HOME/bin/tirith → install; failures cached in-process and on disk for 24h). On a miss - the expanded configured path is returned so the spawn fails open via the dedupe'd OSError.""" - if cached := _cached_path(): - return cached - expanded = os.path.expanduser(configured_path) - # No tirith build for this platform: cache the verdict; the spawn fails open once, then - # the fast path above short-circuits. - if configured_path == "tirith" and not is_platform_supported(): - _set_failed("unsupported_platform") - return expanded - found, may_install = _resolve_locally(configured_path, warn_missing=True) - if found or not may_install: - return found or expanded - # A background install is running: don't start a parallel one; fail-open until it finishes. - if _install_running() or _disk_marker_blocks_install(): - return expanded - installed = _record_install_result(*_install_tirith()) - return installed or expanded + """Resolve for a scan; do not wait on a startup download already in flight.""" + if found := _local_tirith(configured_path): + return found + if configured_path == "tirith": + import pm + + if not pm.lazy_installs_allowed() or not _claim_install_attempt(): + return os.path.expanduser(configured_path) + try: + pm.ensure("tirith") + selected = pm.installed_package("tirith") + if selected and selected.binary: + return str(selected.binary) + except Exception as exc: + _warn_once("tirith_install", "tirith install unavailable: %s", exc) + return os.path.expanduser(configured_path) -def _install_running() -> bool: - return _install_thread is not None and _install_thread.is_alive() +def _background_install(*, log_failures: bool) -> None: + import pm + + try: + pm.ensure("tirith") + except Exception as exc: + log = logger.warning if log_failures else logger.debug + log("tirith install failed: %s", exc) -def _background_install(*, log_failures: bool = True): - """Background thread target: download and install tirith.""" - with _install_lock: - if _resolved_path is not None: # another thread resolved meanwhile - return - if found := _find_local_tirith(): # may have been installed by another process - _set_resolved(found) - return - _record_install_result(*_install_tirith(log_failures=log_failures)) +def ensure_installed(*, log_failures: bool = True, explicit: bool = False): + """Opt-in startup is non-blocking. Explicit setup waits and reports errors. + Explicit executable configuration remains authoritative, including a miss. + Lazy refusal never starts a thread; already installed tools remain usable. + """ + import pm -def ensure_installed(*, log_failures: bool = True): - """Resolved path if available now, else None after kicking off a daemon-thread download (local - checks are synchronous; the download never blocks startup). Safe to call repeatedly.""" - global _install_thread cfg = _load_security_config() if not cfg["tirith_enabled"]: return None - if cached := _cached_path(): - return cached if _is_executable(cached) else None - # No tirith build here (e.g. Windows): stay silent -- no PATH probe, no download thread, - # no disk marker. Pattern-matching guards still run. - if not is_platform_supported(): - _set_failed("unsupported_platform") - return None - found, may_install = _resolve_locally(cfg["tirith_path"], warn_missing=False) - if found or not may_install or _disk_marker_blocks_install(): + configured = cfg["tirith_path"] + if configured != "tirith": + return _local_tirith(configured) + if explicit: + pm.ensure("tirith", explicit=True) + selected = pm.installed_package("tirith") + return str(selected.binary) if selected and selected.binary else None + if found := _local_tirith(configured): return found - if not _install_running(): - _install_thread = threading.Thread(target=_background_install, daemon=True, - kwargs={"log_failures": log_failures}) - _install_thread.start() - return None # not available yet; commands fail-open until ready + if not is_platform_supported() or not pm.lazy_installs_allowed(): + return None + if _claim_install_attempt(): + context = copy_context() + thread = threading.Thread( + target=context.run, args=(_background_install,), + kwargs={"log_failures": log_failures}, daemon=True, + ) + _install_threads[hermes_home_key()] = thread + thread.start() + return None # --- Main API --- @@ -506,7 +262,7 @@ def check_command_security(command: str) -> dict: if _circuit_open: return _verdict("allow", "tirith disabled (circuit breaker)") # No binary for this platform, ever: skip the resolver so we never spawn. - if not is_platform_supported(): + if cfg["tirith_path"] == "tirith" and not is_platform_supported(): return _verdict("allow") tirith_path = _resolve_tirith_path(cfg["tirith_path"]) timeout, fail_open = cfg["tirith_timeout"], cfg["tirith_fail_open"] diff --git a/website/docs/developer-guide/egress-internals.md b/website/docs/developer-guide/egress-internals.md index 50852a8a36..051192b97a 100644 --- a/website/docs/developer-guide/egress-internals.md +++ b/website/docs/developer-guide/egress-internals.md @@ -13,7 +13,10 @@ The threat model and high-level design are summarised on the user page; this pag ## Module layout ```text -agent/proxy_sources/iron_proxy.py Core: binary install, CA gen, config build, +pm/security_packages.py Pinned binary acquisition and publication + through PM. Release provenance staging. + +agent/proxy_sources/iron_proxy.py Core: binary lookup, GPG checks, CA gen, config build, subprocess lifecycle, mappings I/O, PID/nonce defense. Pure-function surface where possible. @@ -67,15 +70,14 @@ tests/test_iron_proxy_e2e.py Live E2E (gated on HERMES_RUN_E2E=1). ```text hermes egress install -> agent.proxy_sources.iron_proxy.install_iron_proxy(force=...) - Downloads pinned tarball + checksums.txt from GitHub Releases. - SHA-256 verification before extraction. - tarfile.extract(..., filter="data") on Python 3.12+ (PEP 706); - falls back to plain extract on older Python with member-name - sanitisation via _pick_tar_member. - Stage into ~/.hermes/bin/.iron-proxy_XXXX, chmod 755, os.replace - to ~/.hermes/bin/iron-proxy (atomic). - _VERSION_CACHE.pop(target) so a forced reinstall re-probes - --version on next call. + pm.ensure("iron-proxy", explicit=True) installs or repairs the entry. + PM checks archive and provenance hashes against pm/lock.json. + Package staging checks that release checksums cover the pinned archive, + then calls the GPG checker. Missing GPG permits hash-only installation. + An explicit signature rejection aborts installation. + PM publishes the entry and records its identity and digest in facts. + pm.installed_package("iron-proxy").binary returns the selected path. + _VERSION_CACHE.pop(target) makes the next status call probe --version. hermes egress setup [--from-bitwarden | --no-bitwarden] [--rotate-tokens] -> proxy_cli.cmd_setup diff --git a/website/docs/developer-guide/plugins/index.md b/website/docs/developer-guide/plugins/index.md index ebc9ba9f3f..c00c5d6bee 100644 --- a/website/docs/developer-guide/plugins/index.md +++ b/website/docs/developer-guide/plugins/index.md @@ -319,20 +319,20 @@ requires_plugins: - id: other-plugin version_range: ">=1.0,<2" python_dependencies: - - "somepkg>=1.0,<2" # surfaced, never auto-installed + - "somepkg>=1.0,<2" # consent before PM admission config_schema: api_url: {type: str, default: "", description: "Service endpoint"} ``` -:::note pip-dependency isolation is deferred -`python_dependencies` is intentionally declare-and-surface only. Installing -arbitrary packages into Hermes' shared venv is a conflict and supply-chain -surface, so the install seam's isolation design (constraints-file installs -against the host lock vs. per-plugin vendored dirs vs. conflict detection -with refusal) is an explicitly deferred follow-up — see the round-2 review on -[#64165](https://github.com/NousResearch/hermes-agent/issues/64165) and -[#15220](https://github.com/NousResearch/hermes-agent/issues/15220). Plugin -packs (#64166) build on these v2 fields. +:::note Shared dependency admission +Plugin installation requests Python dependency consent. Enabling the plugin +prepares its requirements with core dependencies, extras, and enabled plugins +through PM. Pack enables use the same admission transaction. Reinstalling an +active plugin requests consent against its staged declaration before publication. +A refusal preserves the installed plugin and selected environment. + +The installer and PM admission reject unsupported `manifest_version` values +and unmet `requires_hermes` constraints before publication. ::: ## Step 3: Write the tool schemas @@ -808,12 +808,14 @@ running process. Already available dependencies need no installation, even when `security.allow_lazy_installs` is false. For a directory plugin's own Python dependencies, declare `dependencies` under -`[project]` in its `pyproject.toml`. Legacy `pip_dependencies` and -`python_dependencies` lists in `plugin.yaml` also join the PM workspace. +`[project]` in its `pyproject.toml`. Without an authored project file, PM combines +legacy `pip_dependencies` and `python_dependencies` lists from `plugin.yaml` or +`plugin.yml`. Old PM-generated project files do not override these lists. +Consent, workspace membership, and currency checks use the same declaration. PM prepares their dependencies together with core requirements before enabling the plugin. The generated workspace does not rewrite the plugin directory or the shipped lockfile. Dependency conflicts refuse admission and preserve the -previous selection; PM does not automatically disable other plugins. +previous selection. PM does not automatically disable other plugins. Dependencies installed manually with pip are not durable PM declarations. A later environment replacement need not retain them. Wrapper plugins whose diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index a4887caf74..6a512e8307 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -527,7 +527,7 @@ Pull API keys from an external secret manager at process startup instead of stor | `status` | Show current config, binary path/version, and token validation status. | | `token` | Rotate the access token: validates the new token against Bitwarden before storing it in `.env` (a rejected token changes nothing). Accepts `--access-token` for non-interactive use and `--no-verify` to skip the probe. | | `sync` | Fetch secrets now and report what changed. Add `--apply` to actually export the secrets into the current shell's environment (default is dry-run). | -| `install` | Download and verify the pinned `bws` binary. `--force` re-downloads even if a managed copy already exists. | +| `install` | Install or repair the PM-pinned `bws` binary. `--force` requests the same integrity check and repair, not an unconditional download. | | `disable` | Turn off the Bitwarden integration. | @@ -735,7 +735,7 @@ Outbound credential-injection firewall for remote terminal sandboxes. Wraps the ```bash hermes egress install # download the pinned iron-proxy binary -hermes egress install --force # re-download even if already installed +hermes egress install --force # check and repair the managed copy hermes egress setup # interactive wizard: CA, mappings, config hermes egress setup --tunnel-port N # override the tunnel listener port (default 9090) diff --git a/website/docs/reference/package-management.md b/website/docs/reference/package-management.md index 173b3469a7..4bd9bc2f80 100644 --- a/website/docs/reference/package-management.md +++ b/website/docs/reference/package-management.md @@ -33,11 +33,12 @@ loading application dependencies. Failed syncs keep the previous selection and retry on the next launch; no pending-update marker is required. Developer checkouts and packaged installations retain their existing owner. -Historical updaters can still be executing old Python code after swapping in this -source tree. Their retired helper names are inert compatibility shims; dependency -entry shims stop the old updater cleanly and ask for a relaunch instead of invoking -PM or falling back to pip. Completion belongs to the new launcher, not that mixed -old-code/new-files process. +Historical updaters can still execute old Python code after replacing the source +tree. Compatibility entry points start a fresh child, wait for completion, and +return its exit status. The child bootstrap asks PM to provision required tools +and select the Python generation before application imports. Completion runs in +that interpreter with the update context and receipt. The parent does not clear +`sys.modules`, import the new application graph, or resume a pip fallback. Current source updates use one PM sync for the recorded extras and enabled plugins, then build frontend products in a fresh process on the selected @@ -51,6 +52,11 @@ Use `hermes pm repair` for damaged dependency files. Source installers provision the required tools plus Python. They select the `all` Python extra. Named optional tools install when requested. +For a canonical source installation, Desktop checks and runs the published +installation launcher. PM owns its interpreter and dependency selection. Desktop +does not replace that command with a guessed `venv` path. Developer overrides +retain their selected interpreter. + Native desktop bundles stage the supported tool set and all target-compatible Python extras before packaging. `--extra all` and `--all-extras` are not synonyms. Platform markers still exclude dependencies that cannot run on a target. @@ -103,17 +109,22 @@ the same lock. ## Optional Python dependencies and plugins A built-in feature requests a project extra through `pm.ensure_import`. -Directory plugins declare Python requirements in `pyproject.toml`, or through -legacy `pip_dependencies` or `python_dependencies` lists in `plugin.yaml`. +Directory plugins declare Python requirements in `pyproject.toml`. Without an +authored project file, PM combines the legacy `pip_dependencies` and +`python_dependencies` lists from `plugin.yaml` or `plugin.yml`. An old +PM-generated project file does not override those lists. Consent, dependency +membership, and currency checks use the same declaration reader. PM prepares core requirements, enabled extras, and enabled plugin requirements together. It seeds resolution from the existing lock. Compatible transitive versions can change, but declared constraints and exact pins remain binding. The generated workspace and extended lock remain outside shipped source. -A failed candidate does not replace the selected environment or silently -disable other plugins. If preparation succeeds, a restart can still be required -to activate the new environment in a running Hermes process. +Plugin selection changes, including pack enables, use the same admission +transaction. PM reads the latest selection under its shared lock before applying +each change. A failed candidate does not replace the selected environment or +silently disable other plugins. If preparation succeeds, a running Hermes process +can still require a restart to activate the new environment. Ordinary Hermes application updates preserve user plugin directories. Explicit plugin updates can change the selected plugin's files. A wrapper with no Python @@ -140,6 +151,26 @@ Docker additionally sets the internal lazy-install disable flag in the image. PM is a dependency manager, not a sandbox for plugin code. Installing a plugin requires trust in that plugin and its dependencies. +## Optional security tools + +PM owns the pinned `bws`, `tirith`, and `iron-proxy` packages in +`pm/security_packages.py`. Their versions, artifact URLs, and SHA-256 hashes +come from `pm/lock.json`. Downloads and publication use the shared tool store, +not private installers under `$HERMES_HOME/bin`. + +For Tirith and iron-proxy, PM also acquires pinned signature files and checks +that the release checksums cover the pinned archive. Package staging calls the +integration's signature checker. Cosign and GPG checks remain conditional on +available executables. Locked provenance files must still be available and +match their hashes. An explicit signature rejection aborts installation. +External executables remain outside PM's hash and signature guarantees. + +`bws` and iron-proxy honor an executable on `PATH` before checking PM selection. +Tirith honors `security.tirith_path`, then uses `PATH` before its PM selection +for the default name. An explicit Tirith path never triggers a replacement +download. Lazy installation obeys PM policy. Explicit install commands check +and repair managed entries, including requests with `--force`. + ## Developer workflow {#developer-workflow} Activation asks PM to prepare or sync the toolchain for a source checkout, then diff --git a/website/docs/user-guide/desktop.md b/website/docs/user-guide/desktop.md index a5025a94c8..e7e83726be 100644 --- a/website/docs/user-guide/desktop.md +++ b/website/docs/user-guide/desktop.md @@ -531,15 +531,16 @@ Boot logs land in `HERMES_HOME/logs/desktop.log` (it includes backend output and hermes logs gui -f ``` -Common resets: +For a canonical source installation, Desktop checks and runs the installation +launcher. PM selects its interpreter and dependency generation. A missing +bootstrap marker does not force installation when that launcher works. + +If Python dependencies are damaged, run the installation's `hermes pm repair`. +Then restart Desktop. Do not delete guessed `venv` paths or PM facts. For +damaged application files, repair through the +[installation owner](/reference/package-management#source-installs-and-packaged-builds). ```bash -# Force a clean first-launch setup (macOS/Linux) -rm "$HOME/.hermes/hermes-agent/.hermes-bootstrap-complete" - -# Rebuild a broken Python venv (macOS/Linux) -rm -rf "$HOME/.hermes/hermes-agent/venv" - # Reset a stuck macOS microphone prompt tccutil reset Microphone com.nousresearch.hermes ``` diff --git a/website/docs/user-guide/egress/iron-proxy.md b/website/docs/user-guide/egress/iron-proxy.md index f38cbfa33a..dc433390a2 100644 --- a/website/docs/user-guide/egress/iron-proxy.md +++ b/website/docs/user-guide/egress/iron-proxy.md @@ -8,7 +8,7 @@ This release wires the egress proxy into the Docker backend only. Modal, Daytona ## What it is -- A managed `iron-proxy` subprocess on the host, lazy-installed into `~/.hermes/bin/iron-proxy` +- An `iron-proxy` subprocess on the host, with its pinned binary in the PM tool store - A local CA at `~/.hermes/proxy/ca.crt` that the sandbox trusts so iron-proxy can MITM TLS and rewrite headers - A `proxy.yaml` config at `~/.hermes/proxy/proxy.yaml` listing the upstream hosts you allow and the secrets-transform mapping - A `mappings.json` recording which proxy token corresponds to which real env var @@ -216,7 +216,7 @@ The CLI subcommand tree: ``` hermes egress install # download the pinned iron-proxy binary -hermes egress install --force # re-download even if a managed copy exists +hermes egress install --force # check and repair the managed copy hermes egress setup # interactive wizard hermes egress setup --tunnel-port N # override the tunnel listener port @@ -277,7 +277,14 @@ Containers already running hold the old tokens and will need to be restarted to ## State directory layout -Everything iron-proxy maintains lives in `~/.hermes/proxy/`: +PM owns the managed binary. Hermes honors an `iron-proxy` executable on +`PATH` before checking PM selection. If neither exists, `auto_install` requests the pinned package, subject +to PM's lazy-install policy. Explicit installation checks and repairs managed +entries without forcing a new download of valid files. See +[PM security tools](/reference/package-management#optional-security-tools) for hash and signature checks. + +Daemon configuration, credentials, and logs remain profile-scoped under +`$HERMES_HOME/proxy/` (`~/.hermes/proxy/` by default): | Path | Mode | Purpose | |---|---|---| diff --git a/website/docs/user-guide/secrets/bitwarden.md b/website/docs/user-guide/secrets/bitwarden.md index 671fb640fe..21cbbe84d5 100644 --- a/website/docs/user-guide/secrets/bitwarden.md +++ b/website/docs/user-guide/secrets/bitwarden.md @@ -9,7 +9,9 @@ Pull API keys from [Bitwarden Secrets Manager](https://bitwarden.com/products/se 3. Every time `hermes` (or the gateway, or a cron job) starts, after `~/.hermes/.env` has loaded, Hermes calls `bws secret list ` and sets the returned keys into `os.environ`. 4. By default Hermes **overrides** values already in your environment, so Bitwarden is the source of truth — rotate a key once in the web app and every Hermes process picks it up on next start. Flip `override_existing: false` in config if you want `.env` to win instead. -The `bws` binary is auto-downloaded into `~/.hermes/bin/` on first use — no `apt`, no `brew`, no `sudo`. +Hermes honors a `bws` executable on `PATH` before checking PM selection. +If neither exists, first use requests the pinned package from +[PM](/reference/package-management#optional-security-tools), subject to the lazy-install policy. ## Why machine accounts (and why no 2FA prompt) @@ -39,7 +41,7 @@ hermes secrets bitwarden setup It will: -1. Download and verify `bws v2.0.0` into `~/.hermes/bin/bws`. +1. If `bws` is absent, request its pinned package from PM. 2. Prompt you for the access token (input is hidden). Stored in `~/.hermes/.env` as `BWS_ACCESS_TOKEN`. 3. Ask which Bitwarden region your machine account belongs to — **US Cloud**, **EU Cloud**, or **self-hosted / custom URL**. Stored in `config.yaml` as `secrets.bitwarden.server_url` and passed to `bws` as `BWS_SERVER_URL`. 4. List the projects the machine account can see; pick one. Stored in `config.yaml` as `secrets.bitwarden.project_id`. @@ -72,7 +74,8 @@ From now on, every `hermes` invocation pulls fresh secrets at startup. You'll se | `hermes secrets bitwarden token` | Rotate the access token: validate the new token against Bitwarden, then store it in `.env` | | `hermes secrets bitwarden sync` | Dry-run: pull secrets now and show what would be applied | | `hermes secrets bitwarden sync --apply` | Pull and export into the current shell's environment | -| `hermes secrets bitwarden install` | Just download the pinned `bws` binary (no auth required) | +| `hermes secrets bitwarden install` | Install or repair the PM-pinned `bws` binary. No Bitwarden authentication required. | +| `hermes secrets bitwarden install --force` | Check and repair the managed copy. Valid entries can be reused without another download. | | `hermes secrets bitwarden disable` | Flip `enabled: false`; leaves token + project id in place | ## Rotating an expired or revoked token @@ -122,7 +125,7 @@ secrets: | `encrypted_cache.enabled` | `false` | Store the last successful fetch in an AES-GCM encrypted cache at `~/.hermes/cache/bws_cache.enc.json`. | | `encrypted_cache.max_stale_seconds` | `0` | When encrypted caching is enabled, allow that cache to be used only after network/timeout failures, up to this age. Authentication failures never use stale secrets. A successful encrypted write removes the legacy plaintext `cache/bws_cache.json`. | | `override_existing` | `true` | When true, Bitwarden values overwrite anything already in env (so rotation in the web app actually takes effect). Flip to `false` if you want `.env` / shell exports to win locally. | -| `auto_install` | `true` | When true, `bws` is auto-downloaded into `~/.hermes/bin/` on first use. | +| `auto_install` | `true` | Requests the PM-pinned `bws` package when no binary exists. PM's lazy-install policy also applies. | ## Failure modes @@ -134,8 +137,8 @@ Bitwarden never blocks Hermes startup. If anything goes wrong, you'll see a one- | `Bitwarden rejected the machine-account access token … invalid_client` | Token revoked, expired, machine account deleted — or the token belongs to another region (e.g. EU token hitting the US identity endpoint) | Run `hermes secrets bitwarden token` to paste a fresh token; for region mismatches re-run setup and pick EU/self-hosted (or set `secrets.bitwarden.server_url`) | | `bws exited 1: invalid access token` | Token revoked or wrong | Run `hermes secrets bitwarden token` with a new token | | `bws timed out` | Network blocked or Bitwarden API slow | Check connectivity to `api.bitwarden.com` (or your `server_url`) | -| `bws binary not available` | `auto_install: false` and `bws` not on PATH | Install manually from [github.com/bitwarden/sdk-sm/releases](https://github.com/bitwarden/sdk-sm/releases) or flip `auto_install` back on | -| `Checksum mismatch` | Download corrupted or tampered | Re-run, will retry; if it persists, file an issue | +| `bws binary not available` | No PM selection or executable on `PATH`, and automatic installation is disabled or failed | Run `hermes secrets bitwarden install` and read its diagnostic. | +| Checksum failure | The download does not match the PM lock | Stop and investigate the download source. Do not bypass the hash check. | Startup warnings now include a `→` remediation line telling you exactly which command fixes the failure. @@ -143,8 +146,8 @@ Startup warnings now include a `→` remediation line telling you exactly which - The bootstrap token (`BWS_ACCESS_TOKEN`) is itself sensitive — anyone with it can read every secret the machine account has access to. Treat it the same as any other API key. - Hermes will refuse to let Bitwarden overwrite the bootstrap token itself, even with `override_existing: true`. If you store `BWS_ACCESS_TOKEN` as a secret inside the project, it's silently skipped during apply. -- The `bws` binary download is verified against the published SHA-256 checksum from the same GitHub release. Mismatch aborts the install. -- The pinned version (`bws v2.0.0` at time of writing) is updated through PRs to this repo — Hermes does not auto-upgrade `bws` to "latest" because upstream release shapes can change. +- PM checks the managed archive against its SHA-256 hash in `pm/lock.json`. A mismatch aborts installation. +- The same lock declares the version. First use does not resolve a "latest" release. External binaries remain outside these PM checks. ## When NOT to use this diff --git a/website/docs/user-guide/security.md b/website/docs/user-guide/security.md index f9b3cdd234..e4dbf34645 100644 --- a/website/docs/user-guide/security.md +++ b/website/docs/user-guide/security.md @@ -725,7 +725,16 @@ Hermes integrates [tirith](https://github.com/sheeki03/tirith) for content-level - Pipe-to-interpreter patterns (`curl | bash`, `wget | sh`) - Terminal injection attacks -Tirith auto-installs from GitHub releases on first use with SHA-256 checksum verification (and cosign provenance verification if cosign is available). +Tirith requests a pinned [PM package](/reference/package-management#optional-security-tools) +when enabled and absent. PM checks artifact hashes from `pm/lock.json` and +calls the cosign checker when available. An explicit provenance rejection aborts +installation. Startup requests installation in the background, subject to the +lazy-install policy. PM owns durable installation state and recovery, not a +separate `.tirith-install-failed` marker. + +An explicit `security.tirith_path` remains authoritative, even if the executable +is missing. With the default name, lookup uses `PATH` before the PM selection. +External binaries remain outside PM's hash and provenance checks. ```yaml # In ~/.hermes/config.yaml @@ -738,7 +747,10 @@ security: When `tirith_fail_open` is `true` (default), commands proceed if tirith is not installed or times out. Set to `false` in high-security environments to block commands when tirith is unavailable. -Tirith ships prebuilt binaries for Linux (x86_64 / aarch64) and macOS (x86_64 / arm64). On platforms with no prebuilt binary (Windows, etc.), tirith is silently skipped — pattern-matching guards still run, and the CLI does not surface an "unavailable" banner. To use tirith on Windows, run Hermes under WSL. +PM supports Tirith on Linux (x86_64 / aarch64) and macOS (x86_64 / arm64). +With the default path, unsupported targets, including native Windows and +Android/Termux, skip Tirith. Pattern-matching guards still run. To use the managed +Tirith package on Windows, run Hermes under WSL. Tirith's verdict integrates with the approval flow: safe commands pass through, while both suspicious and blocked commands trigger user approval with the full tirith findings (severity, title, description, safer alternatives). Users can approve or deny — the default choice is deny to keep unattended scenarios secure.