From 5e4a2a3d2407cdc207f05eee543c63a6136540a2 Mon Sep 17 00:00:00 2001 From: ethernet Date: Sat, 12 Sep 2026 14:57:38 -0400 Subject: [PATCH] refactor(pm): remove legacy dependency and launch managers Competing installers and checkout-local venv assumptions bypassed PM selection, install consent, and generation lifetimes. Route consumers through PM and installation-bound launchers. Refresh source launchers before obsolete Python entries can be collected. Remove Node, browser, and CUA acquisition engines, obsolete venv-holder handling, detached sync, and unused PM APIs. Keep historical updater exports inert and preserve external tool ownership and native integration. Share product freshness and prepared inputs across builders. Align plugin admission, Docker provisioning, setup instructions, and behavioral tests. Verified targeted Python and JavaScript tests, desktop and web typechecks, scoped lint, real product builds, and the Docker frontend smoke test. The missed post-setup test cleanup is included and verified. Native Windows/macOS execution, full Rust compilation, and the complete repository suite remain unverified. Historical compatibility requirements were preserved and extended, not fully rescanned. --- .dockerignore | 4 +- .github/actions/plugin-validate/action.yml | 78 +- .github/workflows/plugin-catalog-ci.yml | 11 +- Dockerfile | 31 +- acp_adapter/entry.py | 32 +- agent/lsp/install.py | 6 +- agent/lsp/servers.py | 32 +- .../src-tauri/src/update.rs | 500 ++--- .../gateway-stop-before-update.test.ts | 32 - .../electron/gateway-stop-before-update.ts | 41 +- apps/desktop/electron/main.ts | 124 +- .../electron/update-prerequisites.test.ts | 66 +- apps/desktop/electron/updater-process.ts | 86 +- .../updater/app-installer-recovery.test.ts | 6 +- .../updater/app-installer-strategy.test.ts | 8 +- .../desktop/electron/updater/app-installer.ts | 2 +- .../electron/updater/checkout-legacy.test.ts | 15 +- .../updater/checkout-ownership.test.ts | 10 +- .../electron/updater/checkout-source.test.ts | 35 +- apps/desktop/electron/updater/checkout.ts | 158 +- .../electron/updater/commit-build.test.ts | 4 +- apps/desktop/electron/updater/index.ts | 2 +- .../electron/venv-blocker-scan.test.ts | 407 ---- apps/desktop/electron/venv-blocker-scan.ts | 323 --- .../electron/windows-hermes-path.test.ts | 40 +- apps/desktop/electron/windows-hermes-path.ts | 8 +- apps/desktop/package.json | 3 +- apps/desktop/scripts/build.mjs | 33 + apps/desktop/scripts/rebuild-native.mjs | 22 - .../src/app/updates-overlay.blockers.test.tsx | 213 -- apps/desktop/src/app/updates-overlay.tsx | 111 +- apps/desktop/src/global.d.ts | 15 +- apps/desktop/src/store/updates.test.ts | 20 - apps/desktop/src/store/updates.ts | 8 +- cron/scheduler_script.py | 4 +- docs/middleware/README.md | 13 +- evals/codebase_navigability/README.md | 10 +- gateway/run.py | 49 +- gateway/run_shutdown.py | 18 +- gateway/status.py | 5 - hermes_cli/_install_repair.py | 40 +- hermes_cli/_launchers.py | 163 +- hermes_cli/_old_updater.py | 2 +- hermes_cli/boot_bootstrap.py | 14 +- hermes_cli/dep_ensure.py | 59 - hermes_cli/desktop_update_verify.py | 4 + hermes_cli/doctor_live.py | 22 +- hermes_cli/doctor_tools.py | 45 +- hermes_cli/gateway.py | 132 +- hermes_cli/main.py | 29 +- hermes_cli/main_desktop.py | 69 +- hermes_cli/main_install_repair.py | 124 +- hermes_cli/main_platform_setup.py | 30 +- hermes_cli/main_tui_launch.py | 56 +- hermes_cli/main_web_build.py | 109 +- hermes_cli/memory_setup.py | 49 +- hermes_cli/nous_subscription.py | 33 +- hermes_cli/npm_engine.py | 163 +- hermes_cli/plugins_cmd.py | 2 +- hermes_cli/runtime_paths.py | 28 +- hermes_cli/source_build.py | 50 +- hermes_cli/subcommands/computer_use.py | 54 +- hermes_cli/tools_config.py | 13 +- hermes_cli/tools_config_cua.py | 706 +----- hermes_cli/tools_config_post_setup.py | 233 +- hermes_cli/update_cmd_maint.py | 40 +- hermes_cli/update_cmd_validation.py | 17 +- hermes_cli/venv_sync.py | 20 + hermes_cli/web_routers/memory_providers.py | 49 +- hermes_cli/web_routers/messaging.py | 24 +- hermes_cli/web_server_gateway.py | 35 +- hermes_cli/web_server_memory.py | 74 +- hermes_cli/windows_ssh_runtime.py | 58 +- hermes_constants.py | 430 +--- .../productivity/memento-flashcards/SKILL.md | 19 +- .../scripts/youtube_quiz.py | 4 +- plugins/memory/hindsight/__init__.py | 20 +- plugins/memory/hindsight/settings.py | 3 +- plugins/memory/honcho/README.md | 2 +- plugins/memory/mem0/README.md | 2 +- plugins/memory/retaindb/README.md | 2 +- plugins/memory/supermemory/README.md | 2 +- plugins/observability/langfuse/README.md | 13 +- plugins/platforms/photon/adapter.py | 79 +- plugins/platforms/photon/cli.py | 26 +- plugins/platforms/whatsapp/adapter.py | 62 +- pm/cli.py | 4 + pm/client.py | 9 +- pm/ensure.py | 22 +- pm/lock.json | 8 +- pm/packages.py | 13 +- pm/plugins_state.py | 51 +- pm/receipt.py | 7 +- pm/worker.py | 2 + pm/workspace.py | 79 +- pyproject.toml | 56 +- scripts/build/README.md | 40 +- scripts/build/desktop.mjs | 3 + scripts/build/freshness.mjs | 94 + scripts/build/icon_environment.py | 5 +- scripts/build/node-deps.mjs | 6 +- scripts/build/tui.mjs | 3 + scripts/build/web.mjs | 3 + scripts/bundles/desktop.py | 8 +- scripts/desktop-update/posix.sh | 65 +- scripts/desktop-update/retry-policy.ps1 | 16 - scripts/desktop-update/runtime.ps1 | 53 + scripts/desktop-update/windows.ps1 | 151 +- scripts/generate-icons.mjs | 5 +- scripts/hermes-gateway | 424 +--- scripts/install.ps1 | 19 +- scripts/install.sh | 6 +- .../hermes-agent/SKILL.md | 2 +- skills/media/youtube-content/SKILL.md | 31 +- .../scripts/fetch_transcript.py | 6 +- .../python-debugpy/SKILL.md | 51 +- tests-js/desktop-builder.test.mjs | 44 + tests-js/generate-icons.test.mjs | 12 +- tests-js/node-deps.test.mjs | 15 + tests-js/product-builders.test.mjs | 30 + tests/acp/test_entry.py | 21 +- tests/agent/lsp/test_python_discovery.py | 73 +- tests/ci/test_plugin_validate_action.py | 38 + tests/compat/old_updater_surface.json | 24 +- tests/computer_use/test_cua_no_overlay.py | 5 +- tests/computer_use/test_cua_pm_selection.py | 208 ++ .../test_cua_spawn_env_sanitization.py | 19 +- tests/computer_use/test_doctor.py | 146 +- .../test_permissions_resolution.py | 88 +- tests/cron/test_cron_script.py | 11 + tests/hermes_cli/test_boot_bootstrap.py | 19 + .../hermes_cli/test_bundled_desktop_launch.py | 3 +- tests/hermes_cli/test_computer_use_cli.py | 172 +- .../test_dashboard_spawn_executable.py | 94 - tests/hermes_cli/test_dep_ensure.py | 72 - .../hermes_cli/test_desktop_exe_integrity.py | 16 +- tests/hermes_cli/test_desktop_source_build.py | 22 +- .../hermes_cli/test_desktop_update_verify.py | 5 +- tests/hermes_cli/test_doctor.py | 76 +- tests/hermes_cli/test_doctor_live.py | 10 +- .../test_ensure_windows_bin_launchers.py | 53 +- tests/hermes_cli/test_gateway.py | 48 +- tests/hermes_cli/test_gateway_service.py | 127 +- tests/hermes_cli/test_gui_command.py | 324 +-- tests/hermes_cli/test_install_cua_driver.py | 1883 ++--------------- tests/hermes_cli/test_memory_setup.py | 7 +- .../test_memory_setup_provider_arg.py | 4 +- tests/hermes_cli/test_nous_subscription.py | 52 +- tests/hermes_cli/test_npm_engine.py | 196 +- .../test_plugin_dependency_consent.py | 4 +- tests/hermes_cli/test_post_setup_gating.py | 37 - tests/hermes_cli/test_source_build.py | 66 +- .../test_source_launcher_publication.py | 195 ++ tests/hermes_cli/test_tools_config.py | 266 +-- .../test_tools_config_post_setup.py | 168 ++ tests/hermes_cli/test_tui_npm_install.py | 6 +- tests/hermes_cli/test_update_cua_pm.py | 105 + tests/hermes_cli/test_update_handoff_exit.py | 31 +- tests/hermes_cli/test_update_import_guard.py | 36 +- .../test_update_receipt_pm_embed.py | 23 +- .../hermes_cli/test_update_shim_self_lock.py | 250 +-- .../test_web_memory_provider_setup_install.py | 250 +-- tests/hermes_cli/test_web_server.py | 17 +- tests/hermes_cli/test_web_ui_build.py | 72 +- tests/install/installer-script-e2e.sh | 19 +- tests/install_ps1_fake_uv.py | 102 - tests/installation_launcher_fixture.py | 27 + .../plugins/memory/test_hindsight_provider.py | 72 +- .../plugins/platforms/photon/test_inbound.py | 10 +- .../photon/test_npm_error_log_regression.py | 14 +- tests/pm/test_cua_driver_package.py | 58 + tests/pm/test_custom_root_union.py | 31 +- tests/pm/test_features.py | 23 +- tests/pm/test_lazy_install_policy.py | 30 + tests/pm/test_node_sidecar.py | 189 +- tests/pm/test_plugin_survival_contract.py | 2 - tests/pm/test_plugins_state.py | 85 +- tests/pm/test_receipt.py | 17 +- tests/pm/test_source_update_launch.py | 134 +- tests/pm/test_worker.py | 71 + tests/pm/test_workspace.py | 57 +- tests/pm/test_workspace_build_inputs.py | 25 +- tests/scripts/test_docker_frontend_inputs.py | 79 + tests/test_desktop_update_target.py | 70 +- ...t_desktop_update_windows_python_handoff.py | 186 +- ...est_desktop_update_windows_retry_policy.py | 56 - tests/test_hermes_constants.py | 12 +- tests/test_install_ps1_desktop_stage.py | 20 +- tests/test_install_sh_launch_handoff.py | 24 + tests/test_node_resolution.py | 387 ++++ tests/test_old_updater_additional_shims.py | 20 +- tests/test_old_updater_shims.py | 2 + tests/test_project_metadata.py | 34 - ...test_windows_subprocess_no_window_flags.py | 33 - .../test_browser_chromium_autoinstall.py | 2 - tests/tools/test_browser_chromium_check.py | 39 +- tests/tools/test_browser_hardening.py | 38 - tests/tools/test_browser_homebrew_paths.py | 496 ----- tests/tools/test_browser_npx_warmup.py | 321 --- tests/tools/test_browser_pm.py | 290 +++ tests/tools/test_browser_process_tree.py | 60 + tests/tools/test_browser_real_profile.py | 1 + tests/tools/test_browser_secret_exfil.py | 4 + tests/tools/test_computer_use.py | 185 +- .../test_dockerfile_immutable_install.py | 42 +- tools/browser_tool.py | 12 +- tools/browser_tool_install.py | 228 +- tools/browser_tool_lifecycle.py | 3 +- tools/browser_tool_session.py | 21 +- tools/computer_use/cua_backend.py | 59 +- tools/computer_use/cua_backend_daemon.py | 8 +- tools/computer_use/cua_backend_driver.py | 73 +- web/src/lib/api.ts | 1 + web/src/lib/memory-provider-setup.ts | 22 + web/src/pages/PluginsPage.test.ts | 18 + web/src/pages/PluginsPage.tsx | 19 +- .../adding-platform-adapters.md | 2 +- .../docs/developer-guide/adding-providers.md | 23 +- .../docs/developer-guide/extending-the-cli.md | 6 +- .../developer-guide/memory-provider-plugin.md | 16 +- website/docs/developer-guide/plugins/index.md | 13 +- website/docs/guides/automation-blueprints.md | 6 +- .../user-guide/features/built-in-plugins.md | 18 +- .../user-guide/features/memory-providers.md | 23 +- .../docs/user-guide/features/web-search.md | 8 +- .../autonomous-ai-agents-hermes-agent.md | 4 +- .../bundled/media/media-youtube-content.md | 33 +- .../software-development-python-debugpy.md | 53 +- .../productivity-memento-flashcards.md | 21 +- .../adding-platform-adapters.md | 2 +- .../developer-guide/adding-providers.md | 22 +- .../developer-guide/extending-the-cli.md | 6 +- .../current/developer-guide/plugins/index.md | 11 +- .../current/guides/automation-blueprints.md | 6 +- .../user-guide/features/built-in-plugins.md | 20 +- .../user-guide/features/memory-providers.md | 13 +- .../current/user-guide/features/web-search.md | 7 +- .../autonomous-ai-agents-hermes-agent.md | 985 ++------- .../bundled/media/media-youtube-content.md | 24 +- .../software-development-python-debugpy.md | 47 +- .../productivity-memento-flashcards.md | 16 +- 241 files changed, 5642 insertions(+), 12054 deletions(-) delete mode 100644 apps/desktop/electron/venv-blocker-scan.test.ts delete mode 100644 apps/desktop/electron/venv-blocker-scan.ts create mode 100644 apps/desktop/scripts/build.mjs delete mode 100644 apps/desktop/scripts/rebuild-native.mjs delete mode 100644 apps/desktop/src/app/updates-overlay.blockers.test.tsx delete mode 100644 hermes_cli/dep_ensure.py create mode 100644 scripts/build/freshness.mjs delete mode 100644 scripts/desktop-update/retry-policy.ps1 create mode 100644 scripts/desktop-update/runtime.ps1 create mode 100644 tests/ci/test_plugin_validate_action.py create mode 100644 tests/computer_use/test_cua_pm_selection.py delete mode 100644 tests/hermes_cli/test_dashboard_spawn_executable.py delete mode 100644 tests/hermes_cli/test_dep_ensure.py create mode 100644 tests/hermes_cli/test_tools_config_post_setup.py create mode 100644 tests/hermes_cli/test_update_cua_pm.py delete mode 100644 tests/install_ps1_fake_uv.py create mode 100644 tests/installation_launcher_fixture.py create mode 100644 tests/pm/test_cua_driver_package.py create mode 100644 tests/pm/test_lazy_install_policy.py create mode 100644 tests/scripts/test_docker_frontend_inputs.py delete mode 100644 tests/test_desktop_update_windows_retry_policy.py create mode 100644 tests/test_install_sh_launch_handoff.py create mode 100644 tests/test_node_resolution.py delete mode 100644 tests/tools/test_browser_homebrew_paths.py delete mode 100644 tests/tools/test_browser_npx_warmup.py create mode 100644 tests/tools/test_browser_pm.py create mode 100644 tests/tools/test_browser_process_tree.py create mode 100644 web/src/lib/memory-provider-setup.ts create mode 100644 web/src/pages/PluginsPage.test.ts diff --git a/.dockerignore b/.dockerignore index 31bed2c49b..ababbcda82 100644 --- a/.dockerignore +++ b/.dockerignore @@ -97,9 +97,7 @@ assets/* !assets/nous-girl-black.svg !assets/nous-girl-white.svg !assets/backgrounds/ -assets/backgrounds/* -!assets/backgrounds/squircle-light.svg -!assets/backgrounds/squircle-dark.svg +!assets/backgrounds/** infographic/ # Plugin-level docs (hermes-achievements ships docs/ but the runtime doesn't read them) diff --git a/.github/actions/plugin-validate/action.yml b/.github/actions/plugin-validate/action.yml index 5484aaf56c..95dc5b0fd5 100644 --- a/.github/actions/plugin-validate/action.yml +++ b/.github/actions/plugin-validate/action.yml @@ -1,56 +1,70 @@ name: Hermes Plugin Validate description: >- - Validate a Hermes Agent plugin (plugin.yaml manifest schema AND - declared-vs-actually-registered capabilities) using - `hermes plugins validate`. Drop this into your plugin repo's CI: - - - uses: actions/checkout@ - - uses: NousResearch/hermes-agent/.github/actions/plugin-validate@main - with: - path: . - - The caller's job owns checkout; this action installs Python + hermes-agent - (git install — a supported CI-context install route) and runs the - validator against your plugin directory. + Validate a plugin's manifest and registered capabilities with Hermes at the + requested ref. The caller owns its plugin checkout; Hermes and its locked + runtime are prepared separately under RUNNER_TEMP. inputs: path: description: Path to the plugin directory (containing plugin.yaml). default: "." hermes-ref: - description: hermes-agent git ref (branch/tag/sha) to install and validate with. + description: hermes-agent git ref (branch/tag/sha) to validate with. default: "main" runs: using: composite steps: - - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 - with: - python-version: "3.11" - - - name: Install hermes-agent + - name: Prepare an isolated Hermes checkout + id: source shell: bash env: _HERMES_REF: ${{ inputs.hermes-ref }} run: | set -euo pipefail - # CI-context install from git; the ref lets plugin authors validate - # against a pinned hermes release instead of main. - pip install "git+https://github.com/NousResearch/hermes-agent@${_HERMES_REF}" + source=$(mktemp -d "$RUNNER_TEMP/hermes-plugin-validator.XXXXXX") + git init "$source" + git -C "$source" remote add origin https://github.com/NousResearch/hermes-agent.git + git -C "$source" fetch --depth=1 -- origin "$_HERMES_REF" + git -C "$source" checkout --detach FETCH_HEAD + printf 'source=%s\n' "$source" >> "$GITHUB_OUTPUT" + if [ "$RUNNER_OS" = Windows ]; then bootstrap=python; else bootstrap=python3; fi + "$bootstrap" -S "$source/scripts/ci/setup_toolchain.py" prepare \ + --toolchain python --home "$source/.build/pm" + + - name: Prepare the locked Python toolchain + shell: bash + env: + _SOURCE: ${{ steps.source.outputs.source }} + run: | + set -euo pipefail + if [ "$RUNNER_OS" = Windows ]; then bootstrap=python; else bootstrap=python3; fi + "$bootstrap" -S "$_SOURCE/scripts/ci/setup_toolchain.py" install \ + --toolchain python --home "$_SOURCE/.build/pm" + + - name: Build the isolated validator runtime + id: runtime + shell: bash + working-directory: ${{ steps.source.outputs.source }} + env: + _SOURCE: ${{ steps.source.outputs.source }} + run: | + set -euo pipefail + "$HERMES_PYTHON" - <<'PY' + import os + from pathlib import Path + from pm import build_environment + source = Path(os.environ["_SOURCE"]) + python = build_environment(source=source, out=source / ".build/validator", explicit=True) + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output: + output.write(f"python={python}\n") + PY - name: Validate plugin shell: bash env: _PLUGIN_PATH: ${{ inputs.path }} + _VALIDATOR_PYTHON: ${{ steps.runtime.outputs.python }} run: | - set -uo pipefail - # `hermes plugins validate` checks the plugin.yaml manifest schema - # and loads the plugin in a scratch subprocess to verify that the - # capabilities it DECLARES match what it actually registers. - if hermes plugins validate "$_PLUGIN_PATH"; then - echo "✅ PASS: plugin at '$_PLUGIN_PATH' validated cleanly" - else - echo "❌ FAIL: plugin at '$_PLUGIN_PATH' failed validation (see output above)" - exit 1 - fi + set -euo pipefail + "$_VALIDATOR_PYTHON" -I -m hermes_cli.main plugins validate "$_PLUGIN_PATH" diff --git a/.github/workflows/plugin-catalog-ci.yml b/.github/workflows/plugin-catalog-ci.yml index 204574d801..92122abdc1 100644 --- a/.github/workflows/plugin-catalog-ci.yml +++ b/.github/workflows/plugin-catalog-ci.yml @@ -49,10 +49,6 @@ jobs: with: fetch-depth: 0 # need the merge-base to diff changed catalog files - - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 - with: - python-version: "3.11" - - name: Find changed catalog entries id: changed run: | @@ -71,11 +67,12 @@ jobs: echo '__EOF__' } >> "$GITHUB_OUTPUT" - - name: Install hermes-agent from the PR's own checkout + - name: Prepare Hermes from the PR's own checkout if: steps.changed.outputs.files != '' - uses: ./.github/actions/retry + uses: ./.github/actions/setup-pm with: - command: pip install -e . + toolchain: python + extras: '[]' - name: Clone each entry at its pinned sha and validate if: steps.changed.outputs.files != '' diff --git a/Dockerfile b/Dockerfile index 99c8f05d9e..844922f07a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -40,14 +40,6 @@ RUN apt-get -o Acquire::Retries=3 update && \ make -j"$(nproc)" && \ make install -# Node 26 source stage. Debian trixie's bundled nodejs is pinned to 20.x -# which reached EOL in April 2026 — we copy node + npm from the upstream -# node:26 image instead (Hermes pins its toolchain to Node 26 everywhere). -# Bookworm-based slim image used so the produced binary links -# against glibc 2.36, which runs cleanly on our Debian 13 (trixie, glibc -# 2.41) runtime. Bumping to a new Node major is a one-line ARG change; see -# #4977. -FROM node:26-bookworm-slim@sha256:9e6f9357d371591e32ab6f2d8a26d63bdd0d17c29eee3f4f3e7e454d9634bf73 AS node_source FROM debian:13.4 AS runtime_base # Disable Python stdout buffering to ensure logs are printed immediately. @@ -160,20 +152,6 @@ COPY --chmod=0755 docker/tini-shim.sh /usr/bin/tini # Non-root user for runtime; UID can be overridden via HERMES_UID at runtime RUN useradd -u 10000 -m -d /opt/data hermes -# Node 26: copy the node binary plus the bundled npm JS install from the -# upstream image. npm and npx are recreated as symlinks because they're -# symlinks in the source image (and need to live on PATH). -# -# No corepack: Node unbundled it upstream, so node:26 ships only npm in -# /usr/local/lib/node_modules. Nothing here needs it — no package.json -# declares a `packageManager`, and no build step shells out to yarn or pnpm. -# -# See node_source stage at the top of the file for the version-bump -# rationale (#4977). -COPY --chmod=0755 --from=node_source /usr/local/bin/node /usr/local/bin/ -COPY --from=node_source /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/npm -RUN ln -sf /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \ - ln -sf /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx WORKDIR /opt/hermes @@ -209,9 +187,11 @@ COPY hermes_constants.py hermes_constants.py COPY hermes_cli/__init__.py hermes_cli/runtime_paths.py hermes_cli/runtime_state.py hermes_cli/ COPY scripts/bundles/payload.py scripts/bundles/payload.py RUN set -eu; \ - python3 -c 'from pm.ensure import ensure; [ensure(name, explicit=True) for name in ("uv", "chromium")]'; \ - python3 -c 'from pathlib import Path; from pm.lock import Facts; from pm.registry import get_package; from pm.store import current_target; root = Path("/opt/hermes/tools"); fact = Facts(root / "facts.json").get("python"); binary = get_package("python").binary(root / fact["entry"], current_target()); Path("/usr/local/bin/python3").symlink_to(binary)'; \ - browser_bin="$(find /opt/hermes/tools/chromium-* -type f \( -name chrome -o -name chromium \) -print -quit)"; \ + python3 -c 'from pm.ensure import ensure; [ensure(name, explicit=True) for name in ("uv", "chromium", "npm")]'; \ + python3 -c 'from pathlib import Path; from pm import installed_package; [Path("/usr/local/bin", command).symlink_to(installed_package(package).binary) for command, package in (("python3", "python"), ("node", "node"), ("npm", "npm"))]'; \ + python3 -c 'import shutil; from pathlib import Path; from pm import env_for; Path("/usr/local/bin/npx").symlink_to(shutil.which("npx", path=env_for("npm", base_env={})["PATH"]))'; \ + node --version; npm --version; \ + browser_bin="$(python3 -c 'from pm import installed_package; print(installed_package("chromium").binary)')"; \ test -n "$browser_bin"; \ "$browser_bin" --version; \ mkdir -p /etc/hermes; \ @@ -242,6 +222,7 @@ COPY pyproject.toml uv.lock ./ COPY web/ web/ COPY ui-tui/ ui-tui/ COPY scripts/build/*.mjs scripts/build/ +COPY scripts/build/icon_environment.py scripts/build/icon_environment.py COPY scripts/generate-icons.mjs scripts/generate_icons.py scripts/ COPY assets/ assets/ RUN node scripts/generate-icons.mjs --source /opt/hermes --out /tmp/hermes-icons && \ diff --git a/acp_adapter/entry.py b/acp_adapter/entry.py index 13dcbcbcc4..2ea29719e2 100644 --- a/acp_adapter/entry.py +++ b/acp_adapter/entry.py @@ -91,11 +91,9 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: parser.add_argument("--setup", action="store_true", help="Run interactive Hermes provider/model setup for ACP terminal auth") parser.add_argument("--setup-browser", action="store_true", - help="Install agent-browser + Playwright Chromium into ~/.hermes/node/ " - "for browser tool support. Idempotent.") + help="Prepare PM's pinned browser tools and Chromium.") parser.add_argument("--yes", "-y", action="store_true", dest="assume_yes", - help="Accept all prompts (currently used by --setup-browser to skip the " - "~400 MB Chromium download confirmation).") + help="Accept setup prompts.") return parser.parse_args(argv) @@ -127,34 +125,24 @@ def _run_setup() -> None: if not sys.stdin.isatty(): return try: - reply = input("\nInstall browser tools? Downloads agent-browser (npm) and " - "optionally Playwright Chromium (~400 MB). [y/N] ").strip().lower() + reply = input("\nInstall browser tools? Downloads the pinned browser and " + "Chromium through PM. [y/N] ").strip().lower() except (EOFError, KeyboardInterrupt): return if reply in {"y", "yes"}: _run_setup_browser(assume_yes=False) -_SETUP_BROWSER_STEPS = ( - ("node", "Node.js installation failed — cannot proceed with browser tools."), - ("browser", "Browser tools installation failed."), -) - - def _run_setup_browser(assume_yes: bool = False) -> int: - """Bootstrap agent-browser + Chromium via dep_ensure -> install.{sh,ps1} - --ensure (shared with the runtime lazy installer). Returns 0 on success, 1 on failure.""" - from hermes_cli.dep_ensure import ensure_dependency + """The setup command is an explicit request for PM's browser closure.""" + import pm try: - for dep, failure_msg in _SETUP_BROWSER_STEPS: - if not ensure_dependency(dep, interactive=not assume_yes): - print(failure_msg, file=sys.stderr) - return 1 - return 0 - except OSError as exc: - print(f"Browser bootstrap failed: {exc}", file=sys.stderr) + pm.ensure("agent-browser", explicit=True) + except (pm.InstallError, OSError) as exc: + print(f"Browser setup failed: {exc}", file=sys.stderr) return 1 + return 0 def main(argv: list[str] | None = None) -> None: diff --git a/agent/lsp/install.py b/agent/lsp/install.py index ddea5266c0..30913854ff 100644 --- a/agent/lsp/install.py +++ b/agent/lsp/install.py @@ -19,7 +19,7 @@ from pathlib import Path from typing import Any, Callable, Dict, Optional from hermes_cli._subprocess_compat import windows_hide_flags -from hermes_constants import find_node_executable +from hermes_constants import find_node_executable, with_hermes_node_path logger = logging.getLogger("agent.lsp.install") @@ -184,8 +184,6 @@ def _link_into_bin(target: Path) -> str: def _install_npm(pkg: str, bin_name: str, extra_pkgs: Optional[list] = None) -> Optional[str]: """``npm install --prefix `` then link ``node_modules/.bin/`` into ``lsp/bin/``.""" - # Managed npm first: $HERMES_HOME/node isn't on an arbitrary process's - # PATH, so a bare which() would miss the Node that Hermes installed. npm = find_node_executable("npm") if npm is None: logger.info("[install] cannot install %s: no usable npm found", pkg) @@ -194,7 +192,7 @@ def _install_npm(pkg: str, bin_name: str, extra_pkgs: Optional[list] = None) -> install_targets = [pkg] + list(extra_pkgs or []) logger.info("[install] npm install --prefix %s %s", staging, " ".join(install_targets)) cmd = [npm, "install", "--prefix", str(staging), "--silent", "--no-fund", "--no-audit", *install_targets] - if not _run_installer("npm", pkg, cmd, timeout=300): + if not _run_installer("npm", pkg, cmd, timeout=300, env=with_hermes_node_path()): return None found = _first_existing(staging / "node_modules" / ".bin" / bin_name) if found is not None: diff --git a/agent/lsp/servers.py b/agent/lsp/servers.py index 954db12ff2..1bb93d8f75 100644 --- a/agent/lsp/servers.py +++ b/agent/lsp/servers.py @@ -133,8 +133,12 @@ def _find_binary(ctx: ServerContext, server_id: str, which: Sequence[str], insta def _make_spec(root: str, ctx: ServerContext, server_id: str, command: List[str], base_init: Optional[Dict[str, Any]] = None, seed: bool = False) -> SpawnSpec: + from pm import env_for + init = ctx.init_overrides.get(server_id, {}) if base_init is None else {**base_init, **ctx.init_overrides.get(server_id, {})} - return SpawnSpec(command, root, root, env=ctx.env_overrides.get(server_id, {}), + env = env_for("node") + env.update(ctx.env_overrides.get(server_id, {})) + return SpawnSpec(command, root, root, env=env, initialization_options=init, seed_diagnostics_on_first_push=seed) @@ -164,29 +168,17 @@ def _spawn_pyright(root: str, ctx: ServerContext) -> Optional[SpawnSpec]: return _make_spec(root, ctx, "pyright", [bin_path, "--stdio"], {"python": {"pythonPath": py}} if py else {}) -def _pm_store_python() -> Optional[str]: - """The PM interpreter used when the analyzed project has no environment.""" - try: - from pm import paths - from pm.lock import Facts - except Exception: - return None - try: - fact = Facts(paths.facts_path()).get("python") - if not fact or "entry" not in fact: - return None - entry = paths.store_root() / fact["entry"] - exe = entry / ("python.exe" if os.name == "nt" else "bin/python3") - return str(exe) if exe.exists() else None - except Exception: - return None - - def _detect_python(root: str) -> Optional[str]: # Pyright needs the project's dependencies, not Hermes's runtime packages. venvs = [v for v in (os.environ.get("VIRTUAL_ENV"), os.path.join(root, ".venv"), os.path.join(root, "venv")) if v] paths = (os.path.join(v, sub) for v in venvs for sub in ("bin/python", "bin/python3", "Scripts/python.exe")) - return next((p for p in paths if os.path.exists(p)), None) or _pm_store_python() + project_python = next((p for p in paths if os.path.exists(p)), None) + if project_python is not None: + return project_python + from pm import installed_package + + installed = installed_package("python") + return str(installed.binary) if installed is not None and installed.binary is not None else None _warned_once: set = set() diff --git a/apps/bootstrap-installer/src-tauri/src/update.rs b/apps/bootstrap-installer/src-tauri/src/update.rs index e981b98e8d..52b2fbcc42 100644 --- a/apps/bootstrap-installer/src-tauri/src/update.rs +++ b/apps/bootstrap-installer/src-tauri/src/update.rs @@ -3,26 +3,12 @@ //! Driven when the installer is launched as `Hermes-Setup.exe --update` (see //! `AppMode` in lib.rs). The desktop app hands off to us — it exits, then we: //! -//! 1. wait for the old Hermes desktop process to fully exit (so both the -//! venv shim and packaged app.asar are free; otherwise `hermes update` -//! or repair bootstrap can race locked files), -//! 2. run `hermes update --yes --gateway` (Python/repo update; this does NOT -//! rebuild apps/desktop by design — see cmd_update in hermes_cli/main.py), -//! 3. run `hermes desktop --build-only` (the rebuild step update skips), -//! 4. launch the freshly-built desktop (reuses bootstrap::launch logic). -//! -//! We reuse the `BootstrapEvent` channel + the existing progress UI by -//! emitting a synthetic multi-stage manifest (handoff → update → rebuild, plus -//! an install stage on macOS). To the frontend an update looks like a short -//! bootstrap, broken into the real operations run_update performs so the user -//! sees discrete steps (with the live log underneath) instead of one bar. -//! -//! Cross-platform note: `hermes update` already handles macOS/Linux (git/pip). -//! The only OS-specific bits here are the venv shim path (resolve_hermes) and -//! the no-window creation flag — both already cfg-gated. Keep new logic -//! OS-agnostic so the mac/linux port stays "fill in the paths". +//! Application output locks protect replacement. Python owns dependency +//! generations, product compilation, and gateway draining/restart. Only a +//! pre-PM runtime gets the historical extra rebuild/retry. Published launchers +//! bind every command to the installation; user-bin migration verifies identity +//! through the existing `--version` surface before selecting an older launcher. -use std::env; use std::ffi::OsString; use std::path::{Path, PathBuf}; use std::process::Stdio; @@ -42,7 +28,7 @@ use crate::powershell::{pump_child, DRAIN_GRACE}; const UPDATE_EXIT_CONCURRENT: i32 = 2; /// How long to wait for the old desktop process to release files under the -/// install tree before giving up and letting `hermes update`'s own guard decide. +/// install tree before refusing the handoff. const DESKTOP_EXIT_WAIT: Duration = Duration::from_secs(20); const DESKTOP_EXIT_POLL: Duration = Duration::from_millis(500); @@ -334,7 +320,8 @@ async fn run_update(app: AppHandle) -> Result<()> { None }; - let hermes = resolve_hermes(&install_root).ok_or_else(|| { + let legacy_install = !install_root.join("pm").is_dir(); + let hermes = resolve_hermes(&install_root).await.ok_or_else(|| { let msg = format!( "Could not find the hermes CLI under {}. Is Hermes installed? \ Re-run the installer to repair the install.", @@ -360,16 +347,11 @@ async fn run_update(app: AppHandle) -> Result<()> { ); // ---- stage 1: wait for the old desktop to die ------------------------ - // The desktop exec'd us then called app.exit(), but process teardown is - // async on Windows. If it still holds the venv shim, `hermes update` - // aborts with exit 2. If it still holds the packaged app.asar, - // install.ps1's repair/re-clone path cannot move/remove the install tree. - // Give both handles a bounded window to clear. Surfaced as its own stage - // (rather than a silent pre-step) so a slow close / force-kill reads as - // real progress instead of a frozen first bar. + // Windows process teardown is asynchronous; the old app must release the + // packaged payload before it can be replaced. Python readers are unrelated. let started = Instant::now(); emit_stage(&app, "handoff", StageState::Running, None, None); - wait_for_install_locks_free(&install_root, &app, "handoff").await; + wait_for_install_locks_free(&install_root, &app, "handoff").await?; emit_stage( &app, "handoff", @@ -396,21 +378,8 @@ async fn run_update(app: AppHandle) -> Result<()> { let child_env = update_child_env(&install_root); let mut update_args: Vec = vec!["update".into(), "--yes".into(), "--gateway".into()]; - // --force skips `hermes update`'s Windows running-exe guard (which would - // `sys.exit(2)` and dead-end the handoff). By contract the desktop has - // already exited and waited for the install locks to clear before launching - // us, and wait_for_install_locks_free below force-kills any straggler — so by the - // time `hermes update` runs there is no legitimate hermes.exe to protect, - // and the guard would only produce a false "Hermes is still running" stop. - // - // NOTE: --force does NOT bypass the venv-python holder guard (that needs - // an explicit `--force-venv`, which we deliberately do not pass). Our lock - // probe only checks the hermes.exe shim and app.asar, so an external venv - // python holding a native .pyd (a user terminal, an unmanaged gateway) - // could still be alive here — mutating the venv under it would strand the - // install half-updated. If that guard fires, it exits 2 and the match arm - // below surfaces the correct "close all Hermes windows" message. - update_args.push("--force".into()); + // Only historical in-place updaters need the old shim bypass. + if legacy_install { update_args.push("--force".into()); } update_args.push("--branch".into()); update_args.push(update_branch); @@ -439,7 +408,7 @@ async fn run_update(app: AppHandle) -> Result<()> { // stare at a scary crash first), retry once automatically. Skip the retry // for the concurrent-instance guard (exit 2) — that's a "close Hermes" state // a retry can't fix. - if !matches!(update.exit_code, Some(0) | Some(UPDATE_EXIT_CONCURRENT)) { + if legacy_install && !matches!(update.exit_code, Some(0) | Some(UPDATE_EXIT_CONCURRENT)) { emit_log( &app, Some("update"), @@ -469,7 +438,7 @@ async fn run_update(app: AppHandle) -> Result<()> { // IS the update: drop our claim and retry once with the marker absent. // The guard re-removes on Drop (idempotent), and the desktop is already // gone at this point, so nothing races the brief marker-free window. - if should_heal_self_marker_refusal( + if legacy_install && should_heal_self_marker_refusal( update.exit_code, &crate::paths::update_in_progress_marker(), ) { @@ -545,74 +514,69 @@ async fn run_update(app: AppHandle) -> Result<()> { } } - // ---- stage 3: hermes desktop --build-only ---------------------------- - // `hermes update` deliberately does NOT build apps/desktop (it installs - // repo-root deps with --workspaces=false). This is the rebuild it skips. - emit_stage(&app, "rebuild", StageState::Running, None, None); - let started = Instant::now(); - let rebuild_args: Vec = vec!["desktop".into(), "--build-only".into()]; - let mut rebuild = run_streamed( - &app, - &hermes, - &rebuild_args, - &install_root, - &child_env, - Some("rebuild"), - ) - .await?; + // Older updaters did not own desktop compilation. Current PM update + // composes all products and propagates failures; never build them twice. + if legacy_install { + emit_stage(&app, "rebuild", StageState::Running, None, None); + let started = Instant::now(); + let rebuild_args: Vec = vec!["desktop".into(), "--build-only".into()]; + let mut rebuild = run_streamed(&app, &hermes, &rebuild_args, &install_root, &child_env, Some("rebuild")).await?; - // Retry-once: the first `--build-only` can return nonzero on a still-settling - // post-update tree or a network-blocked Electron fetch that our self-heal - // repaired mid-run. A second attempt then builds clean off the healed dist - // (the content-hash stamp makes it a near-no-op when the first actually - // succeeded). Without this the updater bails here and never reaches the - // relaunch below — the app updates but doesn't restart. Matches the - // retry-once `hermes update` already does above, and `hermes update`'s own - // desktop rebuild in cmd_update. - if rebuild_needs_retry(rebuild.exit_code) { - emit_log( - &app, - Some("rebuild"), - LogStream::Stdout, - "[rebuild] first desktop rebuild failed; retrying once (a self-healed \ - Electron download builds clean on the second run)…", - ); - rebuild = run_streamed( - &app, - &hermes, - &rebuild_args, - &install_root, - &child_env, - Some("rebuild"), - ) - .await?; - } - let rebuild_ms = started.elapsed().as_millis() as u64; + // Retry-once: the first `--build-only` can return nonzero on a still-settling + // post-update tree or a network-blocked Electron fetch that our self-heal + // repaired mid-run. A second attempt then builds clean off the healed dist + // (the content-hash stamp makes it a near-no-op when the first actually + // succeeded). Without this the updater bails here and never reaches the + // relaunch below — the app updates but doesn't restart. Matches the + // retry-once `hermes update` already does above, and `hermes update`'s own + // desktop rebuild in cmd_update. + if rebuild_needs_retry(rebuild.exit_code) { + emit_log( + &app, + Some("rebuild"), + LogStream::Stdout, + "[rebuild] first desktop rebuild failed; retrying once (a self-healed \ + Electron download builds clean on the second run)…", + ); + rebuild = run_streamed( + &app, + &hermes, + &rebuild_args, + &install_root, + &child_env, + Some("rebuild"), + ) + .await?; + } + let rebuild_ms = started.elapsed().as_millis() as u64; - if rebuild.exit_code != Some(0) { - let msg = format!( - "Rebuilding the desktop app failed (exit {:?}). The update was \ - applied but the app could not be rebuilt; run `hermes desktop` \ - from a terminal to see the error.", - rebuild.exit_code - ); - emit_stage( - &app, - "rebuild", - StageState::Failed, - Some(rebuild_ms), - Some(msg.clone()), - ); - emit( - &app, - BootstrapEvent::Failed { - stage: Some("rebuild".into()), - error: msg.clone(), - }, - ); - return Err(anyhow!(msg)); + if rebuild.exit_code != Some(0) { + let msg = format!( + "Rebuilding the desktop app failed (exit {:?}). The update was \ + applied but the app could not be rebuilt; run `hermes desktop` \ + from a terminal to see the error.", + rebuild.exit_code + ); + emit_stage( + &app, + "rebuild", + StageState::Failed, + Some(rebuild_ms), + Some(msg.clone()), + ); + emit( + &app, + BootstrapEvent::Failed { + stage: Some("rebuild".into()), + error: msg.clone(), + }, + ); + return Err(anyhow!(msg)); + } + emit_stage(&app, "rebuild", StageState::Succeeded, Some(rebuild_ms), None); + } else { + emit_stage(&app, "rebuild", StageState::Succeeded, Some(0), None); } - emit_stage(&app, "rebuild", StageState::Succeeded, Some(rebuild_ms), None); let launch_target = if let Some(target_app) = target_app { let started = Instant::now(); @@ -709,87 +673,25 @@ fn exit_after_success(app: &AppHandle) { app.exit(0); } -/// Poll until the venv shim AND packaged desktop app bundle are no longer locked -/// (Windows) or a bounded timeout elapses. On non-Windows this is a short fixed -/// grace since file locking isn't the failure mode there. -pub(crate) async fn wait_for_install_locks_free(install_root: &Path, app: &AppHandle, stage: &str) { +/// Wait for the application payload being replaced, never PM dependency readers. +pub(crate) async fn wait_for_install_locks_free(install_root: &Path, app: &AppHandle, stage: &str) -> Result<()> { let lock_targets = install_lock_probe_paths(install_root); let deadline = Instant::now() + DESKTOP_EXIT_WAIT; - emit_log(app, Some(stage), LogStream::Stdout, "[handoff] waiting for Hermes to exit…"); - loop { let locked = locked_paths(&lock_targets); if locked.is_empty() { - return; + return Ok(()); } if Instant::now() >= deadline { - // Last resort: a backend shim can still hold update-sensitive - // files when the desktop's shutdown races a detached child. Only - // target the shim at this install root: the desktop binary is also - // Hermes.exe, so an image-name kill would tear down the app itself. - emit_log( - app, - Some(stage), - LogStream::Stdout, - &format!( - "[handoff] Hermes still holding install files ({}); locating backend shims…", - format_locked_paths(&locked) - ), - ); - let shim = venv_hermes(install_root); - let shim_pids = backend_shim_pids(&shim); - if shim_pids.is_empty() { - emit_log( - app, - Some(stage), - LogStream::Stdout, - "[handoff] no installed backend shim matched the force-kill fallback", - ); - } else { - for pid in &shim_pids { - emit_log( - app, - Some(stage), - LogStream::Stdout, - &format!( - "[handoff] force-killing backend shim PID {pid} ({})", - shim.display() - ), - ); - } - force_kill_process_trees(&shim_pids); - } - tokio::time::sleep(Duration::from_millis(800)).await; - let locked_after_kill = locked_paths(&lock_targets); - if locked_after_kill.is_empty() { - emit_log( - app, - Some(stage), - LogStream::Stdout, - "[handoff] install files freed after force-kill", - ); - } else { - emit_log( - app, - Some(stage), - LogStream::Stdout, - &format!( - "[handoff] install files still locked ({}); proceeding (--force + quarantine will handle it)", - format_locked_paths(&locked_after_kill) - ), - ); - } - return; + return Err(anyhow!("Desktop application files are still locked: {}. Close the other Hermes window and retry.", format_locked_paths(&locked))); } tokio::time::sleep(DESKTOP_EXIT_POLL).await; } } fn install_lock_probe_paths(install_root: &Path) -> Vec { - let mut paths = vec![venv_hermes(install_root)]; - paths.extend(desktop_app_payload_paths(install_root)); - paths + desktop_app_payload_paths(install_root) } fn desktop_app_payload_paths(install_root: &Path) -> Vec { @@ -817,102 +719,11 @@ fn format_locked_paths(paths: &[PathBuf]) -> String { paths.iter().map(|p| p.display().to_string()).collect::>().join(", ") } -/// Find processes running the exact `venv\Scripts\hermes.exe` shim for this -/// installation. Windows image names are case-insensitive and the desktop is -/// also Hermes.exe, so matching by image name alone is unsafe. -#[cfg(windows)] -fn backend_shim_pids(shim: &Path) -> Vec { - use std::ffi::OsString; - use std::mem::{size_of, zeroed}; - use std::os::windows::ffi::OsStringExt; - use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE}; - use windows_sys::Win32::System::Diagnostics::ToolHelp::{ - CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, - TH32CS_SNAPPROCESS, - }; - use windows_sys::Win32::System::Threading::{ - OpenProcess, QueryFullProcessImageNameW, PROCESS_QUERY_LIMITED_INFORMATION, - }; - - const MAX_PATH_CHARS: usize = 32_768; - - fn image_path_for_pid(pid: u32) -> Option { - unsafe { - let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid); - if handle.is_null() { - return None; - } - let mut path = vec![0_u16; MAX_PATH_CHARS]; - let mut len = path.len() as u32; - let ok = QueryFullProcessImageNameW(handle, 0, path.as_mut_ptr(), &mut len); - CloseHandle(handle); - (ok != 0).then(|| PathBuf::from(OsString::from_wide(&path[..len as usize]))) - } - } - - let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) }; - if snapshot == INVALID_HANDLE_VALUE { - return Vec::new(); - } - - let mut entry: PROCESSENTRY32W = unsafe { zeroed() }; - entry.dwSize = size_of::() as u32; - let mut pids = Vec::new(); - let mut inspected_candidates = 0_u32; - let own_pid = std::process::id(); - let mut has_entry = unsafe { Process32FirstW(snapshot, &mut entry) } != 0; - while has_entry { - let pid = entry.th32ProcessID; - if pid != own_pid { - if let Some(path) = image_path_for_pid(pid) { - inspected_candidates += 1; - if same_windows_path(&path, shim) { - pids.push(pid); - } - } - } - has_entry = unsafe { Process32NextW(snapshot, &mut entry) } != 0; - } - unsafe { CloseHandle(snapshot) }; - if pids.is_empty() && inspected_candidates > 0 { - tracing::debug!( - expected_shim = %shim.display(), - inspected_candidates, - "no queryable process image matched the backend shim path" - ); - } - pids -} - -#[cfg(not(windows))] -fn backend_shim_pids(_shim: &Path) -> Vec { - Vec::new() -} - -fn same_windows_path(actual: &Path, expected: &Path) -> bool { - actual - .to_string_lossy() - .eq_ignore_ascii_case(&expected.to_string_lossy()) -} - -#[cfg(windows)] -fn force_kill_process_trees(pids: &[u32]) { - for pid in pids { - let _ = std::process::Command::new("taskkill") - .args(["/F", "/T", "/PID", &pid.to_string()]) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status(); - } -} - -#[cfg(not(windows))] -fn force_kill_process_trees(_pids: &[u32]) {} /// Best-effort lock probe: try to open the file for read+write. On Windows an /// exclusively-held running .exe refuses the open with a sharing violation. /// On Unix this almost always succeeds (no mandatory locking), which is fine — -/// the venv-shim contention is a Windows-only problem. +/// application-output contention is a Windows-only problem. fn is_locked(path: &Path) -> bool { if !path.exists() { return false; @@ -942,8 +753,23 @@ async fn run_streamed( envs: &[(String, OsString)], stage: Option<&str>, ) -> Result { - let mut cmd = Command::new(program); - cmd.args(args) + let current = resolve_hermes(cwd).await.ok_or_else(|| anyhow!("Installation launcher missing under {}", cwd.display()))?; + let mut command: Vec = vec![current.to_string_lossy().into_owned()]; + if current.starts_with(cwd.join(".hermes").join("bin")) { + let mut query = Command::new(¤t); + query.arg("--print-runtime-command").current_dir(cwd); + #[cfg(windows)] + query.creation_flags(0x0800_0000); + for (key, value) in envs { query.env(key, value); } + let output = query.output().await?; + if !output.status.success() { return Err(anyhow!("Installation launcher could not resolve its runtime: {}", current.display())); } + command = serde_json::from_slice(&output.stdout)?; + if command.len() < 2 || command.iter().any(|part| part.is_empty()) { + return Err(anyhow!("Installation launcher returned invalid runtime command")); + } + } + let mut cmd = Command::new(&command[0]); + cmd.args(&command[1..]).args(args) .current_dir(cwd) .stdin(Stdio::null()) .stdout(Stdio::piped()) @@ -998,36 +824,55 @@ struct CmdResult { exit_code: Option, } -/// Path to the venv hermes shim under an install root, regardless of existence. -fn venv_hermes(install_root: &Path) -> PathBuf { - if cfg!(target_os = "windows") { - install_root.join("venv").join("Scripts").join("hermes.exe") - } else { - install_root.join("venv").join("bin").join("hermes") +/// Resolve only a launcher owned by this installation, never PATH. +async fn resolve_hermes(install_root: &Path) -> Option { + let names: &[&str] = if cfg!(target_os = "windows") { &["hermes.exe", "hermes.cmd"] } else { &["hermes"] }; + for name in names { + let launcher = install_root.join(".hermes").join("bin").join(name); + if launcher.is_file() { return Some(launcher); } } -} - -/// Resolve the hermes CLI to drive. Prefer the venv shim in the install we -/// just updated; fall back to `hermes` on PATH. -fn resolve_hermes(install_root: &Path) -> Option { - let shim = venv_hermes(install_root); - if shim.exists() { - return Some(shim); - } - // PATH fallback. which-style probe via env, kept dependency-free. - let exe = if cfg!(target_os = "windows") { "hermes.exe" } else { "hermes" }; - if let Ok(path) = std::env::var("PATH") { - let sep = if cfg!(target_os = "windows") { ';' } else { ':' }; - for dir in path.split(sep) { - let cand = Path::new(dir).join(exe); - if cand.exists() { - return Some(cand); + // Earlier PM publication lived in user-bin only. The CLI's existing + // version surface proves which source tree that command belongs to. + if install_root.join("hermes_cli/_launchers.py").is_file() { + let mut directories = vec![crate::paths::hermes_home().join("bin")]; + if let Some(home) = dirs::home_dir() { directories.push(home.join(".local/bin")); } + if let Some(parent) = install_root.parent() { directories.push(parent.join("bin")); } + if let Some(local) = std::env::var_os("LOCALAPPDATA") { directories.push(PathBuf::from(local).join("hermes/bin")); } + for directory in directories { + for name in names { + let candidate = directory.join(name); + if candidate.is_file() && launcher_targets_installation(&candidate, install_root).await { + return Some(candidate); + } } } } + // Required transition for pre-PM releases. A broken PM publication must + // fail closed rather than select an unrelated interpreter or checkout. + if !install_root.join("pm").is_dir() { + let legacy = install_root.join("venv") + .join(if cfg!(target_os = "windows") { "Scripts" } else { "bin" }) + .join(names[0]); + if legacy.is_file() { return Some(legacy); } + } None } +async fn launcher_targets_installation(launcher: &Path, root: &Path) -> bool { + let mut command = Command::new(launcher); + command.arg("--version").current_dir(root).kill_on_drop(true); + #[cfg(windows)] + command.creation_flags(0x0800_0000); + let Ok(Ok(output)) = tokio::time::timeout(Duration::from_secs(15), command.output()).await else { return false; }; + if !output.status.success() { return false; } + let stdout = String::from_utf8_lossy(&output.stdout); + let Some(reported) = stdout.lines().find_map(|line| line.strip_prefix("Install directory: ")) else { return false; }; + match (std::fs::canonicalize(reported.trim()), std::fs::canonicalize(root)) { + (Ok(actual), Ok(expected)) => actual == expected, + _ => false, + } +} + fn update_child_env(install_root: &Path) -> Vec<(String, OsString)> { let hermes_home = crate::paths::hermes_home(); let mut envs = vec![( @@ -1052,31 +897,10 @@ fn update_child_env(install_root: &Path) -> Vec<(String, OsString)> { "HERMES_UPDATE_HANDOFF_PID".to_string(), OsString::from(std::process::id().to_string()), )); - if let Some(path) = path_with_prepended_entries(&[ - hermes_home.join("node").join("bin"), - venv_bin_dir(install_root), - ]) { - envs.push(("PATH".to_string(), path)); - } + envs.push(("HERMES_INSTALL_ROOT".to_string(), install_root.as_os_str().to_os_string())); envs } -fn venv_bin_dir(install_root: &Path) -> PathBuf { - if cfg!(target_os = "windows") { - install_root.join("venv").join("Scripts") - } else { - install_root.join("venv").join("bin") - } -} - -fn path_with_prepended_entries(entries: &[PathBuf]) -> Option { - let mut parts: Vec = entries.to_vec(); - if let Some(existing) = env::var_os("PATH") { - parts.extend(env::split_paths(&existing)); - } - env::join_paths(parts).ok() -} - fn update_branch_from_args(args: I) -> Option where I: IntoIterator, @@ -1352,12 +1176,21 @@ fn emit_log(app: &AppHandle, stage: Option<&str>, stream: LogStream, line: &str) mod tests { use super::*; - #[test] - fn venv_hermes_is_under_install_root() { - let root = Path::new("/x/hermes-agent"); - let shim = venv_hermes(root); - assert!(shim.starts_with(root)); - assert!(shim.to_string_lossy().contains("venv")); + #[tokio::test] + async fn launcher_resolution_is_installation_bound() { + let root = unique_tmp_dir("launcher"); + let legacy = root.join("venv").join(if cfg!(windows) { "Scripts" } else { "bin" }) + .join(if cfg!(windows) { "hermes.exe" } else { "hermes" }); + std::fs::create_dir_all(legacy.parent().unwrap()).unwrap(); + std::fs::write(&legacy, "old").unwrap(); + assert_eq!(resolve_hermes(&root).await, Some(legacy)); + std::fs::create_dir(root.join("pm")).unwrap(); + assert_eq!(resolve_hermes(&root).await, None, "PM must never fall back to the old venv"); + let launcher = root.join(".hermes/bin").join(if cfg!(windows) { "hermes.cmd" } else { "hermes" }); + std::fs::create_dir_all(launcher.parent().unwrap()).unwrap(); + std::fs::write(&launcher, "new").unwrap(); + assert_eq!(resolve_hermes(&root).await, Some(launcher)); + std::fs::remove_dir_all(root).unwrap(); } #[test] @@ -1391,10 +1224,8 @@ mod tests { let root = Path::new("/x/hermes-agent"); let probes = install_lock_probe_paths(root); - assert!( - probes.iter().any(|p| p == &venv_hermes(root)), - "venv shim remains part of the update lock probe" - ); + assert!(probes.iter().all(|p| p.starts_with(root.join("apps/desktop/release"))), + "only replaced application outputs belong to the update lock set"); assert!( // Windows/Linux payloads live under `resources/`, the macOS bundle // under `Contents/Resources/` — Path::ends_with is case-sensitive. @@ -1414,21 +1245,6 @@ mod tests { assert!(locked_paths(&probes).is_empty()); } - #[test] - fn same_windows_path_accepts_case_only_difference() { - assert!(same_windows_path( - Path::new(r"C:\Users\tester\.hermes\hermes-agent\venv\scripts\HERMES.EXE"), - Path::new(r"c:\users\tester\.hermes\hermes-agent\venv\Scripts\hermes.exe"), - )); - } - - #[test] - fn same_windows_path_rejects_desktop_binary() { - assert!(!same_windows_path( - Path::new(r"C:\Users\tester\.hermes\hermes-agent\apps\desktop\Hermes.exe"), - Path::new(r"C:\Users\tester\.hermes\hermes-agent\venv\Scripts\hermes.exe"), - )); - } #[test] fn update_marker_guard_writes_then_removes_on_drop() { diff --git a/apps/desktop/electron/gateway-stop-before-update.test.ts b/apps/desktop/electron/gateway-stop-before-update.test.ts index 91c6275ae2..db32e847de 100644 --- a/apps/desktop/electron/gateway-stop-before-update.test.ts +++ b/apps/desktop/electron/gateway-stop-before-update.test.ts @@ -4,7 +4,6 @@ import { test } from 'vitest' import { GATEWAY_STOP_TIMEOUT_MS, - startGatewaysAfterUpdateAbort, stopGatewayBeforeUpdate } from './gateway-stop-before-update' @@ -97,34 +96,3 @@ test('passes a generous timeout with hidden console (taskkill window suppression encoding: 'utf8' }) }) - -test('abort-path counterpart invokes "gateway start --all" (drain-semantics restore)', () => { - let seenArgs: string[] = [] - - const ran = startGatewaysAfterUpdateAbort(CLI, { - isWindows: true, - existsSync: () => true, - execFileSync: ((_c: string, args: string[]) => { - seenArgs = args - - return Buffer.from('') - }) as never - }) - - assert.equal(ran, true) - assert.deepEqual(seenArgs, ['gateway', 'start', '--all']) -}) - -test('abort-path counterpart is a no-op off Windows', () => { - const calls: Array<[string, string[]]> = [] - - const ran = startGatewaysAfterUpdateAbort(CLI, { - isWindows: false, - existsSync: () => true, - execFileSync: fakeExec(true) as never, - spy: (c, a) => calls.push([c, a]) - }) - - assert.equal(ran, false) - assert.deepEqual(calls, []) -}) diff --git a/apps/desktop/electron/gateway-stop-before-update.ts b/apps/desktop/electron/gateway-stop-before-update.ts index 190799a863..6661d59ccd 100644 --- a/apps/desktop/electron/gateway-stop-before-update.ts +++ b/apps/desktop/electron/gateway-stop-before-update.ts @@ -1,24 +1,4 @@ -/** - * gateway-stop-before-update.ts - * - * Windows-only helper for the update hand-off (#70337): stop every - * separately-running messaging gateway BEFORE the venv-shim lock poll. - * - * Why not just tree-kill gateway.pid's PID: - * - gateway.pid records the uv WORKER process, but the venv shim lock is - * held by its parent LAUNCHER (venv\Scripts\python.exe). taskkill /T from - * the worker PID does not reach parents, so the lock could survive. - * - a single gateway.pid read misses multi-profile setups entirely. - * - * So we delegate to `hermes gateway stop --all`: the CLI discovers every - * profile's gateway processes (launcher + worker) via find_gateway_pids, - * drains in-flight agents (planned-stop marker -> resume_pending), and - * force-kills survivors — the same logic `hermes update`'s - * _pause_windows_gateways_for_update relies on. - * - * Pure + dependency-injected so the launcher/worker and multi-profile - * behavior is assertable without booting Electron. - */ +/** Windows uninstall delegates fleet draining to the CLI before deleting files. */ import { execFileSync, type ExecFileSyncOptionsWithStringEncoding } from 'node:child_process' import fs from 'node:fs' @@ -36,13 +16,7 @@ export interface StopGatewayBeforeUpdateDeps { export const GATEWAY_STOP_TIMEOUT_MS = 20_000 -/** - * Best-effort stop of all-profile messaging gateways via the CLI. - * Never throws: a wedged/absent CLI must not abort the update hand-off - * (the shim-lock poll + the updater's venv-blocker scan still fail loudly - * if the venv stays held). Returns true when the CLI ran (or was invoked - * with the injected spy), false when skipped (non-Windows / missing CLI). - */ +/** Best-effort all-profile drain for uninstall. The deletion lock gate follows. */ export function stopGatewayBeforeUpdate( hermesCliPath: string, hermesHome: string, @@ -51,17 +25,6 @@ export function stopGatewayBeforeUpdate( return runGatewayLifecycleCommand(hermesCliPath, ['gateway', 'stop', '--all'], deps) } -/** - * Drain-semantics counterpart (#76057 review): `gateway stop --all` before - * the lock gate takes gateways down even when the update later ABORTS - * (venv-blocked by a user terminal, probe failure, updater spawn failure). - * The updater's own pause machinery resumes what it pauses — the Desktop - * must mirror that on its abort paths, or a failed update strands every - * profile's gateway stopped. Best-effort, never throws. - */ -export function startGatewaysAfterUpdateAbort(hermesCliPath: string, deps: StopGatewayBeforeUpdateDeps = {}): boolean { - return runGatewayLifecycleCommand(hermesCliPath, ['gateway', 'start', '--all'], deps) -} function runGatewayLifecycleCommand(hermesCliPath: string, args: string[], deps: StopGatewayBeforeUpdateDeps): boolean { const isWindows = deps.isWindows ?? process.platform === 'win32' diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 85a15e5337..69554ade9e 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -212,7 +212,7 @@ import { resolveGatewayFileBackend, writeBufferToFile } from './gateway-file-download' -import { startGatewaysAfterUpdateAbort, stopGatewayBeforeUpdate } from './gateway-stop-before-update' +import { stopGatewayBeforeUpdate } from './gateway-stop-before-update' import { resolveGatewayVersion } from './gateway-version' import { probeGatewayWebSocket } from './gateway-ws-probe' import { registerGitIpc } from './git-ipc' @@ -414,11 +414,13 @@ import { readLiveUpdateMarker, updateHandoffConflict, writeUpdateMarker } from ' import { isOfficialSshRemote, OFFICIAL_REPO_HTTPS_URL } from './update-remote' import { resolveUpdaterMechanism, + type UpdaterApplyResultWire, type UpdaterStatusWire, type UpdaterStrategy } from './updater' import { observeUpdaterHandoff, + resolveInstallationLauncher, resolveStagedUpdaterBinary, spawnUpdaterProcess, stagedUpdaterSupportsPrewrittenMarker @@ -733,12 +735,6 @@ const HERMES_HOME: string = resolveDesktopHermesHome({ readWindowsHome: (): string | null => readWindowsUserEnvVar('HERMES_HOME') }) -// The spawned `hermes update` / updater children compose managed-tool env -// (node, git, uv) in-process via pm. Electron only prepends the explicit -// entries the caller names (the venv bin for the hermes shim itself). -function pathWithVenvBin(...entries) { - return [...entries, process.env.PATH].filter(Boolean).join(path.delimiter) -} // ACTIVE_HERMES_ROOT — the canonical mutable Hermes install. Same path // install.ps1 / install.sh use, so a desktop-only user and a CLI-only user end @@ -3213,13 +3209,11 @@ function resolveCheckoutUpdateStrategy(): UpdaterStrategy { isGitCheckout, updateCheckCachePath: path.join(app.getPath('userData'), 'update-check-cache.json'), writeFileAtomic, - pathWithVenvBin, - venvHermesShimPath, + emitUpdateProgress, rememberLog, startHermes, - startGatewaysAfterUpdateAbort, - releaseBackendLockForUpdate, + stopBackendsForUpdate, repairMacUpdaterHelper, preflightStateDb, runningAppBundle, @@ -3397,8 +3391,8 @@ function isShimLocked(shimPath) { // exe under venv\Scripts AND cmdline referencing hindsight_api.main). The // daemon is spawned DETACHED, so it outlives the backend tree-kill and keeps // venv files mapped. External holders (a user terminal running `hermes`, -// unrelated scripts) are NOT killed — scanVenvBlockers reports them and the -// hand-off aborts, per existing design. Selection lives in the pure +// unrelated scripts) are NOT killed. The uninstall lock probe refuses a +// held installation. Selection lives in the pure // venv-holder-select module (ordinal path-prefix, no PowerShell -like // wildcard hazards) so it's testable without Electron. function killHermesOwnedVenvDaemons(updateRoot) { @@ -3427,7 +3421,7 @@ function killHermesOwnedVenvDaemons(updateRoot) { isHermesOwnedVenvDaemon(p?.ExecutablePath, p?.CommandLine, scriptsDir) ) } catch { - // Best-effort: the venv-blocker scan downstream is the real backstop. + // Best-effort: the uninstall lock probe remains the backstop. return } @@ -3740,36 +3734,20 @@ function reapOrphanedBackendsOnce() { return backendOrphanReapPromise } -// Before handing off the update on Windows, the desktop MUST stop every backend -// it spawned and WAIT for the venv shim to actually unlock. The old code did -// `hermesProcess.kill('SIGTERM')` + `app.quit()` fire-and-forget: SIGTERM on -// Windows doesn't reap the backend's grandchildren, and quit didn't wait for -// teardown, so the updater raced a still-locked `hermes.exe`, the quarantine -// rename failed, uv's `pip install` hit "Access is denied", and the git path -// bailed into a full ZIP re-download that ALSO couldn't write the locked shim — -// a half-applied install (ryanc's update.log). Here we tree-kill the primary + -// pool backends and poll the shim until it's writable (or a bounded timeout), -// so by the time we spawn the updater the lock is genuinely gone. -// -// Windows-only: the venv-shim mandatory lock is a Windows phenomenon. On -// macOS/Linux there's no REPLACE-on-running-exe block, the existing before-quit -// SIGTERM + app.quit() teardown already works (the macOS path is flawless), and -// aggressively SIGKILL-ing the backend here would be an untested behavior change -// for no benefit. So we no-op off Windows and leave that path exactly as it was. -async function releaseBackendLockForUpdate(updateRoot) { - return releaseBackendLock(updateRoot, 'updates') +// Stop app-owned Windows backends before replacing application outputs. +// PM generations can retain live readers. Gateway draining/restart belongs to +// `hermes update`; neither venv scans nor a second fleet stop belong here. +async function stopBackendsForUpdate(): Promise { + if (IS_WINDOWS) { + stopBackendTreesForUpdate(backendConnectionState.getProcess(), { + forceKillProcessTree, + stopAllPoolBackends + }) + } } -// Shared backend teardown + venv-shim unlock wait. Used by BOTH the self-update -// hand-off and the desktop uninstaller — they have the identical Windows -// problem: the desktop's backend (and the grandchildren IT spawned — a hermes -// REPL, a pty terminal, the gateway) keep `hermes.exe` and other files in the -// venv mandatory-locked, so any in-place replace/delete of the install tree -// races a live handle and half-fails (#37532). We tree-kill every backend PID -// the desktop owns, then poll the shim until it's genuinely writable. -// -// `tag` only flavors the log lines. No-op off Windows (POSIX has no mandatory -// locks — the before-quit SIGTERM + the cleanup script's own PID-wait suffice). +// Uninstall still deletes the installation and its historical venv. Unlike +// generation updates, deletion must wait for those old files to be released. async function releaseBackendLock(updateRoot, tag) { if (!IS_WINDOWS) { return { unlocked: true } @@ -3800,18 +3778,8 @@ async function releaseBackendLock(updateRoot, tag) { stopAllPoolBackends }) - // Stop separately-running messaging gateways (all profiles) BEFORE the - // release gate. The gateway is launched by the gateway-launcher desktop - // plugin via /api/gateway/start and is NOT in backendConnectionState or - // backendPool, so the tree-kills above never see it — on Windows its - // launcher (venv\Scripts\python.exe) keeps the venv mandatory-locked and - // the 15s gate aborts the hand-off before the venv-blocker scan's - // pausable-gateway exemption ever gets a chance (#70337). Delegate to - // `hermes gateway stop --all`: the CLI discovers every profile's gateway - // (launcher + worker — gateway.pid records only the uv WORKER, and - // taskkill /T from the worker never reaches its parent), drains in-flight - // agents, and force-kills survivors. Best-effort; abort paths restore via - // startGatewaysAfterUpdateAbort. No-op off Windows. + // Uninstall deletes the whole runtime. Drain separately-running gateways + // through the CLI, rather than targeting a gateway worker by PID. stopGatewayBeforeUpdate(venvHermesShimPath(updateRoot), HERMES_HOME) // Reap Hermes-OWNED venv daemons the tree-kill above cannot reach: the @@ -3881,7 +3849,7 @@ async function releaseBackendLock(updateRoot, tag) { // // Detection (checkUpdates / commit changelog / "N behind") stays in the UI; // only this apply action changed. -async function applyUpdates(opts: { stopSafeBlockers?: boolean } = {}) { +async function applyUpdates(): Promise { if (updateInFlight) { throw new Error('An update is already in progress.') } @@ -3891,7 +3859,7 @@ async function applyUpdates(opts: { stopSafeBlockers?: boolean } = {}) { let handedOff = false try { - const result = await strategy.apply(opts) + const result: UpdaterApplyResultWire = await strategy.apply() handedOff = result.handedOff === true return result @@ -3947,33 +3915,19 @@ async function handOffWindowsBootstrapRecovery(reason) { ? await resolveHealedBranch(updateRoot, configuredBranch || DEFAULT_UPDATE_BRANCH) : configuredBranch || DEFAULT_UPDATE_BRANCH - const venvBin = path.join(updateRoot, 'venv', IS_WINDOWS ? 'Scripts' : 'bin') - const venvHermes = path.join(venvBin, IS_WINDOWS ? 'hermes.exe' : 'hermes') - const venvPython = path.join(venvBin, IS_WINDOWS ? 'python.exe' : 'python') - - // The updater invokes the venv's Hermes launcher, which in turn requires the - // venv interpreter. A bootstrap-complete marker proves only that setup once - // finished; it can outlive a manually removed or quarantined venv. Sending a - // marker-only install through --update dead-ends at "Could not find the hermes - // CLI" instead of rebuilding the runtime, so only a runnable pair gets the - // gentle update path. Partial or missing runtimes go through full repair. - const updaterArgs = chooseUpdaterArgs( - { - hasBootstrapMarker: fileExists(path.join(updateRoot, '.hermes-bootstrap-complete')), - hasVenvHermes: fileExists(venvHermes), - hasVenvPython: fileExists(venvPython) - }, + const updaterArgs: string[] = chooseUpdaterArgs( + { runtimeUsable: isSourceRuntimeUsable(updateRoot) }, branch ) - await releaseBackendLockForUpdate(updateRoot) + await stopBackendsForUpdate() const child = spawnUpdaterProcess(updater, updaterArgs, { cwd: HERMES_HOME, env: { ...process.env, HERMES_HOME, - PATH: pathWithVenvBin(venvBin) + HERMES_INSTALL_ROOT: updateRoot }, detached: true, stdio: 'ignore' @@ -4160,19 +4114,19 @@ function readBootstrapMarker() { // or a DMG launch over a prior CLI install satisfies this WITHOUT the desktop // ever having written the bootstrap marker -- so we must be able to recognise // "already installed" off the filesystem alone, not just the marker. -function isActiveRuntimeUsable() { - const venvPython = getVenvPython(VENV_ROOT) +function isSourceRuntimeUsable(root: string): boolean { + const launcher: string | null = resolveInstallationLauncher(root, IS_WINDOWS, HERMES_HOME) - return ( - isHermesSourceRoot(ACTIVE_HERMES_ROOT) && - fileExists(venvPython) && - canImportHermesCli(venvPython, { - cwd: ACTIVE_HERMES_ROOT, - env: { HERMES_HOME } - }) + return isHermesSourceRoot(root) && launcher !== null && verifyHermesCli( + isCommandScript(launcher) ? `"${launcher}"` : launcher, + { shell: isCommandScript(launcher) } ) } +function isActiveRuntimeUsable(): boolean { + return isSourceRuntimeUsable(ACTIVE_HERMES_ROOT) +} + function 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 @@ -15510,7 +15464,7 @@ ipcMain.handle('hermes:connections:update-all', async (_event, payload) => { if (connection.kind === 'local') { // The app-managed runtime updates through the same pipeline as the // Settings → Updates button (marker + venv gate + relaunch flow). - const result: any = await applyUpdates({}) + const result: any = await applyUpdates() return { ...base, ok: result?.ok !== false, detail: result?.message || 'update started' } } @@ -17183,7 +17137,7 @@ ipcMain.handle('hermes:updates:check', async (_event: Electron.IpcMainInvokeEven ) ipcMain.handle('hermes:updates:apply', async (_event, payload) => - applyUpdates(payload || {}).catch(error => ({ + applyUpdates().catch(error => ({ ok: false, error: 'apply-failed', message: error?.message || String(error) diff --git a/apps/desktop/electron/update-prerequisites.test.ts b/apps/desktop/electron/update-prerequisites.test.ts index 4e63b3e7ec..0d5256c9a3 100644 --- a/apps/desktop/electron/update-prerequisites.test.ts +++ b/apps/desktop/electron/update-prerequisites.test.ts @@ -1,29 +1,61 @@ import assert from 'node:assert/strict' -import { mkdirSync, mkdtempSync, rmSync, unlinkSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' +import fs from 'node:fs' +import os from 'node:os' import path from 'node:path' import { test } from 'vitest' -import { windowsUpdatePrerequisiteError } from './updater-process' +import { launcherTargetsInstallation, resolveInstallationLauncher, windowsUpdatePrerequisiteError } from './updater-process' -test('handoff prerequisites fail closed without requiring dependencies, shim or browser UI', () => { - const root = mkdtempSync(path.join(tmpdir(), 'hermes-prerequisites-')) +test('PM update prerequisites use the exact published launcher, not checkout venv files', (): void => { + const root: string = fs.mkdtempSync(path.join(os.tmpdir(), 'update-launcher-')) try { - const python = path.join(root, 'venv', 'Scripts', 'python.exe') - const script = path.join(root, 'scripts', 'desktop-update', 'windows.ps1') - assert.match(windowsUpdatePrerequisiteError(root)!, /python.exe/) - mkdirSync(path.dirname(python), { recursive: true }) - writeFileSync(python, 'file-presence fixture') - assert.equal(windowsUpdatePrerequisiteError(root), null) // legacy flat layout - mkdirSync(path.dirname(script), { recursive: true }) - assert.match(windowsUpdatePrerequisiteError(root)!, /windows.ps1/) - writeFileSync(script, 'file-presence fixture') + fs.mkdirSync(path.join(root, 'pm')) + fs.mkdirSync(path.join(root, '.hermes', 'bin'), { recursive: true }) + const launcher: string = path.join(root, '.hermes', 'bin', 'hermes.cmd') + fs.writeFileSync(launcher, '@echo off') + assert.equal(resolveInstallationLauncher(root, true), launcher) assert.equal(windowsUpdatePrerequisiteError(root), null) - unlinkSync(python) - assert.match(windowsUpdatePrerequisiteError(root)!, /python.exe/) + const scripts: string = path.join(root, 'scripts', 'desktop-update') + fs.mkdirSync(scripts, { recursive: true }) + fs.writeFileSync(path.join(scripts, 'windows.ps1'), '') + assert.equal(windowsUpdatePrerequisiteError(root), null) // old handoff is a manual transition + fs.writeFileSync(path.join(scripts, 'runtime.ps1'), '') + assert.equal(windowsUpdatePrerequisiteError(root), null) + fs.unlinkSync(launcher) + assert.equal(resolveInstallationLauncher(root, true), null) } finally { - rmSync(root, { recursive: true, force: true }) + fs.rmSync(root, { recursive: true, force: true }) + } +}) + + +test('earlier PM user-bin launchers are accepted only for the reported source tree', (): void => { + const base: string = fs.mkdtempSync(path.join(os.tmpdir(), 'old-pm-launcher-')) + + try { + const root: string = path.join(base, 'checkout') + const other: string = path.join(base, 'other') + const home: string = path.join(base, 'home') + fs.mkdirSync(path.join(root, 'hermes_cli'), { recursive: true }) + fs.mkdirSync(path.join(root, 'pm')) + fs.mkdirSync(other) + fs.writeFileSync(path.join(root, 'hermes_cli', '_launchers.py'), '') + fs.mkdirSync(path.join(home, 'bin'), { recursive: true }) + const launcher: string = path.join(home, 'bin', process.platform === 'win32' ? 'hermes.cmd' : 'hermes') + + const body = (reported: string): string => process.platform === 'win32' + ? `@echo off\r\necho Install directory: ${reported}\r\n` + : `#!/bin/sh\nprintf '%s\\n' 'Install directory: ${reported}'\n` + + fs.writeFileSync(launcher, body(root), { mode: 0o755 }) + assert.equal(launcherTargetsInstallation(launcher, root), true) + assert.equal(resolveInstallationLauncher(root, process.platform === 'win32', home), launcher) + fs.writeFileSync(launcher, body(other), { mode: 0o755 }) + assert.equal(launcherTargetsInstallation(launcher, root), false) + assert.equal(resolveInstallationLauncher(root, process.platform === 'win32', home), null) + } finally { + fs.rmSync(base, { recursive: true, force: true }) } }) diff --git a/apps/desktop/electron/updater-process.ts b/apps/desktop/electron/updater-process.ts index 04eef34f64..4ff624e605 100644 --- a/apps/desktop/electron/updater-process.ts +++ b/apps/desktop/electron/updater-process.ts @@ -1,25 +1,83 @@ -import { spawn, type SpawnOptions } from 'node:child_process' -import { existsSync, statSync } from 'node:fs' +import { execFileSync, spawn, type SpawnOptions } from 'node:child_process' +import { existsSync, realpathSync, statSync } from 'node:fs' +import os from 'node:os' import path from 'node:path' import { hiddenWindowsChildOptions } from './windows-child-options' -/** File prerequisites only: dependency recovery must remain reachable through update. */ -export function windowsUpdatePrerequisiteError(updateRoot: string): string | null { - const maintainedDir = path.join(updateRoot, 'scripts', 'desktop-update') - const required = [path.join(updateRoot, 'venv', 'Scripts', 'python.exe')] +/** Exact installation identity; PATH may refer to another checkout. */ +export function resolveInstallationLauncher(updateRoot: string, isWindows: boolean = process.platform === 'win32', hermesHome: string = process.env.HERMES_HOME ?? ''): string | null { + const names: string[] = isWindows ? ['hermes.exe', 'hermes.cmd'] : ['hermes'] - // Pre-reorg flat scripts remain supported; damaged modern trees do not. - if (existsSync(maintainedDir)) { - required.push(path.join(maintainedDir, 'windows.ps1')) + for (const name of names) { + const candidate: string = path.join(updateRoot, '.hermes', 'bin', name) + + if (stagedFileExists(candidate)) { return candidate } } - for (const candidate of required) { - if (stagedFileExists(candidate)) { - continue - } + // Earlier PM installers published only to user-bin. Trust that historical + // launcher only after its existing version surface proves exact source identity. + if (stagedFileExists(path.join(updateRoot, 'hermes_cli', '_launchers.py'))) { + const defaultHome: string = isWindows + ? path.join(process.env.LOCALAPPDATA ?? path.join(os.homedir(), 'AppData', 'Local'), 'hermes') + : path.join(os.homedir(), '.hermes') - return `Update aborted: ${candidate} is missing or unreadable. Repair the installation and review antivirus quarantine before retrying.` + const dirs: string[] = isWindows + ? [path.join(hermesHome || defaultHome, 'bin'), path.join(defaultHome, 'bin'), path.join(path.dirname(updateRoot), 'bin')] + : [path.join(os.homedir(), '.local', 'bin'), path.join(hermesHome || defaultHome, 'bin')] + + for (const dir of new Set(dirs)) { + for (const name of names) { + const candidate: string = path.join(dir, name) + + if (stagedFileExists(candidate) && launcherTargetsInstallation(candidate, updateRoot)) { return candidate } + } + } + } + + // An old shim is a migration rung, never a damaged PM install fallback. + if (!existsSync(path.join(updateRoot, 'pm'))) { + const legacy: string = path.join(updateRoot, 'venv', isWindows ? 'Scripts' : 'bin', names[0]) + + if (stagedFileExists(legacy)) { return legacy } + } + + return null +} + +export function launcherTargetsInstallation(launcher: string, root: string): boolean { + try { + const shell: boolean = process.platform === 'win32' && /\.cmd$/i.test(launcher) + + const output: string = execFileSync(shell ? `"${launcher}"` : launcher, ['--version'], { + cwd: root, encoding: 'utf8', timeout: 15000, windowsHide: true, shell, + env: { ...process.env, HERMES_INSTALL_ROOT: root } + }) + + const reported: string | undefined = /^Install directory: (.+)$/m.exec(output)?.[1]?.trim() + + return reported !== undefined && realpathSync(reported) === realpathSync(root) + } catch { + return false + } +} + +/** File prerequisites only: dependency recovery remains reachable through update. */ +export function windowsUpdatePrerequisiteError(updateRoot: string, hermesHome?: string): string | null { + if (!resolveInstallationLauncher(updateRoot, true, hermesHome)) { + return `Update aborted: the installation launcher under ${updateRoot} is missing. Repair this installation before retrying.` + } + + const maintainedDir: string = path.join(updateRoot, 'scripts', 'desktop-update') + + if (existsSync(maintainedDir)) { + for (const name of ['windows.ps1']) { + const candidate: string = path.join(maintainedDir, name) + + if (!stagedFileExists(candidate)) { + return `Update aborted: ${candidate} is missing or unreadable. Repair the installation and review antivirus quarantine before retrying.` + } + } } return null diff --git a/apps/desktop/electron/updater/app-installer-recovery.test.ts b/apps/desktop/electron/updater/app-installer-recovery.test.ts index 4ad859e6de..668a513571 100644 --- a/apps/desktop/electron/updater/app-installer-recovery.test.ts +++ b/apps/desktop/electron/updater/app-installer-recovery.test.ts @@ -55,13 +55,13 @@ for (const failureAt of ['prepare', 'register', 'teardown', 'open', 'none']) { try { if (failureAt === 'none') { - const result = await new AppInstallerStrategy(deps).apply({}) + const result = await new AppInstallerStrategy(deps).apply() assert.equal(result.ok, true) assert.equal(result.handedOff, true, 'keep backend restart blocked until the quitting app exits') assert.deepEqual(calls, ['prepare', 'register', 'teardown', 'open', 'quit']) assert.equal(fs.existsSync(marker), true) } else { - await assert.rejects(new AppInstallerStrategy(deps).apply({}), error => error === failure) + await assert.rejects(new AppInstallerStrategy(deps).apply(), error => error === failure) assert.equal(running, true) assert.equal(calls.includes('quit'), false) assert.equal(calls.includes('restore'), ['teardown', 'open'].includes(failureAt)) @@ -96,7 +96,7 @@ test('handoff errors retain cleanup failures while still restoring the backend', } try { - await assert.rejects(new AppInstallerStrategy(deps).apply({}), error => { + await assert.rejects(new AppInstallerStrategy(deps).apply(), error => { assert.ok(error instanceof AggregateError) assert.equal(error.cause, original) assert.equal(error.errors[0], original) diff --git a/apps/desktop/electron/updater/app-installer-strategy.test.ts b/apps/desktop/electron/updater/app-installer-strategy.test.ts index c169e28a18..aa9ccfe373 100644 --- a/apps/desktop/electron/updater/app-installer-strategy.test.ts +++ b/apps/desktop/electron/updater/app-installer-strategy.test.ts @@ -43,7 +43,7 @@ describe('AppInstallerStrategy.apply', () => { it('writes the relaunch marker before teardown, trigger, and quit — order is the contract', async () => { const { deps, calls } = makeDeps() const strategy = new AppInstallerStrategy(deps) - const result = await strategy.apply({}) + const result = await strategy.apply() expect(result).toEqual({ ok: true, manual: false, bundled: true, handedOff: true, mechanism: 'app-installer' }) expect(calls).toEqual(['prepare', 'relaunch-marker', 'teardown', 'open', 'quit']) @@ -57,7 +57,7 @@ describe('AppInstallerStrategy.apply', () => { emitUpdateProgress: event => { progress.push(event.message) } }) - const result = await new AppInstallerStrategy(deps).apply({}) + const result = await new AppInstallerStrategy(deps).apply() expect(result.ok).toBe(true) expect(calls).toContain('quit') expect(progress.some(message => message.includes('Reopen Hermes'))).toBe(true) @@ -77,14 +77,14 @@ describe('AppInstallerStrategy.apply', () => { } }) - expect((await new AppInstallerStrategy(deps).apply({})).manual).toBe(false) + expect((await new AppInstallerStrategy(deps).apply()).manual).toBe(false) expect(prepared).toEqual(['https://registered.example/channel.appinstaller']) expect(calls).toEqual(['relaunch-marker', 'teardown', 'quit']) }) it('no feed URL → manual card, no teardown, no quit', async () => { const { deps, calls } = makeDeps({ feedBaseUrl: '' }) - const result = await new AppInstallerStrategy(deps).apply({}) + const result = await new AppInstallerStrategy(deps).apply() expect(result).toEqual({ ok: true, manual: true, bundled: true, mechanism: 'app-installer' }) expect(calls).toEqual([]) }) diff --git a/apps/desktop/electron/updater/app-installer.ts b/apps/desktop/electron/updater/app-installer.ts index 2a5a6de545..edaba628b8 100644 --- a/apps/desktop/electron/updater/app-installer.ts +++ b/apps/desktop/electron/updater/app-installer.ts @@ -81,7 +81,7 @@ export class AppInstallerStrategy { return appInstallerCheckToStatus(check, this.deps.appVersion) } - async apply(_opts: { stopSafeBlockers?: boolean }): Promise { + async apply(): Promise { const feedBaseUrl = this.deps.feedBaseUrl let sourceUri: string | undefined diff --git a/apps/desktop/electron/updater/checkout-legacy.test.ts b/apps/desktop/electron/updater/checkout-legacy.test.ts index 4dc0210068..2c0977a6ef 100644 --- a/apps/desktop/electron/updater/checkout-legacy.test.ts +++ b/apps/desktop/electron/updater/checkout-legacy.test.ts @@ -33,10 +33,9 @@ it('offers manual recovery only for a missing source probe, never for a broken p runGit: async (args: string[]): Promise<{ code: number; stdout: string; stderr: string }> => ({ code: 0, stdout: args.includes('--abbrev-ref') ? 'feature/work' : args.includes('HEAD') ? 'a'.repeat(40) : '', stderr: '' }), - firstLine: (text: string): string => text.split('\n')[0], pathWithVenvBin: (): string => '', - venvHermesShimPath: (): string => '', emitUpdateProgress: vi.fn(), rememberLog: vi.fn(), - startHermes: vi.fn(async (): Promise => {}), startGatewaysAfterUpdateAbort: vi.fn(), - releaseBackendLockForUpdate: vi.fn(async (): Promise<{ unlocked: boolean }> => ({ unlocked: true })), + firstLine: (text: string): string => text.split('\n')[0], emitUpdateProgress: vi.fn(), rememberLog: vi.fn(), + startHermes: vi.fn(async (): Promise => {}), + stopBackendsForUpdate: vi.fn(async (): Promise => {}), repairMacUpdaterHelper: vi.fn(), preflightStateDb: vi.fn(), runningAppBundle: (): null => null, markQuittingForHandoff: vi.fn(), quit: vi.fn() } @@ -49,21 +48,21 @@ it('offers manual recovery only for a missing source probe, never for a broken p else { fs.rmSync(modulePath) } expect(await strategy.check()).toMatchObject({ supported: false, reason: 'source-probe-unavailable' }) - const result: Awaited> = await strategy.apply({}) + const result: Awaited> = await strategy.apply() expect(result).toMatchObject({ manual: true, command: 'hermes update --help' }) expect(result.message).toContain('branch or channel') expect(result.command).not.toContain('--branch') - expect(deps.releaseBackendLockForUpdate).not.toHaveBeenCalled() + expect(deps.stopBackendsForUpdate).not.toHaveBeenCalled() expect(deps.resolveUpdaterBinary).not.toHaveBeenCalled() expect(deps.fetchGitHubApi).not.toHaveBeenCalled() expect(deps.quit).not.toHaveBeenCalled() } fs.writeFileSync(modulePath, 'def main():\n raise RuntimeError("invalid channel configuration")\n') - await expect(strategy.apply({})).rejects.toThrow('invalid channel configuration') + await expect(strategy.apply()).rejects.toThrow('invalid channel configuration') fs.writeFileSync(modulePath, 'import missing_probe_dependency\n') await expect(probe()).rejects.toThrow('missing_probe_dependency') - expect(deps.releaseBackendLockForUpdate).not.toHaveBeenCalled() + expect(deps.stopBackendsForUpdate).not.toHaveBeenCalled() } finally { fs.rmSync(root, { recursive: true, force: true }) } diff --git a/apps/desktop/electron/updater/checkout-ownership.test.ts b/apps/desktop/electron/updater/checkout-ownership.test.ts index 21b6b34e71..9328b14bc8 100644 --- a/apps/desktop/electron/updater/checkout-ownership.test.ts +++ b/apps/desktop/electron/updater/checkout-ownership.test.ts @@ -23,14 +23,10 @@ function dependencies(): CheckoutStrategyDeps { getOriginUrl: async () => '', runGit: vi.fn(async () => { throw new Error('unexpected git invocation') }), firstLine: text => text.split('\n')[0], - - pathWithVenvBin: () => '', - venvHermesShimPath: () => '', emitUpdateProgress: vi.fn(), rememberLog: vi.fn(), startHermes: vi.fn(async () => {}), - startGatewaysAfterUpdateAbort: vi.fn(), - releaseBackendLockForUpdate: vi.fn(async () => ({ unlocked: true })), + stopBackendsForUpdate: vi.fn(async (): Promise => {}), repairMacUpdaterHelper: vi.fn(), preflightStateDb: vi.fn(), runningAppBundle: () => null, @@ -47,11 +43,11 @@ describe('checkout update admission', () => { const result = await strategy.check() expect(result.supported).toBe(false) - expect(await strategy.apply({})).toMatchObject({ ok: false }) + expect(await strategy.apply()).toMatchObject({ ok: false }) expect(deps.readSourceUpdate).not.toHaveBeenCalled() expect(result.mechanism).toBe(strategy.mechanism) expect(deps.runGit).not.toHaveBeenCalled() - expect(deps.releaseBackendLockForUpdate).not.toHaveBeenCalled() + expect(deps.stopBackendsForUpdate).not.toHaveBeenCalled() expect(deps.quit).not.toHaveBeenCalled() }) diff --git a/apps/desktop/electron/updater/checkout-source.test.ts b/apps/desktop/electron/updater/checkout-source.test.ts index f9429ef9a5..b50cee461a 100644 --- a/apps/desktop/electron/updater/checkout-source.test.ts +++ b/apps/desktop/electron/updater/checkout-source.test.ts @@ -9,7 +9,6 @@ import { promisify } from 'node:util' import { expect, it, vi } from 'vitest' import * as updaterProcess from '../updater-process' -import * as blockers from '../venv-blocker-scan' import { type CheckoutStrategyDeps, createCheckoutStrategy } from './checkout' import { readSourceUpdate, type SourceUpdate } from './checkout-source' @@ -168,13 +167,12 @@ import urllib.request\nfrom urllib.parse import urlsplit\noriginal = urllib.requ directoryExists: fs.existsSync, resolveUpdaterBinary: (): null => null, firstLine: (text: string): string => text.split('\n')[0], - pathWithVenvBin: (): string => process.env.PATH ?? '', - venvHermesShimPath: (): string => '', + emitUpdateProgress: vi.fn(), rememberLog: vi.fn(), startHermes: async (): Promise => {}, - startGatewaysAfterUpdateAbort: (): void => {}, - releaseBackendLockForUpdate: vi.fn(async (): Promise<{ unlocked: boolean }> => ({ unlocked: true })), + + stopBackendsForUpdate: vi.fn(async (): Promise => {}), repairMacUpdaterHelper: (): void => {}, preflightStateDb: (): void => {}, runningAppBundle: (): null => null, @@ -191,14 +189,13 @@ import urllib.request\nfrom urllib.parse import urlsplit\noriginal = urllib.requ return { unref: (): void => {} } } ) - vi.spyOn(blockers, 'scanVenvBlockers').mockResolvedValue({ - kind: 'clear', - result: { blocked: false, processes: [] } - }) + const scriptDirectory: string = path.join(root, 'scripts', 'desktop-update') const script: string = path.join(scriptDirectory, process.platform === 'win32' ? 'windows.ps1' : 'posix.sh') - fs.mkdirSync(path.join(root, 'venv', 'Scripts'), { recursive: true }) - fs.writeFileSync(path.join(root, 'venv', 'Scripts', 'python.exe'), '') + fs.mkdirSync(scriptDirectory, { recursive: true }) + fs.writeFileSync(path.join(scriptDirectory, 'runtime.ps1'), '') + fs.mkdirSync(path.join(root, '.hermes', 'bin'), { recursive: true }) + fs.writeFileSync(path.join(root, '.hermes', 'bin', 'hermes.exe'), '') for (const channel of ['stable', 'canary'] as const) { await setChannel(channel) @@ -213,13 +210,13 @@ import urllib.request\nfrom urllib.parse import urlsplit\noriginal = urllib.requ updateAvailable: true }) fs.rmSync(scriptDirectory, { recursive: true, force: true }) - expect(await strategy.apply({})).toMatchObject({ manual: true, command: `hermes update --channel ${channel}` }) + expect(await strategy.apply()).toMatchObject({ manual: true, command: `hermes update --channel ${channel}` }) deps.resolveUpdaterBinary = (): string => path.join(temporary, 'frozen-updater') - expect(await strategy.apply({})).toMatchObject({ manual: true, command: `hermes update --channel ${channel}` }) + expect(await strategy.apply()).toMatchObject({ manual: true, command: `hermes update --channel ${channel}` }) expect(spawned).toHaveLength(0) fs.mkdirSync(scriptDirectory, { recursive: true }) fs.writeFileSync(script, '') - expect(await strategy.apply({})).toMatchObject({ ok: true, handedOff: true }) + expect(await strategy.apply()).toMatchObject({ ok: true, handedOff: true }) const handoff: (typeof spawned)[number] | undefined = spawned.pop() expect(handoff?.args).toContain(script) expect(handoff?.args).toContain(channel) @@ -244,9 +241,9 @@ import urllib.request\nfrom urllib.parse import urlsplit\noriginal = urllib.requ draft: true, prerelease: true }) - vi.mocked(deps.releaseBackendLockForUpdate).mockClear() - expect(await strategy.apply({})).toMatchObject({ ok: false, error: 'release-unavailable' }) - expect(deps.releaseBackendLockForUpdate).not.toHaveBeenCalled() + vi.mocked(deps.stopBackendsForUpdate).mockClear() + expect(await strategy.apply()).toMatchObject({ ok: false, error: 'release-unavailable' }) + expect(deps.stopBackendsForUpdate).not.toHaveBeenCalled() expect(spawned).toHaveLength(0) await setChannel('main') expect(await readSourceUpdate({ python, git: 'git', updateRoot: root, hermesHome: home })).toEqual({ @@ -258,12 +255,12 @@ import urllib.request\nfrom urllib.parse import urlsplit\noriginal = urllib.requ targetSha: commits[3], updateAvailable: false }) - expect(await strategy.apply({})).toMatchObject({ ok: true, handedOff: true }) + expect(await strategy.apply()).toMatchObject({ ok: true, handedOff: true }) expect(spawned.pop()?.args).toEqual( expect.arrayContaining([process.platform === 'win32' ? '-Branch' : '--branch', 'feature/gui']) ) fs.rmSync(scriptDirectory, { recursive: true, force: true }) - expect(await strategy.apply({})).toMatchObject({ manual: true, command: 'hermes update --branch feature/gui' }) + expect(await strategy.apply()).toMatchObject({ manual: true, command: 'hermes update --branch feature/gui' }) expect(requests).toHaveLength(count) } finally { vi.restoreAllMocks() diff --git a/apps/desktop/electron/updater/checkout.ts b/apps/desktop/electron/updater/checkout.ts index 50bda7ff56..2d748f900c 100644 --- a/apps/desktop/electron/updater/checkout.ts +++ b/apps/desktop/electron/updater/checkout.ts @@ -1,11 +1,13 @@ // Checkout update policy and handoff execution. The shell supplies process and UI dependencies. +import { existsSync } from 'node:fs' import * as path from 'node:path' import { updateHandoffConflict, writeUpdateMarker } from '../update-marker' import { collectRelaunchArgs, observeUpdaterHandoff, + resolveInstallationLauncher, resolvePosixScriptHandoff, resolveUpdateScriptHandoff, sandboxFallbackFromEnv, @@ -14,7 +16,6 @@ import { windowsUpdatePrerequisiteError, wrapHandoffForDetachedConsole } from '../updater-process' -import { formatBlockerMessage, formatProbeFailedMessage, scanVenvBlockers, stopSafeVenvBlockers } from '../venv-blocker-scan' import { checkCheckoutUpdates, type CheckoutCheckDeps } from './checkout-check' import { sourceUpdateEnvironment } from './checkout-source' @@ -34,13 +35,11 @@ export interface CheckoutStrategyDeps extends CheckoutCheckDeps { directoryExists: (filePath: string) => boolean resolveUpdaterBinary: () => string | null firstLine: (text: string) => string - pathWithVenvBin: (...entries: string[]) => string - venvHermesShimPath: (updateRoot: string) => string + emitUpdateProgress: (payload: { stage: string; message: string; percent: number | null }) => void rememberLog: (chunk: unknown) => void startHermes: () => Promise - startGatewaysAfterUpdateAbort: (shimPath: string) => boolean | void | Promise - releaseBackendLockForUpdate: (updateRoot: string) => Promise<{ unlocked: boolean }> + stopBackendsForUpdate: () => Promise repairMacUpdaterHelper: (updater: string) => void | Promise preflightStateDb: (hermesHome: string, rememberLog: (chunk: string) => void) => void runningAppBundle: () => string | null @@ -75,8 +74,8 @@ export function createCheckoutStrategy(deps: CheckoutStrategyDeps): UpdaterStrat return status } - async function apply(opts: { stopSafeBlockers?: boolean }): Promise { - const result = await applyBody(opts) + async function apply(): Promise { + const result: UpdaterApplyResultWire = await applyBody() result.mechanism = mechanism return result @@ -84,7 +83,7 @@ export function createCheckoutStrategy(deps: CheckoutStrategyDeps): UpdaterStrat return { mechanism, check, apply } - async function applyBody(opts: { stopSafeBlockers?: boolean } = {}): Promise { + async function applyBody(): Promise { const status: UpdaterStatusWire = await checkCheckoutUpdates(deps, { force: true }) if (status.reason === 'source-probe-unavailable') { @@ -100,6 +99,23 @@ export function createCheckoutStrategy(deps: CheckoutStrategyDeps): UpdaterStrat const targetLabel: string = status.channel ?? branch const manualCommand: string = status.channel ? `hermes update --channel ${status.channel}` : buildManualUpdateCommand(branch) const updater: string | null = deps.resolveUpdaterBinary() + const root: string = deps.resolveUpdateRoot() + + // Earlier PM scripts still demand checkout/venv. Do not invoke that known + // incompatible handoff: one exact-install CLI update obtains the new scripts. + if (existsSync(path.join(root, 'pm')) && !existsSync(path.join(root, 'scripts', 'desktop-update', 'runtime.ps1'))) { + const launcher: string | null = resolveInstallationLauncher(root, deps.isWindows, deps.hermesHome) + + if (!launcher) { return { ok: false, error: 'installation-launcher-missing' } } + + const quote = (value: string): string => deps.isWindows + ? `'${value.replace(/'/g, "''")}'` + : `'${value.replace(/'/g, "'\\''")}'` + + const command: string = `${deps.isWindows ? '& ' : ''}${quote(launcher)} update ${targetArgs.map(quote).join(' ')}` + + return { ok: true, manual: true, command, hermesRoot: root } + } if (!deps.isWindows && (!updater || status.channel)) { // macOS/Linux: hand off to the repo-owned posix script — same shape as @@ -165,7 +181,6 @@ export function createCheckoutStrategy(deps: CheckoutStrategyDeps): UpdaterStrat updaterArgs.push('--target-app', targetApp) } - const venvBin = path.join(updateRoot, 'venv', deps.isWindows ? 'Scripts' : 'bin') // ── Pre-flight state.db integrity guard (#68474) ───────────────── // Emergency backup and header verification before the update touches @@ -173,7 +188,7 @@ export function createCheckoutStrategy(deps: CheckoutStrategyDeps): UpdaterStrat deps.preflightStateDb(deps.hermesHome, deps.rememberLog) if (deps.isWindows && resolveUpdateScriptHandoff(updateRoot)) { - const message = windowsUpdatePrerequisiteError(updateRoot) + const message = windowsUpdatePrerequisiteError(updateRoot, deps.hermesHome) if (message) { deps.emitUpdateProgress({ stage: 'error', message, percent: null }) @@ -182,102 +197,12 @@ export function createCheckoutStrategy(deps: CheckoutStrategyDeps): UpdaterStrat } } - // Stop our own backend(s) and wait for the venv shim to unlock BEFORE we - // spawn the updater. Without this the updater races a still-locked - // hermes.exe (held by the backend child / its grandchildren) and the update - // bricks. See releaseBackendLockForUpdate for the full failure analysis. - const lock = await deps.releaseBackendLockForUpdate(updateRoot) + // Release app-owned backends for output replacement. PM publishes a new + // dependency generation; old Python readers are not update blockers. + // The CLI owns gateway draining and restart, including failure recovery. + await deps.stopBackendsForUpdate() - if (!lock.unlocked) { - // Something OUTSIDE this app holds the venv (a second window, a user - // terminal running hermes, an unkillable child). Handing off anyway - // guarantees a half-updated venv — abort loudly instead and let the - // user close the holder and retry. Restart our own backend so the app - // keeps working after the failed attempt. - const message = - 'Update aborted: another process is holding the Hermes install open ' + - '(a second Hermes window or a terminal running hermes?). Close it and retry.' - - deps.emitUpdateProgress({ stage: 'error', message, percent: null }) - deps.startHermes().catch(() => {}) - - if (deps.isWindows) { - // The pre-gate `gateway stop --all` (#70337) took every profile's - // gateway down for an update that never happened — bring them back. - deps.startGatewaysAfterUpdateAbort(deps.venvHermesShimPath(updateRoot)) - } - - return { ok: false, error: message } - } - - // Preflight: after releasing our own backends, check for remaining - // Hermes processes running from this venv. The updater normally refuses - // when it detects a holder, but because the updater is spawned detached - // with stdio:ignore, the user never sees that refusal and the update - // silently fails. This preflight detects holders early and gives the - // user an actionable error. Windows-only; the .pyd lock hazard is a - // Windows phenomenon. ALL failures (blocked, missing python, timeout, - // malformed output, missing psutil) abort the handoff — never proceed - // to the detached updater when the venv state is unknown. - if (deps.isWindows) { - let scanOutcome = await scanVenvBlockers(updateRoot) - - if (scanOutcome.kind === 'blocked' && opts.stopSafeBlockers) { - const stopResult = await stopSafeVenvBlockers(updateRoot, scanOutcome.result) - deps.rememberLog( - `[updates] user-approved blocker cleanup: stopped=${stopResult.stopped.join(',') || 'none'} failed=${stopResult.failed.join(',') || 'none'}` - ) - // Let verified process-tree termination finish unwinding wrapper shells, - // then make the scanner — not the stale renderer payload — authoritative. - await new Promise(resolve => setTimeout(resolve, 300)) - scanOutcome = await scanVenvBlockers(updateRoot) - } - - // Re-scan before aborting on 'blocked' (#74805). Process-table teardown - // is asynchronous on Windows: even after releaseBackendLock's PID-exit - // wait, a grandchild the desktop never tracked (or a process an AV / - // NTFS filter driver is holding in teardown) can stay enumerable for a - // few more seconds and read as a holder. Each scan already costs - // seconds (spawns a venv python + psutil sweep), so two retries with a - // short dwell give the table time to settle without meaningfully - // delaying the abort path when a REAL holder (a user terminal, second - // window) is present — that holder is still there on the third scan. - for (let attempt = 0; scanOutcome.kind === 'blocked' && attempt < 2; attempt++) { - deps.rememberLog( - `[updates] venv-blocker scan reported ${scanOutcome.result.processes.length} holder(s); re-scanning after settle (attempt ${attempt + 2}/3)` - ) - await new Promise(resolve => setTimeout(resolve, 1500)) - scanOutcome = await scanVenvBlockers(updateRoot) - } - - if (scanOutcome.kind === 'blocked') { - const message = formatBlockerMessage(scanOutcome.result) - - deps.rememberLog(`[updates] venv-blocked: ${scanOutcome.result.processes.length} process(es) hold the install`) - deps.emitUpdateProgress({ stage: 'error', message, percent: null }) - deps.startHermes().catch(() => {}) - // Restore the gateways the pre-gate stop took down (#70337 drain - // semantics): the update aborted, so nothing else will relaunch them. - deps.startGatewaysAfterUpdateAbort(deps.venvHermesShimPath(updateRoot)) - - return { ok: false, error: 'venv-blocked', message, blockers: scanOutcome.result.processes } - } - - if (scanOutcome.kind === 'probe-failure') { - const message = formatProbeFailedMessage(scanOutcome.error) - - deps.rememberLog(`[updates] venv-blocker probe failed: ${scanOutcome.error}`) - deps.emitUpdateProgress({ stage: 'error', message, percent: null }) - deps.startHermes().catch(() => {}) - // Same drain-semantics restore as the venv-blocked abort above. - deps.startGatewaysAfterUpdateAbort(deps.venvHermesShimPath(updateRoot)) - - return { ok: false, error: 'venv-probe-failed', message } - } - } - - // Detached so the updater outlives this process — it needs us GONE before - // `hermes update` will run (the venv shim is locked while we live). + // Detached so app replacement can outlive this process. // // Prefer the repo-owned hand-off script over the staged Tauri binary. // The staged binary is frozen (no self-update path) and historically runs @@ -314,8 +239,7 @@ export function createCheckoutStrategy(deps: CheckoutStrategyDeps): UpdaterStrat cwd: deps.hermesHome, env: { ...sourceUpdateEnvironment(updateRoot, deps.hermesHome), - HERMES_UPDATE_STARTED_AT: String(updateStartedAt), - PATH: deps.pathWithVenvBin(venvBin) + HERMES_UPDATE_STARTED_AT: String(updateStartedAt) }, detached: true, stdio: 'ignore' @@ -333,14 +257,13 @@ export function createCheckoutStrategy(deps: CheckoutStrategyDeps): UpdaterStrat } deps.rememberLog( - `[updates] launched repo hand-off script: ${scriptHandoff.scriptPath} (${targetLabel}); exiting desktop to release venv shim` + `[updates] launched repo hand-off script: ${scriptHandoff.scriptPath} (${targetLabel}); exiting desktop for application replacement` ) } else { child = spawnUpdaterProcess(updater, updaterArgs, { cwd: deps.hermesHome, env: { - ...sourceUpdateEnvironment(updateRoot, deps.hermesHome), - PATH: deps.pathWithVenvBin(venvBin) + ...sourceUpdateEnvironment(updateRoot, deps.hermesHome) }, detached: true, stdio: 'ignore' @@ -349,8 +272,8 @@ export function createCheckoutStrategy(deps: CheckoutStrategyDeps): UpdaterStrat // Write the update-in-progress marker IMMEDIATELY — before the 2.5s // quit dwell. The Tauri updater won't write its own marker for several // seconds (window init + manifest), and during that gap our renderer - // can reconnect and spawn a fresh backend that re-locks .pyd files in - // the venv. By writing the marker ourselves the renderer's + // can reconnect into an update still replacing application files. + // By writing the marker ourselves the renderer's // waitForUpdateToFinish() gate sees a live update and parks instead. // The updater overwrites this with its own PID later; same format. // @@ -370,13 +293,13 @@ export function createCheckoutStrategy(deps: CheckoutStrategyDeps): UpdaterStrat } deps.rememberLog( - `[updates] launched updater: ${updater} ${updaterArgs.join(' ')}; exiting desktop to release venv shim` + `[updates] launched updater: ${updater} ${updaterArgs.join(' ')}; exiting desktop for application replacement` ) } // Linger on the "updating — don't reopen" overlay long enough for the user // to actually read it (and to bridge the gap until the updater's own window - // appears), THEN quit to release the venv shim. The updater rebuilds and + // appears), THEN quit for application replacement. The updater rebuilds and // relaunches us when it's done. (#50419 — a 600ms quit looked like a crash // and lured users into the #50238 relaunch loop.) // @@ -396,10 +319,6 @@ export function createCheckoutStrategy(deps: CheckoutStrategyDeps): UpdaterStrat deps.emitUpdateProgress({ stage: 'error', message, percent: null }) deps.startHermes().catch(() => {}) - if (deps.isWindows) { - // Same drain-semantics restore as the earlier abort paths (#70337). - deps.startGatewaysAfterUpdateAbort(deps.venvHermesShimPath(updateRoot)) - } return { ok: false, error: 'updater-spawn-failed', message } } @@ -472,8 +391,7 @@ export function createCheckoutStrategy(deps: CheckoutStrategyDeps): UpdaterStrat cwd: deps.hermesHome, env: { ...sourceUpdateEnvironment(updateRoot, deps.hermesHome), - HERMES_UPDATE_STARTED_AT: String(updateStartedAt), - PATH: deps.pathWithVenvBin(path.join(updateRoot, 'venv', 'bin')) + HERMES_UPDATE_STARTED_AT: String(updateStartedAt) }, detached: true, stdio: 'ignore' diff --git a/apps/desktop/electron/updater/commit-build.test.ts b/apps/desktop/electron/updater/commit-build.test.ts index a3c1445868..b269a992a9 100644 --- a/apps/desktop/electron/updater/commit-build.test.ts +++ b/apps/desktop/electron/updater/commit-build.test.ts @@ -21,9 +21,9 @@ describe('one-commit artifacts', (): void => { expect(await strategy.check({ force: true })).toMatchObject({ supported: false, mechanism: 'external', reason: 'commit-build', message }) - expect(await strategy.apply({ stopSafeBlockers: true })).toMatchObject({ + expect(await strategy.apply()).toMatchObject({ ok: false, mechanism: 'external', error: 'commit-build', message }) - expect(await strategy.apply({})).not.toHaveProperty('command') + expect(await strategy.apply()).not.toHaveProperty('command') }) }) diff --git a/apps/desktop/electron/updater/index.ts b/apps/desktop/electron/updater/index.ts index 21a234401b..443969623a 100644 --- a/apps/desktop/electron/updater/index.ts +++ b/apps/desktop/electron/updater/index.ts @@ -88,5 +88,5 @@ export interface UpdaterApplyResultWire { export interface UpdaterStrategy { readonly mechanism: UpdaterMechanism check(opts?: { force?: boolean }): Promise - apply(opts: { stopSafeBlockers?: boolean }): Promise + apply(): Promise } diff --git a/apps/desktop/electron/venv-blocker-scan.test.ts b/apps/desktop/electron/venv-blocker-scan.test.ts deleted file mode 100644 index bc62f78849..0000000000 --- a/apps/desktop/electron/venv-blocker-scan.test.ts +++ /dev/null @@ -1,407 +0,0 @@ -'use strict' - -/** - * Tests for apps/desktop/electron/venv-blocker-scan.ts - * - * Run with: npx vitest run electron/venv-blocker-scan.test.ts - * (from apps/desktop; wired into npm test:desktop:platforms) - */ - -import assert from 'node:assert/strict' -import fs from 'node:fs' -import os from 'node:os' -import path from 'node:path' - -import { describe, it } from 'vitest' - -import { - formatBlockerMessage, - formatProbeFailedMessage, - parseVenvBlockerScanOutput, - resolveVenvPython, - scanVenvBlockers, - stopSafeVenvBlockers -} from './venv-blocker-scan' - -// --------------------------------------------------------------------------- -// resolveVenvPython -// --------------------------------------------------------------------------- - -describe('resolveVenvPython', () => { - it('returns a real path when a temp venv python file exists', () => { - const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-vt-')) - - try { - const scriptsDir = process.platform === 'win32' ? 'Scripts' : 'bin' - const pythonName = process.platform === 'win32' ? 'python.exe' : 'python3' - const dir = path.join(sandbox, 'venv', scriptsDir) - fs.mkdirSync(dir, { recursive: true }) - const pyPath = path.join(dir, pythonName) - fs.writeFileSync(pyPath, '', { mode: 0o755 }) - assert.equal(resolveVenvPython(sandbox), pyPath) - } finally { - fs.rmSync(sandbox, { recursive: true, force: true }) - } - }) - - it('returns null for non-existent venv', () => { - assert.equal(resolveVenvPython('/nonexistent'), null) - }) -}) - -// --------------------------------------------------------------------------- -// formatBlockerMessage / formatProbeFailedMessage -// --------------------------------------------------------------------------- - -describe('formatBlockerMessage', () => { - it('includes PID, name, cmdline, remote-client warning, and retry suggestion', () => { - const msg = formatBlockerMessage({ - blocked: true, - processes: [{ pid: 101, name: 'python.exe', cmdline: 'serve --host 10.0.0.1', kind: 'other', safeToStop: false }] - }) - - assert.ok(msg.includes('PID 101')) - assert.ok(msg.includes('python.exe')) - assert.ok(msg.includes('serve')) - assert.ok(msg.includes('remote backend')) - assert.ok(msg.includes('retry')) - assert.ok(!msg.includes('force-venv')) - }) -}) - -describe('formatProbeFailedMessage', () => { - it('suggests retry and hermes update', () => { - const msg = formatProbeFailedMessage() - assert.ok(msg.includes('hermes update')) - assert.ok(msg.includes('retry')) - }) - - it('distinguishes a timeout from a confirmed blocker', () => { - const msg = formatProbeFailedMessage('timed out after 60 seconds') - assert.ok(msg.includes('timed out after 60 seconds')) - assert.ok(msg.includes('no blocking process was confirmed')) - }) -}) - -// --------------------------------------------------------------------------- -// parseVenvBlockerScanOutput — pure function -// --------------------------------------------------------------------------- - -describe('parseVenvBlockerScanOutput', () => { - const ok = (over: any = {}) => JSON.stringify({ ok: true, blocked: false, processes: [], ...over }) - - it('valid clear', () => { - const o = parseVenvBlockerScanOutput(ok()) - assert.equal(o.kind, 'clear') - }) - - it('valid blocked', () => { - const o = parseVenvBlockerScanOutput( - ok({ - blocked: true, - processes: [{ pid: 1, name: 'p', cmdline: 'c' }] - }) - ) - - assert.equal(o.kind, 'blocked') - }) - - // Contract fixture (#98336/#98350): the scanner reports exemption - // diagnostics (counts + sanitized evidence) alongside the authoritative - // blocked/processes fields. The consumer must tolerate those fields today - // and must keep enforcing blocked/processes consistency — a future parser - // change that either chokes on the diagnostics or silently reinterprets - // an exemption as a blocker breaks this fixture. - it('tolerates exemption diagnostics while enforcing blocked/processes consistency', () => { - const clear = parseVenvBlockerScanOutput( - ok({ - pausable_gateways: 2, - deferred_backends: 1, - deferred_backend_evidence: [{ pid: 78, purpose: 'serve', port: 9119 }] - }) - ) - - assert.equal(clear.kind, 'clear') - - const blocked = parseVenvBlockerScanOutput( - ok({ - blocked: true, - processes: [{ pid: 79, name: 'python.exe', cmdline: 'c' }], - pausable_gateways: 1, - deferred_backends: 1, - deferred_backend_evidence: [{ pid: 78, purpose: 'serve', port: 9119 }] - }) - ) - - assert.equal(blocked.kind, 'blocked') - - if (blocked.kind !== 'blocked') { - return - } - - assert.deepEqual( - blocked.result.processes.map(p => p.pid), - [79] - ) - }) - - it('classifies Python http.server blockers as safe local previews with a human label', () => { - const o = parseVenvBlockerScanOutput( - ok({ - blocked: true, - processes: [ - { - pid: 47484, - name: 'python.exe', - cmdline: 'C:\\Hermes\\venv\\Scripts\\python.exe -m http.server 8766 --directory C', - kind: 'local-preview', - safeToStop: true, - label: 'Example Preview', - port: 8766, - createTime: 1722798000.25 - } - ] - }) - ) - - assert.equal(o.kind, 'blocked') - - if (o.kind !== 'blocked') { - return - } - - assert.deepEqual(o.result.processes[0], { - pid: 47484, - name: 'python.exe', - cmdline: 'C:\\Hermes\\venv\\Scripts\\python.exe -m http.server 8766 --directory C', - kind: 'local-preview', - safeToStop: true, - label: 'Example Preview', - port: 8766, - createTime: 1722798000.25 - }) - }) - - it('does not trust a truncated http.server command line without scanner identity metadata', () => { - const o = parseVenvBlockerScanOutput( - ok({ - blocked: true, - processes: [ - { - pid: 47484, - name: 'python.exe', - cmdline: 'python.exe -m http.server 8766 --directory C' - } - ] - }) - ) - - assert.equal(o.kind, 'blocked') - - if (o.kind !== 'blocked') { - return - } - - assert.equal(o.result.processes[0]?.kind, 'other') - assert.equal(o.result.processes[0]?.safeToStop, false) - }) - - it('never marks an arbitrary Python process safe to stop', () => { - const o = parseVenvBlockerScanOutput( - ok({ - blocked: true, - processes: [{ pid: 9, name: 'python.exe', cmdline: 'python.exe important-script.py' }] - }) - ) - - assert.equal(o.kind, 'blocked') - - if (o.kind !== 'blocked') { - return - } - - assert.equal(o.result.processes[0]?.kind, 'other') - assert.equal(o.result.processes[0]?.safeToStop, false) - }) - - it('malformed JSON', () => { - assert.equal(parseVenvBlockerScanOutput('not json').kind, 'probe-failure') - }) - - it('ok=false is rejected', () => { - assert.equal( - parseVenvBlockerScanOutput(JSON.stringify({ ok: false, blocked: false, processes: [] })).kind, - 'probe-failure' - ) - }) - - it('blocked must be boolean', () => { - assert.equal(parseVenvBlockerScanOutput(ok({ blocked: 'false' })).kind, 'probe-failure') - }) - - it('blocked=true with empty processes rejected', () => { - assert.equal(parseVenvBlockerScanOutput(ok({ blocked: true, processes: [] })).kind, 'probe-failure') - }) - - it('blocked=false with non-empty processes rejected', () => { - assert.equal( - parseVenvBlockerScanOutput(ok({ processes: [{ pid: 1, name: 'p', cmdline: 'c' }] })).kind, - 'probe-failure' - ) - }) - - it('process pid must be positive integer', () => { - assert.equal( - parseVenvBlockerScanOutput(ok({ blocked: true, processes: [{ pid: 0, name: 'p', cmdline: 'c' }] })).kind, - 'probe-failure' - ) - }) - - it('process name must be non-empty string', () => { - assert.equal( - parseVenvBlockerScanOutput(ok({ blocked: true, processes: [{ pid: 1, name: '', cmdline: 'c' }] })).kind, - 'probe-failure' - ) - }) - - it('process missing cmdline is rejected', () => { - assert.equal( - parseVenvBlockerScanOutput(ok({ blocked: true, processes: [{ pid: 1, name: 'p' }] })).kind, - 'probe-failure' - ) - }) -}) - -// --------------------------------------------------------------------------- -// scanVenvBlockers — subprocess with injection -// --------------------------------------------------------------------------- - -describe('scanVenvBlockers', () => { - const stubVenv = () => '/fake/venv/python.exe' - const okJson = JSON.stringify({ ok: true, blocked: false, processes: [] }) - - const blockedJson = JSON.stringify({ - ok: true, - blocked: true, - processes: [{ pid: 1, name: 'p', cmdline: 'c' }] - }) - - function execReturn(json: string): any { - return (async (...args: any[]) => ({ stdout: json, stderr: '' })) as any - } - - function execThrow(status: number, stderr: string): any { - return (async (...args: any[]) => { - const e: any = new Error() - e.status = status - e.stderr = Buffer.from(stderr) - throw e - }) as any - } - - function execTimeout(): any { - return (async (...args: any[]) => { - const e: any = new Error() - e.killed = true - e.signal = 'SIGTERM' - throw e - }) as any - } - - it('clear scan returns clear', async () => { - assert.equal((await scanVenvBlockers('/r', execReturn(okJson), stubVenv)).kind, 'clear') - }) - - it('blocked scan returns blocked', async () => { - assert.equal((await scanVenvBlockers('/r', execReturn(blockedJson), stubVenv)).kind, 'blocked') - }) - - it('non-zero exit is probe-failure', async () => { - const o = await scanVenvBlockers('/r', execThrow(2, 'ModuleNotFoundError'), stubVenv) - assert.equal(o.kind, 'probe-failure') - }) - - it('reports a timed-out subprocess explicitly', async () => { - const o = await scanVenvBlockers('/r', execTimeout(), stubVenv) - assert.deepEqual(o, { - kind: 'probe-failure', - error: 'timed out after 60 seconds' - }) - }) - - it('missing venv python is probe-failure', async () => { - const o = await scanVenvBlockers('/r', execReturn(okJson), () => null) - assert.equal(o.kind, 'probe-failure') - }) - - it('malformed subprocess output is probe-failure', async () => { - const o = await scanVenvBlockers('/r', execReturn('bad json'), stubVenv) - assert.equal(o.kind, 'probe-failure') - }) - - it('calls subprocess with correct args, cwd and timeout', async () => { - const calls: any[] = [] - - const spy = (async (cmd: string, args: string[], opts: any) => { - calls.push({ cmd, args, cwd: opts.cwd, timeout: opts.timeout }) - - return { stdout: okJson, stderr: '' } - }) as any - - await scanVenvBlockers('/update/root', spy, stubVenv) - assert.equal(calls.length, 1) - const c = calls[0] - assert.ok(c.cmd.endsWith('python.exe')) - assert.deepEqual(c.args, ['-m', 'hermes_cli._scan_venv_blockers']) - assert.equal(c.cwd, '/update/root') - assert.equal(c.timeout, 60_000) - }) -}) - -describe('stopSafeVenvBlockers', () => { - it('stops only blockers explicitly classified as safe local previews', async () => { - const calls: Array<{ command: string; args: string[] }> = [] - - const exec = (async (command: string, args: string[]) => { - calls.push({ command, args }) - - return { stdout: '', stderr: '' } - }) as any - - const outcome = await stopSafeVenvBlockers( - '/update/root', - { - blocked: true, - processes: [ - { - pid: 47484, - name: 'python.exe', - cmdline: 'python.exe -m http.server 8766 --directory C:\\preview', - kind: 'local-preview', - safeToStop: true, - label: 'preview', - port: 8766, - createTime: 1722798000.25 - }, - { - pid: 99, - name: 'python.exe', - cmdline: 'python.exe important-script.py', - kind: 'other', - safeToStop: false - } - ] - }, - exec, - () => 'C:\\Hermes\\venv\\Scripts\\python.exe' - ) - - assert.deepEqual(calls, [ - { - command: 'C:\\Hermes\\venv\\Scripts\\python.exe', - args: ['-m', 'hermes_cli._scan_venv_blockers', '--terminate-safe', '47484', '1722798000.25'] - } - ]) - assert.deepEqual(outcome, { stopped: [47484], failed: [] }) - }) -}) diff --git a/apps/desktop/electron/venv-blocker-scan.ts b/apps/desktop/electron/venv-blocker-scan.ts deleted file mode 100644 index d891d75052..0000000000 --- a/apps/desktop/electron/venv-blocker-scan.ts +++ /dev/null @@ -1,323 +0,0 @@ -'use strict' - -/** - * venv-blocker-scan.ts - * - * Thin helper that runs the Python venv-blocker scan as a subprocess and - * returns a typed result for the Desktop update preflight. - */ - -import { execFile } from 'node:child_process' -import fs from 'node:fs' -import path from 'node:path' -import { promisify } from 'node:util' - -const execFileAsync = promisify(execFile) - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -export type VenvBlockerKind = 'local-preview' | 'other' - -export interface VenvBlockerProcess { - pid: number - name: string - cmdline: string - kind: VenvBlockerKind - safeToStop: boolean - label?: string - port?: number - createTime?: number -} - -export interface VenvBlockerScanResult { - blocked: boolean - processes: VenvBlockerProcess[] -} - -export type ScanOutcome = - | { kind: 'clear'; result: VenvBlockerScanResult } - | { kind: 'blocked'; result: VenvBlockerScanResult } - | { kind: 'probe-failure'; error: string } - -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - -// A Windows process table can take longer than 15 seconds to inspect on busy -// hosts. Keep a watchdog for genuinely wedged probes, but leave enough headroom -// for the scanner's conservative fallback checks. -const SCAN_TIMEOUT_MS = 60000 -const SCAN_MODULE = 'hermes_cli._scan_venv_blockers' - -// --------------------------------------------------------------------------- -// Public API -// --------------------------------------------------------------------------- - -function classifyVenvBlocker( - process: Pick, - hints?: Record -): VenvBlockerProcess { - const moduleMatch = process.cmdline.match(/(?:^|\s)-m\s+http\.server(?:\s+(\d{1,5}))?(?:\s|$)/i) - const isPython = /^python(?:w)?(?:\.exe)?$/i.test(process.name) - const hintedCreateTime = typeof hints?.createTime === 'number' ? hints.createTime : undefined - - const trustedScannerIdentity = - hints?.kind === 'local-preview' && - hints.safeToStop === true && - hintedCreateTime !== undefined && - Number.isFinite(hintedCreateTime) && - hintedCreateTime > 0 - - if (!isPython || !moduleMatch || !trustedScannerIdentity) { - return { ...process, kind: 'other', safeToStop: false } - } - - const parsedPort = moduleMatch[1] ? Number(moduleMatch[1]) : 8000 - const hintedPort = trustedScannerIdentity && typeof hints?.port === 'number' ? hints.port : undefined - const candidatePort = hintedPort ?? parsedPort - - const port = - Number.isInteger(candidatePort) && candidatePort > 0 && candidatePort <= 65535 ? candidatePort : undefined - - const directoryMatch = process.cmdline.match(/(?:^|\s)--directory\s+(?:"([^"]+)"|'([^']+)'|(.+))$/i) - const directory = (directoryMatch?.[1] || directoryMatch?.[2] || directoryMatch?.[3] || '').trim() - const parsedLabel = directory ? path.win32.basename(directory.replace(/["']$/, '')) : undefined - const hintedLabel = trustedScannerIdentity && typeof hints?.label === 'string' ? hints.label.trim() : '' - const label = hintedLabel || parsedLabel - - return { - ...process, - kind: 'local-preview', - safeToStop: true, - ...(label ? { label } : {}), - ...(port ? { port } : {}), - createTime: hintedCreateTime - } -} - -/** - * Stop only blockers that the fresh scanner identified as Python static-file - * preview servers. Unknown Python/Hermes processes are deliberately ignored. - */ -export async function stopSafeVenvBlockers( - updateRoot: string, - result: VenvBlockerScanResult, - execOverride?: typeof execFileAsync, - resolvePython: typeof resolveVenvPython = resolveVenvPython -): Promise<{ stopped: number[]; failed: number[] }> { - const execFn = execOverride || execFileAsync - const stopped: number[] = [] - const failed: number[] = [] - const pythonPath = resolvePython(updateRoot) - - for (const process of result.processes) { - if ( - !pythonPath || - !process.safeToStop || - process.kind !== 'local-preview' || - !process.createTime || - !Number.isFinite(process.createTime) - ) { - if (process.safeToStop && process.kind === 'local-preview') { - failed.push(process.pid) - } - - continue - } - - try { - await execFn( - pythonPath, - ['-m', 'hermes_cli._scan_venv_blockers', '--terminate-safe', String(process.pid), String(process.createTime)], - { cwd: updateRoot, windowsHide: true, timeout: 10_000, maxBuffer: 256 * 1024 } - ) - stopped.push(process.pid) - } catch { - failed.push(process.pid) - } - } - - return { stopped, failed } -} - -/** - * Strictly validate and parse the JSON output from the venv-blocker scan. - * Pure function — no side effects. - */ -export function parseVenvBlockerScanOutput(raw: string): ScanOutcome { - let parsed: any - - try { - parsed = JSON.parse(raw) - } catch { - return { kind: 'probe-failure', error: 'malformed JSON' } - } - - if (!parsed || typeof parsed !== 'object' || parsed.ok !== true) { - return { kind: 'probe-failure', error: 'missing or invalid ok field' } - } - - if (typeof parsed.blocked !== 'boolean') { - return { kind: 'probe-failure', error: 'blocked must be a boolean' } - } - - if (!Array.isArray(parsed.processes)) { - return { kind: 'probe-failure', error: 'processes must be an array' } - } - - const processes: VenvBlockerProcess[] = [] - - for (const entry of parsed.processes) { - if (!entry || typeof entry !== 'object') { - return { kind: 'probe-failure', error: 'process entry must be an object' } - } - - const { pid, name, cmdline } = entry - - if (!Number.isInteger(pid) || pid <= 0) { - return { kind: 'probe-failure', error: 'process pid must be a positive integer' } - } - - if (typeof name !== 'string' || name.length === 0) { - return { kind: 'probe-failure', error: 'process name must be a non-empty string' } - } - - if (typeof cmdline !== 'string') { - return { kind: 'probe-failure', error: 'process cmdline must be a string' } - } - - processes.push(classifyVenvBlocker({ pid, name, cmdline }, entry)) - } - - // Reject inconsistent combinations - if (parsed.blocked && processes.length === 0) { - return { kind: 'probe-failure', error: 'blocked is true but process list is empty' } - } - - if (!parsed.blocked && processes.length > 0) { - return { kind: 'probe-failure', error: 'blocked is false but process list is non-empty' } - } - - return parsed.blocked - ? { kind: 'blocked', result: { blocked: true, processes } } - : { kind: 'clear', result: { blocked: false, processes } } -} - -/** - * Run the venv-blocker scan subprocess. Async so the Electron main-process - * event loop is never blocked by the psutil process scan (tens of seconds on a - * loaded Windows box). Accepts optional overrides for testing (dependency - * injection). - */ -export async function scanVenvBlockers( - updateRoot: string, - execOverride?: typeof execFileAsync, - resolveOverride?: typeof resolveVenvPython -): Promise { - const execFn = execOverride || execFileAsync - const resolveFn = resolveOverride || resolveVenvPython - const venvPython = resolveFn(updateRoot) - - if (!venvPython) { - return { kind: 'probe-failure', error: 'venv python not found' } - } - - let stdout: string - - try { - const proc = await execFn(venvPython, ['-m', SCAN_MODULE], { - cwd: updateRoot, - encoding: 'utf-8', - timeout: SCAN_TIMEOUT_MS, - windowsHide: true - } as any) - - stdout = String((proc as any).stdout ?? '') - } catch (err: any) { - if (err?.killed === true) { - return { - kind: 'probe-failure', - error: `timed out after ${SCAN_TIMEOUT_MS / 1000} seconds` - } - } - - const diag = [`exit code ${err.status ?? err.code ?? -1}`] - - if (err.stderr) { - diag.push(String(err.stderr).slice(0, 200)) - } - - return { kind: 'probe-failure', error: diag.join('; ') } - } - - return parseVenvBlockerScanOutput(stdout) -} - -// --------------------------------------------------------------------------- -// Internal helpers (exported for testing) -// --------------------------------------------------------------------------- - -/** Resolve the venv python path. Returns null if the file does not exist. */ -export function resolveVenvPython(updateRoot: string): string | null { - const isWindows = process.platform === 'win32' - const pythonName = isWindows ? 'python.exe' : 'python3' - const scriptsDir = isWindows ? 'Scripts' : 'bin' - const candidate = path.join(updateRoot, 'venv', scriptsDir, pythonName) - - try { - fs.accessSync(candidate) - - return candidate - } catch { - return null - } -} - -/** - * Build a human-readable error message from blocker scan results. - * Does NOT recommend --force-venv. - */ -export function formatBlockerMessage(result: VenvBlockerScanResult): string { - const lines = [ - 'Update aborted: another Hermes process is using this installation.', - '', - 'These processes must be stopped before updating:', - '' - ] - - for (const proc of result.processes.slice(0, 10)) { - lines.push(` PID ${proc.pid} ${proc.name} ${proc.cmdline}`) - } - - if (result.processes.length > 10) { - lines.push(` ... and ${result.processes.length - 10} more`) - } - - lines.push('') - lines.push( - 'Close the terminal, app, or service owning that process. If it is a ' + - 'remote backend, stopping it will disconnect remote clients.' - ) - lines.push('Then retry the update.') - - return lines.join('\n') -} - -/** - * Build a probe-failure error message. - */ -export function formatProbeFailedMessage(error?: string): string { - const timeoutDetail = error?.startsWith('timed out after') - ? `\n\nThe verification scan ${error}; no blocking process was confirmed.` - : '' - - return ( - 'Update aborted: Desktop could not verify the Hermes installation is free.' + - timeoutDetail + - '\n\n' + - 'Close other Hermes windows and terminals, then retry. If the problem\n' + - 'persists, run `hermes update` in a terminal for detailed diagnostics.' - ) -} diff --git a/apps/desktop/electron/windows-hermes-path.test.ts b/apps/desktop/electron/windows-hermes-path.test.ts index 5a02e57d50..a1796ea338 100644 --- a/apps/desktop/electron/windows-hermes-path.test.ts +++ b/apps/desktop/electron/windows-hermes-path.test.ts @@ -39,43 +39,9 @@ test('buildPathExtCandidates: non-Windows only tries the bare name', () => { assert.deepEqual(buildPathExtCandidates(undefined, false), ['']) }) -test('chooseUpdaterArgs: gentle --update when both updater runtime files exist', () => { - assert.deepEqual(chooseUpdaterArgs({ hasBootstrapMarker: true, hasVenvHermes: true, hasVenvPython: true }, 'main'), [ - '--update', - '--branch', - 'main' - ]) -}) - -test('chooseUpdaterArgs: marker-only install uses --repair when the venv is gone', () => { - assert.deepEqual( - chooseUpdaterArgs({ hasBootstrapMarker: true, hasVenvHermes: false, hasVenvPython: false }, 'main'), - ['--repair', '--branch', 'main'] - ) -}) - -test('chooseUpdaterArgs: partial updater runtimes use --repair', () => { - assert.deepEqual(chooseUpdaterArgs({ hasBootstrapMarker: true, hasVenvHermes: false, hasVenvPython: true }, 'main'), [ - '--repair', - '--branch', - 'main' - ]) - assert.deepEqual(chooseUpdaterArgs({ hasBootstrapMarker: true, hasVenvHermes: true, hasVenvPython: false }, 'main'), [ - '--repair', - '--branch', - 'main' - ]) -}) - -test('chooseUpdaterArgs: passes the branch through unchanged in both modes', () => { - assert.deepEqual( - chooseUpdaterArgs({ hasBootstrapMarker: false, hasVenvHermes: true, hasVenvPython: true }, 'release/1.2'), - ['--update', '--branch', 'release/1.2'] - ) - assert.deepEqual( - chooseUpdaterArgs({ hasBootstrapMarker: false, hasVenvHermes: false, hasVenvPython: false }, 'release/1.2'), - ['--repair', '--branch', 'release/1.2'] - ) +test('chooseUpdaterArgs preserves the target and requires a usable runtime, not a marker', () => { + assert.deepEqual(chooseUpdaterArgs({ runtimeUsable: true }, 'release/1.2'), ['--update', '--branch', 'release/1.2']) + assert.deepEqual(chooseUpdaterArgs({ runtimeUsable: false }, 'release/1.2'), ['--repair', '--branch', 'release/1.2']) }) function makeDeps(overrides: Partial[2]> = {}) { diff --git a/apps/desktop/electron/windows-hermes-path.ts b/apps/desktop/electron/windows-hermes-path.ts index 2174a8491e..548a1f3617 100644 --- a/apps/desktop/electron/windows-hermes-path.ts +++ b/apps/desktop/electron/windows-hermes-path.ts @@ -68,15 +68,11 @@ export function buildPathExtCandidates(pathext: string | undefined, isWindows: b * @returns {string[]} updater argv, e.g. ['--update', '--branch', 'main']. */ export interface BootstrapRecoverySignals { - hasBootstrapMarker: boolean - hasVenvHermes: boolean - hasVenvPython: boolean + runtimeUsable: boolean } export function chooseUpdaterArgs(signals: BootstrapRecoverySignals, branch: string): string[] { - const canRunUpdater = signals.hasVenvHermes && signals.hasVenvPython - - return canRunUpdater ? ['--update', '--branch', branch] : ['--repair', '--branch', branch] + return signals.runtimeUsable ? ['--update', '--branch', branch] : ['--repair', '--branch', branch] } export interface ResolveVenvHermesCommandDeps { diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 9d707c7daf..06d3b8d678 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -27,8 +27,7 @@ "profile:main": "tsc --build tsconfig.electron.json && wait-on http://127.0.0.1:5174 && node scripts/bundle-electron-main.mjs --dev && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron --inspect=9229 .", "profile:main:cpu": "tsc --build tsconfig.electron.json && wait-on http://127.0.0.1:5174 && node scripts/bundle-electron-main.mjs --dev && cross-env XCURSOR_SIZE=24 NODE_OPTIONS=--cpu-prof HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .", "start": "npm run build && electron .", - "prebuild": "node ../../scripts/generate-icons.mjs && node scripts/assert-root-install.mjs", - "build": "node scripts/assert-root-install.mjs && node scripts/write-build-stamp.mjs && node scripts/stage-native-deps.mjs && node ../../scripts/build/desktop.mjs --source ../.. --icons ../.. --stamp build/install-stamp.json --native-deps build/native-deps --out dist", + "build": "node scripts/build.mjs", "postbuild": "node scripts/assert-dist-built.mjs", "prebuilder": "node scripts/patch-electron-builder-mac-binary.mjs", "builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 node scripts/run-electron-builder.mjs", diff --git a/apps/desktop/scripts/build.mjs b/apps/desktop/scripts/build.mjs new file mode 100644 index 0000000000..e3276d9b5b --- /dev/null +++ b/apps/desktop/scripts/build.mjs @@ -0,0 +1,33 @@ +// npm's source-development composition; the compiler consumes prepared inputs. +import { execFileSync } from 'node:child_process' +import { cpSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { parseArgs } from 'node:util' +import { generateIcons } from '../../../scripts/generate-icons.mjs' +import { isMain, repoRoot } from '../../../scripts/build/frontend-common.mjs' + +export function buildSourceDesktop({ source = repoRoot, icons, onDemand = false, run = execFileSync, generate = generateIcons } = {}) { + source = resolve(source) + const app = join(source, 'apps/desktop') + const step = (script, args = []) => run(process.execPath, [join(source, script), ...args], { cwd: app, stdio: 'inherit' }) + step('apps/desktop/scripts/assert-root-install.mjs') + if (!icons) { + if (generate(['--source', source, '--out', source, ...(onDemand ? ['--on-demand'] : [])]) !== 0) throw new Error('Icon preparation failed') + icons = source + } + icons = resolve(icons) + if (icons !== source) { + // electron-builder consumes packaging artwork in the workspace. Copy the + // prepared pixels; do not create another Python environment to redraw them. + cpSync(join(icons, 'apps/desktop/assets'), join(app, 'assets'), { recursive: true }) + } + step('apps/desktop/scripts/write-build-stamp.mjs') + step('apps/desktop/scripts/stage-native-deps.mjs') + step('scripts/build/desktop.mjs', ['--source', source, '--icons', icons, + '--stamp', join(app, 'build/install-stamp.json'), '--native-deps', join(app, 'build/native-deps'), '--out', join(app, 'dist')]) +} + +if (isMain(import.meta.url)) { + const { values } = parseArgs({ options: { icons: { type: 'string' }, 'on-demand': { type: 'boolean' } } }) + buildSourceDesktop({ ...values, onDemand: values['on-demand'] }) +} diff --git a/apps/desktop/scripts/rebuild-native.mjs b/apps/desktop/scripts/rebuild-native.mjs deleted file mode 100644 index ddec5ea318..0000000000 --- a/apps/desktop/scripts/rebuild-native.mjs +++ /dev/null @@ -1,22 +0,0 @@ -// rebuild-native.mjs -import { rebuild } from '@electron/rebuild' -import { resolve, dirname } from 'node:path' -import { fileURLToPath } from 'node:url' -import { isMain } from './utils.mjs' -import packageJson from '../package.json' with { type: 'json' } -const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..') - -export async function rebuildNodePty({ arch = process.arch } = {}) { - await rebuild({ - buildPath: projectRoot, // where node_modules lives - electronVersion: packageJson.devDependencies.electron.replace('^', ''), - arch, - onlyModules: ['node-pty'], - force: true - }) -} - -if (isMain(import.meta.url)) { - const [arch] = process.argv.slice(2) - await rebuildNodePty({ arch }) -} diff --git a/apps/desktop/src/app/updates-overlay.blockers.test.tsx b/apps/desktop/src/app/updates-overlay.blockers.test.tsx deleted file mode 100644 index de47566471..0000000000 --- a/apps/desktop/src/app/updates-overlay.blockers.test.tsx +++ /dev/null @@ -1,213 +0,0 @@ -import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' - -import { Dialog, DialogContent } from '@/components/ui/dialog' -import type { DesktopUpdateStatus } from '@/global' -import { I18nProvider } from '@/i18n/context' -import { - $updateApply, - $updateOverlayOpen, - $updateOverlayTarget, - $updateStatus, - resetUpdateApplyState -} from '@/store/updates' - -import { BlockerView, formatBlockerCommandLine, UpdatesOverlay } from './updates-overlay' - -async function renderWithI18n(ui: React.ReactNode) { - await act(async () => { - render( - ({}), saveConfig: async () => ({ ok: true }) }}> - - {ui} - - - ) - }) -} - -async function renderUpdatesOverlay() { - await act(async () => { - render( - ({}), saveConfig: async () => ({ ok: true }) }}> - - - ) - }) -} - -describe('formatBlockerCommandLine', () => { - it('redacts the full ambiguous tail after a sensitive CLI, environment, or header marker', () => { - const secretParts = ['first-part', 'second-part'] - const secret = secretParts.join(' ') - - const commands = [ - `python.exe watcher.py --token ${secret} --mode inspect`, - ['python.exe watcher.py --password="', secret, '" --mode inspect'].join(''), - `AUTH_TOKEN=${secret} python.exe watcher.py --mode inspect`, - `python.exe watcher.py Authorization: Bearer ${secret} --mode inspect` - ] - - for (const commandLine of commands) { - const formatted = formatBlockerCommandLine(commandLine) - - expect(formatted).not.toContain(secretParts[0]) - expect(formatted).not.toContain(secretParts[1]) - expect(formatted).not.toContain('--mode inspect') - expect(formatted.endsWith('[REDACTED]')).toBe(true) - } - }) - - it('redacts a sensitive query value and bounds the remaining diagnostic output', () => { - const queryValue = ['private', 'value'].join('-') - - const commandLine = - `python.exe watcher.py https://example.test/?api_key=${queryValue}&mode=inspect ` + 'x'.repeat(600) - - const formatted = formatBlockerCommandLine(commandLine) - - expect(formatted).not.toContain(queryValue) - expect(formatted).toContain('api_key=[REDACTED]&mode=inspect') - expect(Array.from(formatted).length).toBeLessThanOrEqual(500) - expect(formatted.endsWith('…')).toBe(true) - }) -}) - -describe('BlockerView', () => { - afterEach(() => { - cleanup() - $updateOverlayOpen.set(false) - $updateOverlayTarget.set('client') - $updateStatus.set(null) - resetUpdateApplyState() - }) - - it('uses the blocker view for a foreign process instead of the generic update error', async () => { - $updateOverlayTarget.set('client') - $updateOverlayOpen.set(true) - $updateStatus.set({ - supported: true, - updateAvailable: true, - behind: 1, - commits: [] - } as DesktopUpdateStatus) - $updateApply.set({ - applying: false, - stage: 'error', - message: 'Update aborted: another Hermes process is using this installation.', - percent: null, - error: 'venv-blocked', - command: null, - blockers: [ - { - pid: 58636, - name: 'python.exe', - cmdline: 'python.exe fenbi_session_refresh.py', - kind: 'other', - safeToStop: false - } - ], - log: [] - }) - - await renderUpdatesOverlay() - - expect(screen.getByText('Close other processes to update Hermes')).toBeTruthy() - expect(screen.getByText('python.exe')).toBeTruthy() - expect(screen.queryByText('Update didn’t finish')).toBeNull() - }) - - it('identifies foreign blockers without offering automatic termination', async () => { - const onStopAndUpdate = vi.fn() - - await renderWithI18n( - {}} - onStopAndUpdate={onStopAndUpdate} - /> - ) - - expect(screen.getByText('Close other processes to update Hermes')).toBeTruthy() - expect(screen.getByText('python.exe')).toBeTruthy() - expect(screen.getByText('PID 58636')).toBeTruthy() - expect(screen.getByText(/can’t safely close these processes automatically/i)).toBeTruthy() - expect(screen.getByText(/python.exe fenbi_session_refresh\.py/i)).toBeTruthy() - expect(screen.queryByRole('button', { name: /close previews/i })).toBeNull() - expect(onStopAndUpdate).not.toHaveBeenCalled() - }) - - it('keeps mixed blocker cleanup limited to safe previews', async () => { - const onStopAndUpdate = vi.fn() - - await renderWithI18n( - {}} - onStopAndUpdate={onStopAndUpdate} - /> - ) - - expect(screen.getByText(/can close the local previews listed below/i)).toBeTruthy() - expect(screen.getByText('Example Preview')).toBeTruthy() - expect(screen.getByText('python.exe')).toBeTruthy() - - fireEvent.click(screen.getByRole('button', { name: 'Close previews and check again' })) - expect(onStopAndUpdate).toHaveBeenCalledTimes(1) - }) - - it('explains safe local previews and offers one-click close-and-update', async () => { - const onStopAndUpdate = vi.fn() - - await renderWithI18n( - {}} - onStopAndUpdate={onStopAndUpdate} - /> - ) - - expect(screen.getByText('Close local previews to update Hermes?')).toBeTruthy() - expect(screen.getByText('Example Preview')).toBeTruthy() - expect(screen.getByText('Port 8766')).toBeTruthy() - expect(screen.getByText(/will not modify or delete your files/i)).toBeTruthy() - - fireEvent.click(screen.getByRole('button', { name: 'Close previews and update' })) - expect(onStopAndUpdate).toHaveBeenCalledTimes(1) - }) -}) diff --git a/apps/desktop/src/app/updates-overlay.tsx b/apps/desktop/src/app/updates-overlay.tsx index 1aa8128183..334bd52002 100644 --- a/apps/desktop/src/app/updates-overlay.tsx +++ b/apps/desktop/src/app/updates-overlay.tsx @@ -18,7 +18,7 @@ import { Progress } from '@/components/ui/progress' import { UpdateStatusCard, VersionHero } from '@/components/update-status' import { VersionDetails } from '@/components/version-details' import type { - DesktopUpdateBlocker, + DesktopUpdateCommit, DesktopUpdateStage, DesktopUpdateStatus, @@ -92,7 +92,6 @@ export function UpdatesOverlay() { ? 'error' : 'idle' - const updateBlockers = !isBackend && apply.error === 'venv-blocked' && apply.blockers?.length ? apply.blockers : null const handleClose = (next: boolean) => { if (phase === 'applying') { @@ -133,15 +132,8 @@ export function UpdatesOverlay() { {phase === 'guiSkew' && handleClose(false)} />} - {phase === 'error' && updateBlockers ? ( - handleClose(false)} - onStopAndUpdate={() => void applyUpdates({ stopSafeBlockers: true })} - /> - ) : null} - {phase === 'error' && !updateBlockers ? ( + {phase === 'error' ? ( handleClose(false)} onRetry={handleInstall} /> ) : null} @@ -486,105 +478,6 @@ function ApplyingView({ ) } -const BLOCKER_COMMAND_LINE_LIMIT = 500 - -const SENSITIVE_ARGUMENT_NAME = - '(?:api[-_]?key|access[-_]?token|refresh[-_]?token|auth[-_]?token|x[-_]?plex[-_]?token|token|password|passwd|client[-_]?secret|secret|authorization)' - -const SENSITIVE_COMMAND_TAIL = new RegExp( - `((?:^|\\s)(?:(?:--?)${SENSITIVE_ARGUMENT_NAME}(?:\\s*=\\s*|\\s+)|${SENSITIVE_ARGUMENT_NAME}\\s*(?:=|:)\\s*)).*$`, - 'i' -) - -const SENSITIVE_QUERY_ARGUMENT = new RegExp(`([?&]${SENSITIVE_ARGUMENT_NAME}=)[^&#\\s]+`, 'gi') - -export function formatBlockerCommandLine(commandLine: string): string { - const redacted = commandLine - .replace(SENSITIVE_QUERY_ARGUMENT, '$1[REDACTED]') - .replace(SENSITIVE_COMMAND_TAIL, '$1[REDACTED]') - - const characters = Array.from(redacted) - - return characters.length > BLOCKER_COMMAND_LINE_LIMIT - ? `${characters.slice(0, BLOCKER_COMMAND_LINE_LIMIT - 1).join('')}…` - : redacted -} - -export function BlockerView({ - blockers, - onDismiss, - onStopAndUpdate -}: { - blockers: readonly DesktopUpdateBlocker[] - onDismiss: () => void - onStopAndUpdate: () => void -}) { - const { t } = useI18n() - const u = t.updates - - const safeBlockers = blockers.filter(blocker => blocker.kind === 'local-preview' && blocker.safeToStop) - const hasForeignBlockers = safeBlockers.length !== blockers.length - const title = hasForeignBlockers ? u.foreignBlockerTitle : u.blockerTitle - - const body = hasForeignBlockers - ? safeBlockers.length > 0 - ? u.mixedBlockerBody - : u.foreignBlockerBody - : u.blockerBody - - return ( -
-
-
- -
- {title} - - {body} - -
- -
- {blockers.map(blocker => { - const isSafePreview = blocker.kind === 'local-preview' && blocker.safeToStop - - return ( -
-
- {isSafePreview ? blocker.label || u.localPreview : blocker.name} -
-
- {isSafePreview && blocker.port ? u.portLabel(blocker.port) : u.pidLabel(blocker.pid)} -
-
- ) - })} -
- -
- {u.technicalDetails} -
- {blockers.map(blocker => ( -
- PID {blocker.pid} · {formatBlockerCommandLine(blocker.cmdline)} -
- ))} -
-
- -
- {safeBlockers.length > 0 ? ( - - ) : null} - -
-
- ) -} function ErrorView({ message, onDismiss, onRetry }: { message: string; onDismiss: () => void; onRetry: () => void }) { const { t } = useI18n() diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index d3bf1b14cc..f96f868952 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -784,21 +784,10 @@ export interface DesktopUpdateStatus { export type DesktopUpdateDirtyStrategy = 'abort' | 'stash' | 'force' -export interface DesktopUpdateBlocker { - pid: number - name: string - cmdline: string - kind: 'local-preview' | 'other' - safeToStop: boolean - label?: string - port?: number - createTime?: number -} export interface DesktopUpdateApplyOptions { dirtyStrategy?: DesktopUpdateDirtyStrategy - /** User confirmed that Desktop may stop freshly re-scanned safe local preview servers. */ - stopSafeBlockers?: boolean + } export interface DesktopUpdateApplyResult { @@ -808,7 +797,7 @@ export interface DesktopUpdateApplyResult { branch?: string error?: string message?: string - blockers?: DesktopUpdateBlocker[] + /** True when no staged updater exists (CLI install) and the user should run * `hermes update` themselves. `command` is the exact line to run. */ manual?: boolean diff --git a/apps/desktop/src/store/updates.test.ts b/apps/desktop/src/store/updates.test.ts index 047527e92f..611ade6447 100644 --- a/apps/desktop/src/store/updates.test.ts +++ b/apps/desktop/src/store/updates.test.ts @@ -933,26 +933,6 @@ describe('applyUpdates terminal state', () => { expect($updateApply.get().error).toBe('rebuild-failed') }) - it('preserves structured safe blockers for the close-and-update prompt', async () => { - const blockers = [ - { - pid: 47484, - name: 'python.exe', - cmdline: 'python.exe -m http.server 8766', - kind: 'local-preview' as const, - safeToStop: true, - label: 'Example Preview', - port: 8766 - } - ] - - applyMock.mockResolvedValue({ ok: false, error: 'venv-blocked', message: 'blocked', blockers }) - - await applyUpdates() - - expect($updateApply.get().error).toBe('venv-blocked') - expect($updateApply.get().blockers).toEqual(blockers) - }) it('keeps the manual command state for CLI installs with no staged updater', async () => { applyMock.mockResolvedValue({ ok: true, manual: true, command: 'hermes update' }) diff --git a/apps/desktop/src/store/updates.ts b/apps/desktop/src/store/updates.ts index 9b260f1a19..845952aa26 100644 --- a/apps/desktop/src/store/updates.ts +++ b/apps/desktop/src/store/updates.ts @@ -9,7 +9,7 @@ import { connectionScoped, profileScoped } from '@/api/client' import type { DesktopUpdateApplyOptions, DesktopUpdateApplyResult, - DesktopUpdateBlocker, + DesktopUpdateProgress, DesktopUpdateStage, DesktopUpdateStatus, @@ -34,8 +34,7 @@ export interface UpdateApplyState { /** When the stage is 'manual': the exact command the user should run * (CLI install with no staged updater). */ command: string | null - /** Structured update blockers used by the safe close-and-update confirmation. */ - blockers?: readonly DesktopUpdateBlocker[] | null + log: readonly { stage: DesktopUpdateStage; message: string; at: number }[] } @@ -559,8 +558,7 @@ export async function applyUpdates(opts: DesktopUpdateApplyOptions = {}): Promis applying: false, stage: 'error', error: result?.error ?? 'apply-failed', - message: result?.message ?? translateNow('updates.errorBody'), - blockers: result?.blockers ?? null + message: result?.message ?? translateNow('updates.errorBody') }) } } diff --git a/cron/scheduler_script.py b/cron/scheduler_script.py index 0d63a8e71b..c532ca07c7 100644 --- a/cron/scheduler_script.py +++ b/cron/scheduler_script.py @@ -139,9 +139,7 @@ def _windows_cron_python_invocation(python_exe: str) -> tuple[str, dict[str, str # A packaged caller may hand us the old venv launcher; select bytes # from the install record rather than interpreting relocated pyvenv.cfg. dependencies = dependency_site(selected_venv(repo)) - payload_dependencies = venv_dir / "Lib" / "site-packages" - if (venv_dir.parent / "manifest.json").is_file() and payload_dependencies.is_dir(): - dependencies = payload_dependencies + return str(managed_python), {"PYTHONPATH": os.pathsep.join([str(repo), str(dependencies)])} cfg = _read_windows_pyvenv_cfg(venv_dir) diff --git a/docs/middleware/README.md b/docs/middleware/README.md index 96304fa0e1..01c83e9d8c 100644 --- a/docs/middleware/README.md +++ b/docs/middleware/README.md @@ -128,13 +128,16 @@ hermes plugins enable hermes chat --query 'Reply exactly ok' ``` -For source checkouts, prefer the source command so the runtime sees plugins and -middleware from the working tree: +For source checkouts, use the [PM developer workflow](../../website/docs/reference/package-management.md#developer-workflow) +and a separate development home so the runtime sees plugins and middleware from +the working tree: ```bash -uv sync -uv run hermes plugins enable -uv run hermes chat --query 'Reply exactly ok' +export HERMES_HOME="$HOME/hermes-middleware-test" +export HERMES_RUNTIME_DIR="$HERMES_HOME/tools" +source ./activate +python hermes plugins enable +python hermes chat --query 'Reply exactly ok' ``` ## Generic Plugin Examples diff --git a/evals/codebase_navigability/README.md b/evals/codebase_navigability/README.md index 4e43d7bfbc..04cbdfab5c 100644 --- a/evals/codebase_navigability/README.md +++ b/evals/codebase_navigability/README.md @@ -30,7 +30,9 @@ numbers. Everything here is offline and deterministic; no model calls. ## Usage ```bash -# deps: tiktoken + radon (bench venv or the project venv) +# Independent benchmark environment only — never Hermes's selected environment. +uv venv /tmp/hermes-navigability-bench +source /tmp/hermes-navigability-bench/bin/activate uv pip install tiktoken radon # 1 + 2: pass two checkouts (git worktree add is the easy way to get the baseline) @@ -46,6 +48,12 @@ NAV_OUT=out/ python evals/codebase_navigability/runtime_bench.py /tmp/base base NAV_OUT=out/ python evals/codebase_navigability/runtime_bench.py . head 9 ``` +Use a fresh benchmark path; do not replace an existing environment. Runtime and +pytest-collection measurements also require the target tree's application/test +dependencies. Prepare those in a separate caller-owned output with +`python -m pm.build_env --source --out --extra dev --group test` +from a PM-prepared checkout, rather than injecting benchmark packages into Hermes. + `bench.py` and `static_metrics.py` take ~2 min each on a 1M-line tree; `lookup_sim.py` ~10 min for 4,000 symbols (it tokenizes every window it "reads"); `runtime_bench.py` ~4 min per tree at 9 reps. diff --git a/gateway/run.py b/gateway/run.py index d394bc3beb..35ab82ca1a 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -400,53 +400,6 @@ _GATEWAY_SECRET_PATTERNS = ( re.compile(r"(?i)\b(Bearer\s+)[A-Za-z0-9._\-]{20,}\b")) -def _ensure_windows_gateway_venv_imports() -> None: - """Make detached Windows gateway runs see the Hermes venv packages. - - Patched before MCP discovery so tool injection does not depend on launchers preserving PYTHONPATH.""" - if sys.platform != "win32": - return - - project_root = Path(__file__).resolve().parent.parent - candidates: list[Path] = [] - if os.environ.get("VIRTUAL_ENV"): - candidates.append(Path(os.environ["VIRTUAL_ENV"])) - candidates.append(project_root / "venv") - - seen: set[str] = set() - for venv_dir in candidates: - try: - resolved_venv = venv_dir.resolve() - except OSError: - resolved_venv = venv_dir - venv_key = str(resolved_venv).lower() - if venv_key in seen: - continue - seen.add(venv_key) - - site_packages = resolved_venv / "Lib" / "site-packages" - if not site_packages.exists(): - continue - - project_entry = str(project_root) - site_entry = str(site_packages) - if project_entry not in sys.path: - sys.path.insert(0, project_entry) - # addsitedir semantics matter: pywin32 (MCP SDK on Windows) needs .pth processing for pywintypes. - site.addsitedir(site_entry) - if site_entry in sys.path: - sys.path.remove(site_entry) - insert_at = 1 if sys.path and sys.path[0] == project_entry else 0 - sys.path.insert(insert_at, site_entry) - - os.environ["VIRTUAL_ENV"] = str(resolved_venv) - pythonpath = [project_entry, site_entry] - if os.environ.get("PYTHONPATH"): - pythonpath.append(os.environ["PYTHONPATH"]) - os.environ["PYTHONPATH"] = os.pathsep.join(dict.fromkeys(pythonpath)) - return - - def _gateway_platform_value(platform: Any) -> str: """Return a normalized gateway platform value for enums or raw strings.""" return str(getattr(platform, "value", platform) or "").strip().lower() @@ -5341,7 +5294,7 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool = _best_effort(_lifecycle_record_startup, "Lifecycle ledger startup record failed: %s") _best_effort(_start_keepalive, "Nous auth keepalive did not start: %s") - _ensure_windows_gateway_venv_imports() + # discover_mcp_tools() blocks up to 120s; on the loop thread it would freeze platform heartbeats. try: diff --git a/gateway/run_shutdown.py b/gateway/run_shutdown.py index a25b025072..71ddcc4812 100644 --- a/gateway/run_shutdown.py +++ b/gateway/run_shutdown.py @@ -1275,19 +1275,11 @@ class GatewayShutdownMixin: # The watcher runs sys.executable (console python) under the CREATE_NO_WINDOW detach kwargs below: # it owns one hidden console, inherited by the `hermes gateway restart` child, so nothing flashes. # See #54220, #56747. - watcher_python = sys.executable - venv_dir = Path(watcher_env.get("VIRTUAL_ENV") or project_root / "venv") - site_packages = venv_dir / "Lib" / "site-packages" - if site_packages.exists(): - watcher_env["VIRTUAL_ENV"] = str(venv_dir) - pythonpath = [str(project_root), str(site_packages)] - if watcher_env.get("PYTHONPATH"): - pythonpath.append(watcher_env["PYTHONPATH"]) - watcher_env["PYTHONPATH"] = os.pathsep.join(dict.fromkeys(pythonpath)) - watcher_argv = [ - watcher_python, "-c", _WINDOWS_RESTART_WATCHER, - str(current_pid), str(restart_after_s), *hermes_cmd, "gateway", "restart", - ] + from hermes_cli._launchers import runtime_command + watcher_argv = runtime_command(project_root, + [str(current_pid), str(restart_after_s), *hermes_cmd, "gateway", "restart"], + code=_WINDOWS_RESTART_WATCHER) + watcher_python = watcher_argv[0] popen_kwargs = dict(stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, env=watcher_env) # Break away from the parent CLI's job object or be reaped when the CLI exits; a job without # BREAKAWAY_OK rejects CREATE_BREAKAWAY_FROM_JOB (OSError) — retry once without the bit. diff --git a/gateway/status.py b/gateway/status.py index d90cd87190..ae7bb4cd83 100644 --- a/gateway/status.py +++ b/gateway/status.py @@ -46,11 +46,6 @@ _gateway_running_pid_cache: dict[tuple[str, bool, bool], tuple[float, tuple, Opt logger = logging.getLogger(__name__) -def profile_flag_value(command: str) -> Optional[str]: - # Shim to suppress old updater work until relaunch. Do not select a profile. - return None - - class StormInfo(NamedTuple): """Respawn-storm check result: start count, window, and backoff to sleep.""" diff --git a/hermes_cli/_install_repair.py b/hermes_cli/_install_repair.py index 3738bf068b..d94824487d 100644 --- a/hermes_cli/_install_repair.py +++ b/hermes_cli/_install_repair.py @@ -124,45 +124,45 @@ def ensure_windows_bin_launchers( def _launcher_present(target: Path, name: str) -> bool: return (target / f"{name}.exe").exists() or (target / f"{name}.cmd").exists() - # Launchers boot the pm STORE python with PYTHONPATH=repo;site-packages - # — never the venv interpreter (no boot through the venv; pyvenv.cfg is - # inert dead config). mint_launcher prefers a distlib exe trampoline - # bound to the store python; a runtime-resolving .cmd is written when - # the store has not materialized a python yet, and the repair upgrades - # it (and any legacy copied-venv trampoline) to the exe once the store - # python exists. See hermes_cli/_launchers.py. + # Only the launch producer knows the executable/boot contract. Old venv + # paths below identify obsolete artifacts; they never select dependencies. from hermes_cli._launchers import ( + ensure_install_launchers, exe_is_venv_bound, - mint_launcher, - resolve_store_python, stage_launcher, ) from hermes_constants import project_venv_dir venv_dir = project_venv_dir(root) - from hermes_cli.runtime_paths import site_packages as dependency_site - - site_packages = dependency_site(venv_dir) if venv_dir else None - - store_python = resolve_store_python(root) def _needs_attention(target: Path, name: str) -> bool: """Missing, a placeholder .cmd, or a launcher that still boots the venv interpreter — anything the store-python launcher should replace.""" exe = target / f"{name}.exe" if not exe.exists(): - return True + return not ((target / f"{name}.cmd").is_file() + and _launcher_present(root / ".hermes" / "bin", name)) return exe_is_venv_bound(exe, venv_dir) targets: list[Path] = [] + restored: list[str] = [] # Canonical target — gate on the managed-clone shape. This runs at # every hermes_cli.main process start (right after the profile # override), so the healthy path must stay at a couple of stat calls. if _normalize_windows_path(root.parent) == _normalize_windows_path(home): canonical = home / "bin" - if any( + local = root / ".hermes" / "bin" + if any(not _launcher_present(local, name) for name in _WINDOWS_BIN_LAUNCHERS): + # Upgrade existing PM installs too: their healthy external launcher + # predates the exact-install command and may lack the runtime query. + try: + canonical.mkdir(parents=True, exist_ok=True) + restored.extend(ensure_install_launchers(root, canonical)) + except OSError: + return [] + if not restored and any( _needs_attention(canonical, name) for name in _WINDOWS_BIN_LAUNCHERS ): targets.append(canonical) @@ -182,9 +182,8 @@ def ensure_windows_bin_launchers( targets.append(legacy) if not targets: - return [] + return restored - restored: list[str] = [] for target in targets: try: target.mkdir(parents=True, exist_ok=True) @@ -195,10 +194,7 @@ def ensure_windows_bin_launchers( # Already a store-python launcher (or a form this heal does # not understand but that does not boot the venv): leave it. continue - if store_python is not None: - final = mint_launcher(name, root, target, store_python, site_packages) - else: - final = stage_launcher(name, root, target) + final = stage_launcher(name, root, target) if final is not None: # Windows resolves .exe before .cmd. A surviving venv-bound # launcher would shadow the successfully staged fallback. diff --git a/hermes_cli/_launchers.py b/hermes_cli/_launchers.py index 914f02f510..7c8d081626 100644 --- a/hermes_cli/_launchers.py +++ b/hermes_cli/_launchers.py @@ -20,8 +20,60 @@ from pathlib import Path if __name__ == "__main__": sys.path.insert(0, str(Path(__file__).resolve().parents[1])) -from hermes_constants import get_hermes_home, project_venv_dir -from hermes_cli.runtime_paths import site_packages, store_root +from hermes_constants import get_hermes_home +from hermes_cli.runtime_paths import store_root + + +def runtime_command(repo_root: Path, args=(), *, module: str = "hermes_cli.main", + code: str | None = None, python: str | Path | None = None, + home: str | Path | None = None) -> list[str]: + """An installation-bound command, safe to persist across dependency GC. + + Store Python owns the ABI; bootstrap selects and leases dependencies at + child start. Nix and developer interpreters retain their external owner. + No selected generation or ambient PYTHONPATH is captured in the command. + """ + root = Path(repo_root).resolve() + python = python or resolve_store_python(root) or Path(sys.executable) + entry = f"exec({code!r})" if code is not None else ( + f"runpy.run_module({module!r}, run_name='__main__', alter_sys=True)") + bootstrap = ( + "import os, sys, runpy; " + f"os.environ['HERMES_HOME'] = os.environ.get('HERMES_HOME') or {str(home or get_hermes_home())!r}; " + "os.environ.pop('PYTHONHOME', None); os.environ.pop('PYTHONPATH', None); " + "os.environ.pop('VIRTUAL_ENV', None); " + f"sys.path.insert(0, {str(root)!r}); " + "import hermes_bootstrap; " + + entry + ) + return [str(python), "-I", "-c", bootstrap, *args] + + +def print_runtime_command(repo_root: Path, argv: list[str]) -> None: + """Machine boundary for consumers holding the exact published launcher.""" + import argparse + + parser = argparse.ArgumentParser(description="Resolve this installation's launch command.") + parser.add_argument("--module", default="hermes_cli.main") + parser.add_argument("args", nargs=argparse.REMAINDER) + options = parser.parse_args(argv) + args = options.args[1:] if options.args[:1] == ["--"] else options.args + print(json.dumps(runtime_command(repo_root, args, module=options.module))) + + +def installation_command(repo_root: Path, args=(), *, module: str = "hermes_cli.main", + python: str | Path | None = None, home: str | Path | None = None) -> list[str]: + """Persist a source launcher, never the versioned tool it currently uses. + + External/Nix installs retain their externally owned interpreter contract. + Source installation/update publication refreshes the local launcher when + the managed Python pin changes. + """ + root = Path(repo_root) + if resolve_store_python(root) is None: + return runtime_command(root, args, module=module, python=python, home=home) + prefix = [] if module == "hermes_cli.main" else ["--run-module", module] + return [str(root / ".hermes" / "bin" / "hermes"), *prefix, *args] #: Launcher command names — keep in lockstep with scripts/install.ps1 #: Stage-Path and hermes_cli/_install_repair.py. @@ -40,9 +92,7 @@ def _is_windows() -> bool: def resolve_store_python(repo_root: Path) -> Path | None: - """The interpreter the store installed from the ``python`` package - (facts.json entry first, newest ``python-*`` entry as fallback), or - None when `hermes pm install` has not materialized one yet.""" + """Read PM's committed Python tool, without adopting unrecorded bytes.""" runtime = store_root(repo_root) rel = "python.exe" if _is_windows() else "bin/python3" @@ -60,10 +110,6 @@ def resolve_store_python(repo_root: Path) -> Path | None: if candidate.is_file(): return candidate - for entry_dir in sorted(runtime.glob("python-*"), key=lambda p: p.name): - candidate = entry_dir / rel - if candidate.is_file(): - return candidate return None @@ -182,8 +228,19 @@ def _launcher_script(name: str, repo_root: Path, dependencies: Path | None) -> s "os.environ.pop('PYTHONHOME', None)\n" "os.environ.pop('PYTHONPATH', None)\n" f"sys.path.insert(0, {str(repo_root.resolve())!r})\n" - + (f"sys.path.append({str(dependencies)!r})\n" if dependencies else "") - + "import hermes_bootstrap\n" + "if sys.argv[1:2] == ['--print-runtime-command']:\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" + " sys.exit(0)\n" + "import hermes_bootstrap\n" + "if sys.argv[1:2] == ['--run-module']:\n" + " import runpy\n" + " if len(sys.argv) < 3: sys.exit('hermes: --run-module needs a module')\n" + " module = sys.argv.pop(2)\n" + " del sys.argv[1]\n" + " runpy.run_module(module, run_name='__main__', alter_sys=True)\n" + " sys.exit(0)\n" f"from {module} import {func}\n" "sys.argv[0] = re.sub(r'(-script\\.pyw|\\.exe)?$', '', sys.argv[0])\n" f"sys.exit({func}())\n" @@ -201,83 +258,49 @@ def _mint_shell_launcher(name: str, out_dir: Path, python_exe: Path, script: str def stage_launcher(name: str, repo_root: Path, out_dir: Path) -> Path | None: - """Publish one launcher bound to the current store interpreter. - - Windows repair retains a command-file fallback when the store is absent. - The standalone install writer refuses that incomplete state. - """ + """Publish one launcher bound to store Python, or refuse missing tools.""" repo_root = Path(repo_root) - venv_dir = project_venv_dir(repo_root) - dependencies = site_packages(venv_dir) if venv_dir else None store_python = resolve_store_python(repo_root) if store_python is not None: - path = mint_launcher(name, repo_root, out_dir, store_python, dependencies) - if path is not None: - return path - if not _is_windows(): - return None - return _write_runtime_cmd(name, repo_root, dependencies, out_dir) + path = mint_launcher(name, repo_root, out_dir, store_python, None) + if path is not None and path.suffix == ".cmd": + # cmd.exe prefers .exe. An older launcher must not shadow the + # newly published command when distlib is unavailable. + try: + (Path(out_dir) / f"{name}.exe").unlink(missing_ok=True) + except OSError: + return None + return path + return None def ensure_install_launchers(repo_root: Path, out_dir: Path) -> list[str]: - """Stage/refresh every launcher in WINDOWS_BIN_LAUNCHERS — see - :func:`stage_launcher` for the per-name contract.""" + """Publish exact-install commands and their user-bin conveniences. + + Installers/updaters use the local command, since a shared HOME/bin may + have been repointed to another checkout. Return the requested outputs. + """ repo_root = Path(repo_root) + local = repo_root / ".hermes" / "bin" + local.mkdir(parents=True, exist_ok=True) written: list[str] = [] for name in WINDOWS_BIN_LAUNCHERS: + if Path(out_dir).resolve() != local.resolve(): + if stage_launcher(name, repo_root, local) is None: + continue path = stage_launcher(name, repo_root, Path(out_dir)) if path is not None: written.append(str(path)) return written -def _write_runtime_cmd( - name: str, - repo_root: Path, - site_packages: Path | None, - out_dir: Path, -) -> Path | None: - """The boot-time-resolving .cmd fallback (fresh install, no store - interpreter yet): glob ``\\python-*`` for the store python at - boot, compose PYTHONPATH, and fail with a clear message when the store - is empty. Never references the venv interpreter. The ``endlocal & set`` - idiom hoists the boot-resolved interpreter and PYTHONPATH out of the - setlocal scope (percent expansion happens while setlocal is still - active, before endlocal executes).""" - module, func = ENTRY_POINTS[name] - site = "" - if site_packages is not None: - site = ( - ";%HERMES_REPO%\\" - + str(site_packages.relative_to(repo_root)).replace("/", "\\") - ) - body = ( - "@echo off\r\n" - "chcp 65001 >nul\r\n" - "setlocal\r\n" - f'set "HERMES_REPO={repo_root}"\r\n' - 'set "PM_RT=%HERMES_RUNTIME_DIR%"\r\n' - 'if not defined PM_RT set "PM_RT=%LOCALAPPDATA%\\hermes\\tools"\r\n' - 'set "PM_PY="\r\n' - 'for /d %%D in ("%PM_RT%\\python-*") do if exist "%%D\\python.exe" set "PM_PY=%%D\\python.exe"\r\n' - 'if not defined PM_PY (\r\n' - " echo hermes: no pm store interpreter under %PM_RT% - run \"hermes pm install\" first 1>&2\r\n" - " exit /b 1\r\n" - ")\r\n" - f'endlocal & set "PYTHONPATH=%HERMES_REPO%{site}" & set "PM_PY=%PM_PY%"\r\n' - 'set "PYTHONHOME="\r\n' - f'set "PM_ENTRY=import sys; import hermes_bootstrap; from {module} import {func}; sys.exit({func}())"\r\n' - '"%PM_PY%" -c "%PM_ENTRY%" %*\r\n' - ) - return _write_atomic( - Path(out_dir) / f"{name}.cmd", - lambda p: p.write_text(body, encoding="utf-8"), - ) - - if __name__ == "__main__": import argparse + if sys.argv[1:2] == ["--print-runtime-command"]: + print_runtime_command(Path(__file__).resolve().parents[1], sys.argv[2:]) + raise SystemExit(0) + parser = argparse.ArgumentParser(description="Publish source-install launchers.") parser.add_argument("out_dir", type=Path) args = parser.parse_args() diff --git a/hermes_cli/_old_updater.py b/hermes_cli/_old_updater.py index 785c1c6d08..89a4bca1c8 100644 --- a/hermes_cli/_old_updater.py +++ b/hermes_cli/_old_updater.py @@ -7,7 +7,7 @@ from typing import NoReturn 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." + "You're updating from an older version of Hermes Agent. " "To complete this update, run `hermes` again.", file=sys.stderr, ) diff --git a/hermes_cli/boot_bootstrap.py b/hermes_cli/boot_bootstrap.py index 5a7a164770..0b0ef9f5ac 100644 --- a/hermes_cli/boot_bootstrap.py +++ b/hermes_cli/boot_bootstrap.py @@ -103,17 +103,11 @@ def _git_binary() -> str | None: boot import graph for a lookup most platforms answer from PATH. """ try: - from pm.ensure import _facts, _store - from pm.registry import get_package - from pm.store import current_target + from pm import installed_package - fact = _facts().get("git") - if fact is not None: - binary = get_package("git").binary( - _store().entry(fact["entry"]), current_target() - ) - if binary is not None and binary.is_file(): - return str(binary) + installed = installed_package("git") + if installed is not None and installed.binary is not None: + return str(installed.binary) except Exception as exc: # noqa: BLE001 — boot must not die on a lookup logger.debug("pm git lookup failed: %s", exc) import shutil diff --git a/hermes_cli/dep_ensure.py b/hermes_cli/dep_ensure.py deleted file mode 100644 index d343872b04..0000000000 --- a/hermes_cli/dep_ensure.py +++ /dev/null @@ -1,59 +0,0 @@ -"""Lazy bootstrapper for non-Python runtime deps, routed through pm. - -The old shape spawned install.sh/install.ps1 with an --ensure flag; the -rewritten bootstraps no longer install tools ("heavy deps are pm's job"), -so this module is now a thin adapter: each dep maps to the pm packages -that provide it, pm's lazy-install policy decides whether installing is -allowed right now, and pm's InstallError carries the remedy when not. - -Deps that degrade gracefully (ffmpeg → skip conversion) are not wired -here — only hard-fail sites call ensure_dependency (TUI needs node, -browser tools need the browser stack). -""" -from __future__ import annotations - -import shutil - -from hermes_constants import find_node_executable - -# dep name -> (availability check, pm packages that provide it) -_DEPS = { - "node": (lambda: find_node_executable("node") is not None, ("node",)), - "browser": (lambda: _browser_available(), ("agent-browser", "chromium")), - "ripgrep": (lambda: shutil.which("rg") is not None, ("ripgrep",)), -} - - -def _browser_available() -> bool: - from hermes_constants import agent_browser_runnable - - if agent_browser_runnable(shutil.which("agent-browser")): - return True - try: - import pm - - return pm.is_installed("agent-browser") - except Exception: - return False - - -def ensure_dependency(dep: str, interactive: bool = True) -> bool: - """Ensure a non-Python dependency is available. Returns True if available.""" - entry = _DEPS.get(dep) - if entry is None: - return False - check, packages = entry - if check(): - return True - - try: - import pm - - for name in packages: - pm.ensure(name) - except Exception as exc: - if interactive: - print(f" {exc}") - return False - return check() - diff --git a/hermes_cli/desktop_update_verify.py b/hermes_cli/desktop_update_verify.py index a847f889cf..d1f7a3f8a1 100644 --- a/hermes_cli/desktop_update_verify.py +++ b/hermes_cli/desktop_update_verify.py @@ -93,3 +93,7 @@ def verify_windows_desktop_update(project_root: Path | None = None) -> None: _verify_packaged_entry(executable.parent / "resources") if _desktop_build_needed(desktop, project_root, source_mode=False): raise RuntimeError("The updated Desktop build is stale, unstamped, or incomplete") + + +if __name__ == "__main__": + verify_windows_desktop_update() diff --git a/hermes_cli/doctor_live.py b/hermes_cli/doctor_live.py index 1a391d5dee..23def87d0d 100644 --- a/hermes_cli/doctor_live.py +++ b/hermes_cli/doctor_live.py @@ -57,29 +57,11 @@ def _http_get(url: str, headers: Optional[dict] = None, timeout: Optional[float] def _browser_available() -> bool: """Is the local browser automation backend (agent-browser) installed?""" - import shutil - if shutil.which("agent-browser"): - return True try: - from hermes_cli.doctor import HERMES_HOME, PROJECT_ROOT - if (PROJECT_ROOT / "node_modules" / "agent-browser").exists(): - return True - for candidate in (HERMES_HOME / "node" / "bin", HERMES_HOME / "node", HERMES_HOME / "node_modules" / ".bin"): - if shutil.which("agent-browser", path=str(candidate)): - return True - except Exception: - pass - # agent-browser resolves lazily via npx on the default install (#43564), - # invisible to the PATH/node_modules probes above. Mirror the rung - # hermes_cli.doctor uses so this probe can't diverge from it. - # MERGE-CHECK: upstream moved these fns to tools.browser_tool_install; termux - # carve-out (_requires_real_termux_browser_install) dropped per merge brief. - try: - from tools.browser_tool_install import _find_agent_browser, _is_npx_agent_browser_sentinel - browser_cmd = _find_agent_browser(validate=False) + from tools.browser_tool_install import _find_agent_browser + return bool(_find_agent_browser(validate=False)) except Exception: return False - return _is_npx_agent_browser_sentinel(browser_cmd) def _launch_browser_probe(timeout: float) -> tuple: diff --git a/hermes_cli/doctor_tools.py b/hermes_cli/doctor_tools.py index a3cbae8e1b..77f1ee1992 100644 --- a/hermes_cli/doctor_tools.py +++ b/hermes_cli/doctor_tools.py @@ -12,7 +12,7 @@ from pathlib import Path from hermes_cli.doctor_platform import _system_package_install_cmd from hermes_cli.doctor_report import Finding, _fail_and_issue, check_bool, check_info, check_ok, check_warn, doctor_check from hermes_cli.vercel_auth import describe_vercel_auth -from hermes_constants import agent_browser_runnable, is_termux as _is_termux +from hermes_constants import is_termux as _is_termux def _safe_which(cmd: str) -> str | None: @@ -316,37 +316,28 @@ def _check_terminal_backend(should_fix: bool, f: Finding) -> None: def _check_agent_browser(should_fix: bool) -> bool: - """agent-browser resolution; returns True when browser tools will find a usable install. - - Mirrors ``tools.browser_tool_install._find_agent_browser``'s own cascade (lazy npx or a global/Hermes-managed - install) so doctor can't diverge from the tools; validate=False keeps it a cheap, side-effect-free check. - """ + """Read the runtime's installed selection; only --fix may acquire through PM.""" try: - # agent-browser is no longer a root package.json dependency (#43564) — it resolves lazily via npx - # (or a global/Hermes-managed install) at first use. - from tools.browser_tool_install import _find_agent_browser, _is_npx_agent_browser_sentinel + from tools.browser_tool_install import _find_agent_browser resolved = _find_agent_browser(validate=False) except Exception: resolved = None - if resolved and _is_npx_agent_browser_sentinel(resolved): - check_ok("agent-browser", "(resolves via npx on first use)") - if should_fix: - # Can't tell whether npx's cache is warm — fire the same warm-up `hermes update` does. - from tools.browser_tool_install import warm_agent_browser_npx_cache - check_info(" Warmed npx cache for agent-browser" if warm_agent_browser_npx_cache() - else " Could not warm npx cache (offline or npx unavailable)") - return True - if resolved and agent_browser_runnable(resolved): - check_ok("agent-browser", "(browser automation)") - return True + if not resolved and should_fix and not _is_termux(): + try: + import pm + from tools.browser_tool_install import _find_agent_browser + pm.ensure("agent-browser", explicit=True) + resolved = _find_agent_browser(validate=False) + except Exception as exc: + check_warn("agent-browser install failed", f"({exc})") if resolved: - # Almost always a dangling global symlink left by npm postinstall after `hermes update` wiped node_modules. - check_warn("agent-browser found but not runnable", f"(broken symlink at {resolved}? run: npx agent-browser --version)") - elif _is_termux(): + check_ok("agent-browser", f"({resolved})") + return True + if _is_termux(): _termux_browser_hints("agent-browser is not installed (expected in the tested Termux path)", "Install it manually later with: npm install -g agent-browser && agent-browser install", node_installed=True) else: - check_warn("agent-browser not installed", "(requires npm/npx on PATH)") + check_warn("agent-browser not installed", "(run: hermes pm install agent-browser)") return False @@ -406,13 +397,13 @@ def _check_node_and_browser(should_fix: bool, f: Finding) -> None: """Node.js, agent-browser resolution, Playwright Chromium, Lightpanda engine.""" if _safe_which("node"): check_ok("Node.js") - if _check_agent_browser(should_fix) and not _is_termux(): # Chromium check is not a tested Termux path - _check_chromium() elif _is_termux(): _termux_browser_hints("Node.js not found (browser tools are optional in the tested Termux path)", "Install Node.js on Termux with: pkg install nodejs", node_installed=False) else: - check_warn("Node.js not found", "(optional, needed for browser tools)") + check_warn("Node.js not found", "(optional; PM agent-browser is a native executable)") + if _check_agent_browser(should_fix) and not _is_termux(): + _check_chromium() _check_lightpanda() diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index 1d3f7cf55b..957fe88c29 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -879,11 +879,12 @@ def find_windows_gateway_services( def _gateway_run_args_for_profile(profile: str) -> list[str]: - args = [get_python_path(), "-m", "hermes_cli.main"] + from hermes_cli._launchers import runtime_command + args = [] if profile != "default": args.extend(["--profile", profile]) args.extend(["gateway", "run", "--replace"]) - return args + return runtime_command(PROJECT_ROOT, args) def _capture_gateway_argv(pid: int) -> list[str] | None: @@ -2646,39 +2647,10 @@ def launchd_gateway_labels_for_install() -> list[str]: return root_label + sorted(profile_labels) -def _detect_venv_dir() -> Path | None: - """Active virtualenv dir: pm-provisioned runtime venv first (facts + store - layout — the authority under no-boot-through-venv, where ``sys.prefix`` no - longer distinguishes and bundled installs carry no ``VIRTUAL_ENV``), then - ``sys.prefix`` / ``VIRTUAL_ENV`` / .venv/venv under PROJECT_ROOT.""" - pm_venv = _pm_runtime_venv_dir() - if pm_venv is not None: - return pm_venv - candidates: list[Path] = [] - if sys.prefix != sys.base_prefix: - candidates.append(Path(sys.prefix)) - if os.environ.get("VIRTUAL_ENV"): - candidates.append(Path(os.environ["VIRTUAL_ENV"])) - candidates += [PROJECT_ROOT / ".venv", PROJECT_ROOT / "venv"] - return next((venv for venv in candidates if venv.is_dir()), None) - - def get_python_path() -> str: - venv = _detect_venv_dir() - if venv is not None: - try: - from hermes_constants import venv_python_path - except ImportError: - # Update-boundary: a gateway restarted mid-update can hold a stale hermes_constants - # without this symbol; reload its definitions from the updated checkout. - import importlib - import hermes_constants - venv_python_path = importlib.reload(hermes_constants).venv_python_path + from hermes_cli._launchers import resolve_store_python - venv_python = venv_python_path(venv, windows=is_windows()) - if venv_python.exists(): - return str(venv_python) - return sys.executable + return str(resolve_store_python(PROJECT_ROOT) or sys.executable) # ============================================================================= @@ -2768,20 +2740,7 @@ def _build_service_path_dirs(project_root: Path | None = None) -> list[str]: return False candidates = [] - # pm-provisioned runtime venv first — the same authority _detect_venv_dir() - # trusts: under no-boot-through-venv sys.prefix equals base_prefix and the - # committed environment is the only record of which venv serves this install. - pm_venv = _pm_runtime_venv_dir(project_root) - if pm_venv is not None: - pm_bin = pm_venv / ("Scripts" if is_windows() else "bin") - if _is_dir(pm_bin): - candidates.append(str(pm_bin)) - if not candidates: - venv_bin = project_root / "venv" / "bin" - if _is_dir(venv_bin): - candidates.append(str(venv_bin)) - elif sys.prefix != sys.base_prefix: - candidates.append(str(Path(sys.prefix) / "bin")) + # Python and dependency executable paths are selected at boot, not persisted. hermes_home = get_hermes_home() extras = (project_root / "node_modules" / ".bin", hermes_home / "node" / "bin", hermes_home / "node_modules" / ".bin") @@ -2886,16 +2845,48 @@ def _append_node_dir_for_service(path_entries: list[str], hermes_root: Path | No path_entries.append(resolved_node_dir) -def _service_venv_dir() -> str: - """VIRTUAL_ENV baked into service definitions: detected venv, else ``PROJECT_ROOT/venv``.""" - detected_venv = _detect_venv_dir() - return str(detected_venv) if detected_venv else str(PROJECT_ROOT / "venv") +def _systemd_command(argv: list[str]) -> str: + """Quote argv for systemd, including its non-shell specifier expansion.""" + return " ".join('"' + part.replace("\\", "\\\\").replace('"', '\\"') + .replace("%", "%%").replace("$", "$$") + '"' for part in argv) + + +def _prepare_service_launcher(*, system: bool = False, run_as_user: str | None = None) -> None: + """Publish the source command before a service definition references it.""" + from hermes_cli._launchers import ENTRY_POINTS, ensure_install_launchers, resolve_store_python + from hermes_constants import set_hermes_home_override, reset_hermes_home_override + + root, home = PROJECT_ROOT, get_hermes_home() + owner = None + if system: + username, _group, home_dir, uid = _system_service_identity(run_as_user) + root = Path(_remap_path_for_user(str(root), home_dir)) + home = Path(_hermes_home_for_target_user(home_dir)) + owner = (uid, username) + token = set_hermes_home_override(home) + try: + if resolve_store_python(root) is None: + return # Externally owned Nix/developer runtime. + local = root / ".hermes" / "bin" + paths = ensure_install_launchers(root, local) + if len(paths) != len(ENTRY_POINTS): + raise RuntimeError("Could not publish the gateway installation launcher") + if owner is not None: + import pwd + uid, username = owner + gid = pwd.getpwnam(username).pw_gid + for path in (local.parent, local, *map(Path, paths)): + os.chown(path, uid, gid) + finally: + reset_hermes_home_override(token) def generate_systemd_unit(system: bool = False, run_as_user: str | None = None) -> str: + from hermes_cli._launchers import installation_command + python_path = get_python_path() working_dir = _stable_service_working_dir() - venv_dir = _service_venv_dir() + project_root = PROJECT_ROOT path_entries = _build_service_path_dirs() if not system: @@ -2918,7 +2909,7 @@ def generate_systemd_unit(system: bool = False, run_as_user: str | None = None) # Remap paths under the calling user's home (/root/) to the target user's so the service can read them. python_path = _remap_path_for_user(python_path, home_dir) working_dir = str(hermes_home) if hermes_home else _remap_path_for_user(working_dir, home_dir) - venv_dir = _remap_path_for_user(venv_dir, home_dir) + project_root = Path(_remap_path_for_user(str(project_root), home_dir)) path_entries = [_remap_path_for_user(p, home_dir) for p in path_entries] # Managed Node for the TARGET user's tree, prepended so it outranks remapped shell-PATH entries. _target_node_entries: list[str] = [] @@ -2951,6 +2942,10 @@ def generate_systemd_unit(system: bool = False, run_as_user: str | None = None) path_entries.extend(_build_wsl_interop_paths(path_entries)) path_entries.extend(["/usr/local/sbin", "/usr/local/bin", "/usr/sbin", "/usr/bin", "/sbin", "/bin"]) sane_path = ":".join(path_entries) + start = installation_command(project_root, [*shlex.split(profile_arg), "gateway", "run"], + python=python_path, home=hermes_home) + cleanup = installation_command(project_root, module="gateway.cgroup_cleanup", + python=python_path, home=hermes_home) return f"""[Unit] Description={SERVICE_DESCRIPTION} After=network-online.target @@ -2959,10 +2954,10 @@ Wants=network-online.target [Service] Type={systemd_type} -{systemd_watchdog_directives}{identity_lines}ExecStart={python_path} -m hermes_cli.main{f" {profile_arg}" if profile_arg else ""} gateway run +{systemd_watchdog_directives}{identity_lines}ExecStart={_systemd_command(start)} WorkingDirectory={working_dir} {env_lines}Environment="PATH={sane_path}" -Environment="VIRTUAL_ENV={venv_dir}" + Environment="HERMES_HOME={hermes_home}" Environment="HERMES_SUPERVISED_CHILD=1" Restart=always @@ -2973,7 +2968,7 @@ RestartPreventExitStatus={GATEWAY_FATAL_CONFIG_EXIT_CODE} KillMode=mixed KillSignal=SIGTERM ExecReload=/bin/kill -USR1 $MAINPID -ExecStopPost=-{python_path} -m gateway.cgroup_cleanup +ExecStopPost=-{_systemd_command(cleanup)} TimeoutStopSec={restart_timeout} StandardOutput=journal StandardError=journal @@ -3086,6 +3081,7 @@ def refresh_systemd_unit_if_needed(system: bool = False) -> bool: if _refuse_temp_home_service_write(new_unit, "systemd unit"): return False + _prepare_service_launcher(system=system, run_as_user=expected_user) unit_path.write_text(new_unit, encoding="utf-8") _run_systemctl(["daemon-reload"], system=system, check=True, timeout=30) print(f"↻ Updated gateway {_service_scope_label(system)} service definition to match the current Hermes install") @@ -3295,6 +3291,7 @@ def systemd_install( if _refuse_temp_home_service_write(new_unit, "systemd unit"): return print(f"Installing {scope_label} systemd service to: {unit_path}") + _prepare_service_launcher(system=system, run_as_user=run_as_user) unit_path.write_text(new_unit, encoding="utf-8") _run_systemctl(["daemon-reload"], system=system, check=True, timeout=30) @@ -3772,8 +3769,9 @@ def _launchd_unsupported_marker_exists() -> bool: def _gateway_run_command() -> list[str]: - """Build ``python -m hermes_cli.main [--profile X] gateway run --replace``, honoring the active profile.""" - return [get_python_path(), "-m", "hermes_cli.main", *_profile_arg().split(), "gateway", "run", "--replace"] + from hermes_cli._launchers import runtime_command + return runtime_command(PROJECT_ROOT, [*shlex.split(_profile_arg()), "gateway", "run", "--replace"], + python=get_python_path()) def _timestamped_stderr_gateway_command(error_log: Path, *, external_supervisor: bool = False) -> list[str]: @@ -3793,12 +3791,17 @@ def _timestamped_stderr_gateway_command(error_log: Path, *, external_supervisor: bootout+bootstrap in install/refresh), which run before supervision resumes. Mirrors ``generate_systemd_unit``, whose ExecStart also runs ``gateway run`` without ``--replace``. """ + from hermes_cli._launchers import installation_command, runtime_command inner = _gateway_run_command() if external_supervisor: + inner = installation_command(PROJECT_ROOT, [*shlex.split(_profile_arg()), "gateway", "run"], + python=get_python_path()) inner = [part for part in inner if part != "--replace"] if "--external-supervisor" not in inner: inner.append("--external-supervisor") - return [get_python_path(), "-m", "hermes_cli.stderr_timestamp", "--error-log", str(error_log), "--", *inner] + command = installation_command if external_supervisor else runtime_command + return command(PROJECT_ROOT, ["--error-log", str(error_log), "--", *inner], + module="hermes_cli.stderr_timestamp", python=get_python_path()) def _spawn_detached_gateway() -> bool: @@ -3852,13 +3855,14 @@ def _launchd_degrade_or_raise(exc: subprocess.CalledProcessError, what: str) -> def generate_launchd_plist() -> str: + from html import escape # Stable cwd anchor — never the volatile source checkout (same rot risk as systemd's WorkingDirectory). working_dir = _stable_service_working_dir() hermes_home = str(get_hermes_home().resolve()) log_dir = get_hermes_home() / "logs" log_dir.mkdir(parents=True, exist_ok=True) label = get_launchd_label() - venv_dir = _service_venv_dir() + # launchd's default PATH misses Homebrew, nvm, cargo…; prepend venv/bin + node dirs (as in the # systemd unit) so node stays resolvable even if the shell PATH changes, then the shell PATH. priority_dirs = _build_service_path_dirs() @@ -3867,7 +3871,7 @@ def generate_launchd_plist() -> str: # ProgramArguments (incl. --profile); the stderr wrapper keeps launchd restart semantics while timestamping stderr. prog_args_xml = "\n ".join( - f"{part}" + f"{escape(part)}" for part in _timestamped_stderr_gateway_command(log_dir / "gateway.error.log", external_supervisor=True) ) @@ -3907,8 +3911,7 @@ def generate_launchd_plist() -> str: PATH {sane_path} - VIRTUAL_ENV - {venv_dir} + HERMES_HOME {hermes_home} HERMES_SUPERVISED_CHILD @@ -4036,6 +4039,7 @@ def refresh_launchd_plist_if_needed() -> bool: if _refuse_temp_home_service_write(new_plist, "launchd plist"): return False + _prepare_service_launcher() plist_path.write_text(new_plist, encoding="utf-8") label = get_launchd_label() domain = _launchd_domain() @@ -4106,6 +4110,7 @@ def launchd_install(force: bool = False): if _refuse_temp_home_service_write(new_plist, "launchd plist"): return print(f"Installing launchd service to: {plist_path}") + _prepare_service_launcher() plist_path.write_text(new_plist, encoding="utf-8") try: @@ -4147,6 +4152,7 @@ def launchd_start(): sys.exit(1) print("↻ launchd plist missing; regenerating service definition") plist_path.parent.mkdir(parents=True, exist_ok=True) + _prepare_service_launcher() plist_path.write_text(new_plist, encoding="utf-8") if _launchd_bootstrap_and_kickstart(plist_path, label): _launchd_ok("✓ Service started") diff --git a/hermes_cli/main.py b/hermes_cli/main.py index ba9685f7bb..a2ea0df684 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -2316,9 +2316,7 @@ def cmd_update(args): _finalize_update_output(_update_io_state) sys.exit(UPDATE_EXIT_CONCURRENT) - # Exit code for the Windows hand-off child's hard exit (see finally); None - # = not SystemExit-shaped, so real exceptions keep their traceback. - _update_handoff_exit_code: int | None = None + from hermes_cli.update_cmd import _cmd_update_impl from pm import InstallError @@ -2330,7 +2328,7 @@ def cmd_update(args): if gateway_mode: from hermes_cli.update_cmd_fleet import _write_gateway_update_exit_code _write_gateway_update_exit_code(False) - _update_handoff_exit_code = 1 + raise SystemExit(1) from exc except SystemExit as _update_exit: # Receipt boundary: the impl has many early sys.exit paths that never @@ -2341,9 +2339,7 @@ def cmd_update(args): if gateway_mode and _code: from hermes_cli.update_cmd_fleet import _write_gateway_update_exit_code _write_gateway_update_exit_code(False) - _update_handoff_exit_code = ( - _update_exit.code if isinstance(_update_exit.code, int) else 0 - ) + raise except BaseException as _update_exc: _finalize_update_receipt(1, f"{type(_update_exc).__name__}: {_update_exc}") @@ -2352,26 +2348,11 @@ def cmd_update(args): from hermes_cli.update_receipt import COMMAND_BOUNDARY_STOP_REASON _finalize_update_receipt(0, COMMAND_BOUNDARY_STOP_REASON) - _update_handoff_exit_code = 0 + finally: _update_lock.release() _finalize_update_output(_update_io_state) - # Windows hand-off child: a leftover non-daemon thread from the update - # tail would freeze the PowerShell window for minutes after the receipt - # is durable. Every durable step is done by now, so on the hand-off - # path only (marker env set solely by - # _reexec_dependency_sync_off_windows_shim) flush and exit hard. - # By this point every durable step is done (receipt finalized above, lock released, stdio restored), - # so on the hand-off path only, flush and exit hard instead of waiting for the interpreter to unwind - # — the same treatment #79040's cron workaround applies. - if _update_handoff_exit_code is not None and os.environ.get(_UPDATE_REEXEC_ENV) == "1": - logger.debug( - "Update hand-off child %s exiting via os._exit(%s)", - os.getpid(), _update_handoff_exit_code, - ) - sys.stdout.flush() - sys.stderr.flush() - os._exit(_update_handoff_exit_code) + def _coalesce_session_name_args(argv: list) -> list: diff --git a/hermes_cli/main_desktop.py b/hermes_cli/main_desktop.py index 79fb074648..a23912c7a6 100644 --- a/hermes_cli/main_desktop.py +++ b/hermes_cli/main_desktop.py @@ -19,8 +19,6 @@ import time as _time_mod from pathlib import Path from typing import Optional -from hermes_cli.main_web_build import ( - _hash_source_tree, _stamp_is_current, _write_build_stamp) # Log-record parity with the origin module. logger = logging.getLogger("hermes_cli.main") @@ -33,17 +31,6 @@ def _desktop_dist_exists(desktop_dir: Path) -> bool: return (desktop_dir / "dist" / "index.html").exists() -def _compute_desktop_content_hash(project_root: Path) -> str: - """SHA-256 of ``apps/desktop/`` (minus .gitignore matches) plus root workspace config.""" - return _hash_source_tree(project_root, project_root / "apps" / "desktop") - - -def _desktop_stamp_path() -> Path: - """Path of the desktop build stamp under $HERMES_HOME.""" - from hermes_constants import get_hermes_home - return get_hermes_home() / "desktop-build-stamp.json" - - def _renderer_bundle_dir(desktop_dir: Path, *, source_mode: bool) -> Optional[Path]: """The renderer ``dist`` a launch loads: ``apps/desktop/dist`` in source mode, else the ``app.asar.unpacked/dist`` copy (the only real directory, and the one an interrupted replace tears).""" @@ -108,16 +95,9 @@ def _desktop_build_needed(desktop_dir: Path, project_root: Path, *, source_mode: print(f" ⚠ A previous update left the desktop bundle incomplete ({dist_dir}); rebuilding it") return True - return not _stamp_is_current( - _desktop_stamp_path(), lambda: _compute_desktop_content_hash(project_root), sourceMode=source_mode - ) + from hermes_cli.source_build import source_product_current - -def _write_desktop_build_stamp(project_root: Path, *, source_mode: bool) -> None: - """Write the desktop build stamp after a successful build.""" - _write_build_stamp( - _desktop_stamp_path(), "desktop", - lambda: _compute_desktop_content_hash(project_root), sourceMode=source_mode) + return dist_dir is None or not source_product_current(project_root, "desktop", dist_dir) def _desktop_packaged_executable(desktop_dir: Path) -> Optional[Path]: @@ -452,7 +432,7 @@ def _rollback_desktop_from_backup(packaged_executable: Path) -> Optional[Path]: def _ensure_desktop_exe_launchable(desktop_dir: Path, packaged_executable: Optional[Path]) -> tuple: """Windows post-build integrity gate → ``(verified_exe_or_None, rolled_back)``: pass → ``(exe, False)``; corrupt with backup restored → ``(old_exe, True)``; nothing restorable → - ``(None, False)``. Failure purges the cached zip + stamp so the retry re-downloads. + ``(None, False)``. Corrupt staged output never replaces the running app. See #69179. """ @@ -465,14 +445,6 @@ def _ensure_desktop_exe_launchable(desktop_dir: Path, packaged_executable: Optio print(f"✗ The built Hermes.exe failed its integrity check: {error}\n at: {packaged_executable}") - # Only the exe's OWN output dir is purged (a staging dir), never the live - # release/ tree that still holds the last working app. - # Self-heal setup for the retry: drop the (likely corrupt) cached Electron zip and the content stamp so - # the next rebuild is a genuine re-download + re-stage rather than a replay of the same broken - # extraction. See #86443. - _purge_electron_build_cache(desktop_dir, release_dir=packaged_executable.parent.parent) - with contextlib.suppress(OSError): - _desktop_stamp_path().unlink() restored = _rollback_desktop_from_backup(packaged_executable) if restored is not None: @@ -1136,30 +1108,33 @@ def _promote_staged_desktop_app(desktop_dir: Path, staging_dir: Path) -> Path: return packaged_executable -def build_prepared_desktop(desktop_dir: Path, *, source_mode: bool, npm: str, env: dict) -> Optional[Path]: +def build_prepared_desktop(desktop_dir: Path, *, source_mode: bool, npm: str, env: dict, + icons: Path | None = None, explicit: bool = False) -> Optional[Path]: """Build prepared desktop sources, then publish the verified staged app.""" - project_root = desktop_dir.parent.parent build_label = "source build" if source_mode else "packaged app" print(f"→ Building desktop {build_label}...") build_env = dict(env) if _force_adhoc_macos_signing(build_env, source_mode=source_mode): print(" → No Developer ID configured; ad-hoc signing this local rebuild " "(CSC_IDENTITY_AUTO_DISCOVERY=false)") - build_cmd = [npm, "run", "build" if source_mode else "pack"] + build_args = (["--icons", str(icons)] if icons else []) + ([] if explicit else ["--on-demand"]) + build_cmd = [npm, "run", "build", "--", *build_args] staging_dir = None if source_mode else _desktop_staging_dir(desktop_dir) if staging_dir is not None: # electron-builder packs in place; only the verified staging tree may # replace the running app, never a failed or incomplete build. - build_cmd += ["--", f"-c.directories.output={staging_dir}"] + stopped = _stop_desktop_processes_locking_build(desktop_dir) if stopped: print(f" ⚠ Stopped running desktop app to free the build output (pid {', '.join(map(str, stopped))})") try: subprocess.run(build_cmd, cwd=desktop_dir, env=build_env, check=True) + if staging_dir is not None: + subprocess.run([npm, "run", "builder", "--", "--dir", "--publish", "never", + f"-c.directories.output={staging_dir}"], cwd=desktop_dir, env=build_env, check=True) packaged_executable = ( _promote_staged_desktop_app(desktop_dir, staging_dir) if staging_dir is not None else None ) - _write_desktop_build_stamp(project_root, source_mode=source_mode) return packaged_executable finally: if staging_dir is not None: @@ -1303,8 +1278,8 @@ def cmd_gui(args: argparse.Namespace): ) npm = None try: - if source_mode or needs_build: - build_env = source_build_env(env) + if needs_build: + build_env = source_build_env(env, explicit=force_build or getattr(args, "build_only", False)) npm = shutil.which("npm", path=build_env["PATH"]) env["PATH"] = build_env["PATH"] if skip_build: @@ -1312,8 +1287,10 @@ def cmd_gui(args: argparse.Namespace): desktop_dir, PROJECT_ROOT, source_mode=source_mode, packaged_executable=packaged_executable ) elif needs_build: - prepare_source_dependencies(PROJECT_ROOT, ("ui-tui", "web", "apps/desktop"), env=build_env) - built = build_prepared_desktop(desktop_dir, source_mode=source_mode, npm=npm, env=build_env) + prepare_source_dependencies(PROJECT_ROOT, ("ui-tui", "web", "apps/desktop"), env=build_env, + explicit=force_build or getattr(args, "build_only", False)) + built = build_prepared_desktop(desktop_dir, source_mode=source_mode, npm=npm, env=build_env, + explicit=force_build or getattr(args, "build_only", False)) if not source_mode: packaged_executable = built else: @@ -1347,7 +1324,17 @@ def cmd_gui(args: argparse.Namespace): if source_mode: print("→ Launching Hermes Desktop from source build...") - launch_command = [npm, "exec", "--", "electron", "."] + # Launch only the prepared runtime. npm exec can provision a missing + # Electron package, including when --skip-build was requested. + electron = _electron_dir(PROJECT_ROOT) + try: + executable = electron / "dist" / (electron / "path.txt").read_text(encoding="utf-8").strip() + if not executable.is_file(): + raise FileNotFoundError(executable) + except OSError as exc: + print(f"✗ Prepared Electron runtime is missing: {exc}") + raise SystemExit(1) from exc + launch_command = [str(executable), "."] else: if packaged_executable is None: print(f"✗ Desktop package build completed but no launchable app was found at: {desktop_dir / 'release'}") diff --git a/hermes_cli/main_install_repair.py b/hermes_cli/main_install_repair.py index df5abc51ba..4ecee5bdee 100644 --- a/hermes_cli/main_install_repair.py +++ b/hermes_cli/main_install_repair.py @@ -1,14 +1,13 @@ -"""Update markers, Windows launcher recovery and subprocess handoff.""" +"""Historical update markers and interrupted Windows launcher recovery.""" -import contextlib import logging import os import shutil -import subprocess import sys import time as _time from pathlib import Path +from typing import NoReturn from hermes_cli import _early_recovery as _early_recovery_mod # Log-record parity with the origin module. @@ -77,123 +76,14 @@ def _clear_lazy_refresh_incomplete_marker() -> None: _clear_marker_file(_lazy_refresh_marker_path(), label="lazy-refresh-incomplete") -def _norm_exe_path(path) -> str: - """Case-folded resolved path, for comparing executables on Windows.""" - try: - return str(Path(path).resolve()).lower() - except OSError: - return str(path).lower() - - -def _windows_shim_in_process_chain() -> Path | None: - """The venv console shim this process runs from or under, if any. - - ``venv\\Scripts\\hermes.exe`` holds itself open (no ``FILE_SHARE_DELETE``) for the whole - process lifetime, so an editable install run from one can never rewrite it. Two probes, since - either can come up empty: own launch paths (argv[0], ``__main__`` file/spec origin — runpy/ - zipapp puts ``\\__main__.py`` there) and psutil ancestry. Candidates are intersected - with the project venv's own shims so a foreign ``hermes.exe`` never matches. - - See #88838, #89599. - """ - if not _is_windows(): - return None - scripts_dir = _venv_scripts_dir() - if scripts_dir is None: - return None - shims = {_norm_exe_path(shim): shim for shim in _hermes_exe_shims(scripts_dir)} - if not shims: - return None - - def _match(candidate) -> Path | None: - path = Path(candidate) - if path.name.lower() == "__main__.py": - path = path.parent - return shims.get(_norm_exe_path(path)) - - main_mod = sys.modules.get("__main__") - candidates = [*sys.argv[:1], *filter(None, ( - getattr(main_mod, "__file__", None), - getattr(getattr(main_mod, "__spec__", None), "origin", None)))] - for candidate in candidates: - matched = _match(candidate) - if matched is not None: - return matched - - with contextlib.suppress(Exception): - import psutil - me = psutil.Process() - for proc in [me] + list(me.parents()): - try: - matched = _match(proc.exe()) - except Exception: - continue - if matched is not None: - return matched - return None - - -def _windows_running_hermes_launcher_locked() -> bool: - """True when a venv ``hermes*.exe`` shim is this process or an ancestor (best-effort).""" - return _windows_shim_in_process_chain() is not None - - -# Set on the re-exec'd child so it can never spawn another one. +# Frozen old-updater import; current updates never detach dependency work. _UPDATE_REEXEC_ENV = "HERMES_UPDATE_REEXEC" -def _reexec_dependency_sync_off_windows_shim() -> bool: - """Hand the dependency sync to the venv interpreter, off the console shim. - - Returns True when a child was spawned and the caller must exit at once (releasing the - shim before the child reaches ``pip install -e .``); False to continue in-process. - - Called at the dependency-sync boundary, NOT at the top of the command: by then the code swap - is done and every interactive question has been answered; only the venv rewrite — the one - step that cannot run inside the shim — remains. Earlier would detach every run (even the - ``Already up to date!`` no-op) and take the prompts along. Waiting on the child deadlocks - (we hold the handle it needs) and Windows has no exec, so the shell returns; the child keeps - the console, prints its own result, and ``--gateway`` writes the true exit code to - ``.update_exit_code``. The child re-runs ``hermes update`` so the sync and its tail happen - exactly once; ``_UPDATE_REEXEC_ENV`` stops it spawning again and stops the "already up to - date" early return from swallowing the sync. ``.update-incomplete`` is already written, so - a child that dies mid-install is finished by the next launch's recovery. - - Called at the dependency-sync boundary, NOT at the top of the command — the same placement rule as the - native-module deferral beside it, and for the same reason (#86735): a hand-off that fires before the - fetch detaches every run, including the ``Already up to date!`` no-op that never touches the venv at - all, and it takes the interactive prompts with it. By the time we reach here the code swap is done and - every question — stash, branch switch, config migration — has already been asked and answered in the - user's own console. - ``venv\\Scripts\\hermes.exe`` is a launcher that runs the interpreter with the shim as its script and - holds it open without ``FILE_SHARE_DELETE`` for the whole command, so the quarantine rename is refused - and uv fails to replace it with os error 32 (#88838, #89599). - """ - if os.environ.get(_UPDATE_REEXEC_ENV) == "1": - return False - shim = _windows_shim_in_process_chain() - if shim is None: - return False - from hermes_constants import venv_python_path - python_exe = venv_python_path(shim.parent.parent, windows=True) - cmd = [str(python_exe), "-m", "hermes_cli.main", *sys.argv[1:]] - if python_exe.is_file(): - try: - subprocess.Popen( - cmd, env={**os.environ, _UPDATE_REEXEC_ENV: "1"}, stdin=subprocess.DEVNULL) - print( - f"→ Windows: {shim.name} cannot replace itself while it runs; " - "finishing the dependency install under the venv Python.") - print( - " The code update is already applied. The install continues " - "below and this shell returns right away.") - return True - except OSError as exc: - logger.debug("Dependency-sync hand-off via %s failed: %s", python_exe, exc) - print(f" ⚠ Could not hand the dependency install off {shim.name}.") - print(" Continuing in-process; if it cannot replace the shim, run:") - print(f" {subprocess.list2cmdline(cmd)}") - return False +def _reexec_dependency_sync_off_windows_shim() -> NoReturn: + """Stop a mixed old-code/new-files updater at the retired sync boundary.""" + from hermes_cli._old_updater import stop_for_relaunch + stop_for_relaunch() def _is_windows() -> bool: diff --git a/hermes_cli/main_platform_setup.py b/hermes_cli/main_platform_setup.py index 7e737d6dd2..aaf14a6df7 100644 --- a/hermes_cli/main_platform_setup.py +++ b/hermes_cli/main_platform_setup.py @@ -101,15 +101,23 @@ def _whatsapp_install_bridge(bridge_dir) -> bool: print("✓ Bridge dependencies already installed") return True print("\n→ Installing WhatsApp bridge dependencies (this can take a few minutes)...") + import pm + npm = find_node_executable("npm") - if not npm: - print(" ✗ npm not found on PATH — install Node.js first") - return False try: + env = with_hermes_node_path() + if npm is None: + env = pm.ensure("npm", explicit=True).env + installed = pm.installed_package("npm") + assert installed is not None and installed.binary is not None + npm = str(installed.binary) result = subprocess.run( [npm, "install", "--no-fund", "--no-audit", "--progress=false"], cwd=str(bridge_dir), stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True, - encoding="utf-8", errors="replace", env=with_hermes_node_path()) + encoding="utf-8", errors="replace", env=env) + except (pm.InstallError, OSError) as exc: + print(f" ✗ Bridge dependency preparation failed: {exc}") + return False except KeyboardInterrupt: print("\n ✗ Install cancelled") return False @@ -176,9 +184,21 @@ def cmd_whatsapp(args): else: print("📱 Open WhatsApp on your phone, then scan:") _say("", " Settings → Linked Devices → Link a Device", "─" * 50, "") + import pm + + node = find_node_executable("node") + if node is None: + try: + pm.ensure("node", explicit=True) + installed = pm.installed_package("node") + assert installed is not None and installed.binary is not None + node = str(installed.binary) + except pm.InstallError as exc: + print(f" ✗ Node.js preparation failed: {exc}") + return with contextlib.suppress(KeyboardInterrupt): subprocess.run( - [find_node_executable("node") or "node", str(bridge_script), "--pair-only", "--session", str(session_dir)], + [node, str(bridge_script), "--pair-only", "--session", str(session_dir)], cwd=str(bridge_dir), env=with_hermes_node_path()) print() diff --git a/hermes_cli/main_tui_launch.py b/hermes_cli/main_tui_launch.py index 5eacccbca4..c29f08ee7d 100644 --- a/hermes_cli/main_tui_launch.py +++ b/hermes_cli/main_tui_launch.py @@ -73,61 +73,11 @@ def _print_tui_exit_summary(session_id: Optional[str], active_session_file: Opti ) -_TUI_BUILD_INPUT_DIRS = ("src", "packages/hermes-ink/src", "../apps/shared") - -_TUI_BUILD_INPUT_FILES = ( - "package.json", - "package-lock.json", - "../package.json", - "../package-lock.json", - "tsconfig.json", - "tsconfig.build.json", - "babel.compiler.config.cjs", - "../scripts/build/tui.mjs", - "../scripts/build/frontend-common.mjs", - "packages/hermes-ink/package.json", - "packages/hermes-ink/index.js", - "packages/hermes-ink/text-input.js", -) - -_TUI_BUILD_INPUT_SUFFIXES = frozenset({".cjs", ".js", ".jsx", ".json", ".mjs", ".ts", ".tsx"}) - - -def _iter_tui_build_inputs(root: Path): - """Yield source/config files that affect ``ui-tui/dist/entry.js``.""" - for rel in _TUI_BUILD_INPUT_FILES: - path = root / rel - if path.is_file(): - yield path - - for rel in _TUI_BUILD_INPUT_DIRS: - base = root / rel - if not base.is_dir(): - continue - for path in base.rglob("*"): - if path.is_file() and path.suffix in _TUI_BUILD_INPUT_SUFFIXES: - yield path - - def _tui_need_rebuild(root: Path) -> bool: - """True when ``dist/entry.js`` is missing or older than TUI inputs (Termux cold-start saver); - ``HERMES_TUI_FORCE_BUILD=1`` forces a rebuild.""" + from hermes_cli.source_build import source_product_current + force = (os.environ.get("HERMES_TUI_FORCE_BUILD") or "").strip().lower() - if force in {"1", "true", "yes", "on"}: - return True - - try: - output_mtime = (root / "dist" / "entry.js").stat().st_mtime - except OSError: - return True - - for path in _iter_tui_build_inputs(root): - try: - if path.stat().st_mtime > output_mtime: - return True - except OSError: - return True - return False + return force in {"1", "true", "yes", "on"} or not source_product_current(root.parent, "tui", root / "dist") def _find_bundled_tui(hermes_cli_dir: Path | None = None) -> Path | None: diff --git a/hermes_cli/main_web_build.py b/hermes_cli/main_web_build.py index 2792c72885..8dc7f4aec6 100644 --- a/hermes_cli/main_web_build.py +++ b/hermes_cli/main_web_build.py @@ -5,15 +5,10 @@ are imported lazily inside the functions that use them (avoids an import cycle). """ import logging -import contextlib -import hashlib -import json -import os import subprocess import sys from pathlib import Path -from typing import Callable # Log-record parity with the origin module. logger = logging.getLogger("hermes_cli.main") @@ -85,110 +80,16 @@ def _web_dist_dir(web_dir: Path) -> Path: return _web_project_root(web_dir) / "hermes_cli" / "web_dist" -def _hash_source_tree(project_root: Path, tree_dir: Path) -> str: - """SHA-256 over *tree_dir* plus the root ``package.json`` / ``package-lock.json``. - - Ignored paths (``node_modules/``, ``dist/``, ``*.pyc``, ...) are skipped via - the repo-root ``.gitignore`` (pathspec) so build output never feeds back into - its own staleness check. Filenames are sorted for a deterministic digest. - """ - h = hashlib.sha256() - - def _hash_file(path: Path) -> None: - h.update(str(path.relative_to(project_root)).encode()) - h.update(b"\0") - with contextlib.suppress(OSError): - with open(path, "rb") as f: - for chunk in iter(lambda: f.read(65536), b""): - h.update(chunk) - h.update(b"\0") - - from pathspec import PathSpec - gitignore = project_root / ".gitignore" - lines = gitignore.read_text(encoding="utf-8-sig").splitlines() if gitignore.is_file() else [] - spec = PathSpec.from_lines("gitignore", lines) - - def _ignored(path: Path) -> bool: - return spec.match_file(str(path.relative_to(project_root))) - - for name in ("package.json", "package-lock.json"): - p = project_root / name - if p.is_file() and not _ignored(p): - _hash_file(p) - - # Prune ignored directories in place so we never descend into them. - for dirpath, dirnames, filenames in os.walk(tree_dir, topdown=True): - dirnames[:] = [d for d in dirnames if not _ignored(Path(dirpath) / d)] - for fn in sorted(filenames): - fp = Path(dirpath) / fn - if not _ignored(fp): - _hash_file(fp) - - return h.hexdigest() - - -def _stamp_is_current(stamp_file: Path, current_hash: Callable[[], str], **expect) -> bool: - """True when *stamp_file* parses, every ``expect`` key matches, and the hash matches. - - ``current_hash`` is only evaluated once the cheaper checks pass (it walks the - source tree). - """ - if not stamp_file.is_file(): - return False - try: - stamp_data = json.loads(stamp_file.read_text(encoding="utf-8-sig")) - except (OSError, json.JSONDecodeError): - return False - if not isinstance(stamp_data, dict): - return False - if any(stamp_data.get(k) != v for k, v in expect.items()): - return False - saved_hash = stamp_data.get("contentHash") - return bool(saved_hash) and current_hash() == saved_hash - - -def _write_build_stamp(stamp_file: Path, label: str, current_hash: Callable[[], str], **extra) -> None: - """Write ``{contentHash, **extra, builtAt}``; never lets stamp-writing fail a build.""" - try: - stamp_file.parent.mkdir(parents=True, exist_ok=True) - content_hash = current_hash() - from datetime import datetime, timezone - stamp_data = {"contentHash": content_hash, **extra, "builtAt": datetime.now(timezone.utc).isoformat()} - stamp_file.write_text(json.dumps(stamp_data, indent=2) + "\n", encoding="utf-8") - except Exception as exc: - logger.debug("Failed to write %s build stamp: %s", label, exc) - - def _web_ui_build_needed(web_dir: Path) -> bool: - """True if the web UI dist is missing or its source content changed. + from hermes_cli.source_build import source_product_current - Content hash, NOT mtime: ``git checkout`` / ``hermes update`` rewrite source - mtimes without changing content, which made an mtime check unreliable in - both directions. - """ - project_root = _web_project_root(web_dir) - dist_dir = _web_dist_dir(web_dir) - if not any(p.exists() for p in (dist_dir / ".vite" / "manifest.json", dist_dir / "index.html")): - return True - return not _stamp_is_current( - _web_ui_stamp_path(), lambda: _compute_web_ui_content_hash(project_root, web_dir)) - - -def _compute_web_ui_content_hash(project_root: Path, web_dir: Path) -> str: - """SHA-256 of the web UI source tree plus root workspace config.""" - return _hash_source_tree(project_root, web_dir) - - -def _web_ui_stamp_path() -> Path: - """Path of the web UI build stamp under $HERMES_HOME.""" - from hermes_constants import get_hermes_home - return get_hermes_home() / "web-ui-build-stamp.json" + return not source_product_current(_web_project_root(web_dir), "web", _web_dist_dir(web_dir)) def _write_web_ui_build_stamp(project_root: Path, web_dir: Path) -> None: - """Write the web UI build stamp after a successful build.""" - _write_build_stamp( - _web_ui_stamp_path(), "web UI", lambda: _compute_web_ui_content_hash(project_root, web_dir)) + """Historical updater entrypoint; current builders publish their own receipts.""" + from hermes_cli._old_updater import stop_for_relaunch + stop_for_relaunch() def _console_print(text: str) -> None: diff --git a/hermes_cli/memory_setup.py b/hermes_cli/memory_setup.py index 20e3509748..c5daee0ffb 100644 --- a/hermes_cli/memory_setup.py +++ b/hermes_cli/memory_setup.py @@ -51,15 +51,8 @@ def _prompt(label: str, default: str | None = None, secret: bool = False) -> str return val or (default or "") -def _install_dependencies(provider_name: str, *, force: bool = False) -> None: - """Prepare provider dependencies without narrowing the active plugin union. - - A new provider is not selected in config yet. Include its directory with - every active member, and propagate failure before setup saves it. - """ - import subprocess - - import pm +def memory_provider_dependency_inputs(provider_name: str) -> tuple[dict, dict]: + """Read one candidate declaration for preparation and passive readiness.""" from hermes_cli.plugins_admission import candidate_member_dirs from hermes_cli.plugins_cmd import PluginOperationError, _read_manifest_for_install from pm.package import InstallError @@ -68,7 +61,7 @@ def _install_dependencies(provider_name: str, *, force: bool = False) -> None: plugin_dir = find_provider_dir(provider_name) if not plugin_dir: - return + return {}, {} try: meta = _read_manifest_for_install(plugin_dir) member = _is_member_candidate(plugin_dir) @@ -77,15 +70,39 @@ def _install_dependencies(provider_name: str, *, force: bool = False) -> None: extra = meta.get("extra") extras = [extra] if isinstance(extra, str) and extra else [] - missing = [e for e in extras if force or not pm.available(e)] - if not missing and not member: - return + if not extras and not member: + return meta, {} - print(f"\n Preparing dependencies for {provider_name}") # Without a proposed home, selection retains every configured member. inputs = {"plugin_dirs": lambda: candidate_member_dirs((), extra_dirs=[plugin_dir])} if member else {} - pm.sync_venv(missing, explicit=True, **inputs) - print(f" ✓ Dependencies prepared for {provider_name}") + return meta, {"extras": extras, **inputs} + + +def prepare_memory_provider_dependencies(provider_name: str) -> tuple[dict, str | None]: + """Prepare the candidate union; PM owns constraint and currency checks.""" + import pm + + meta, inputs = memory_provider_dependency_inputs(provider_name) + if not inputs: + return meta, None + pm.sync_venv(explicit=True, **inputs) + from hermes_cli.runtime_paths import selected_venv, site_packages + from pm.paths import repo_root + + selected = site_packages(selected_venv(repo_root())).resolve() + active = {Path(entry).resolve() for entry in sys.path} + return meta, "installed" if selected in active else "restart_required" + + +def _install_dependencies(provider_name: str) -> None: + """Render CLI preparation and external-sidecar guidance.""" + import subprocess + + meta, status = prepare_memory_provider_dependencies(provider_name) + if status: + print(f" ✓ Dependencies prepared for {provider_name}") + if status == "restart_required": + print(" Restart Hermes to use the prepared dependencies.") # Also show external (non-pip) dependencies that are missing. for dep in meta.get("external_dependencies", []): diff --git a/hermes_cli/nous_subscription.py b/hermes_cli/nous_subscription.py index a1f4cec87a..3848a8713f 100644 --- a/hermes_cli/nous_subscription.py +++ b/hermes_cli/nous_subscription.py @@ -3,7 +3,6 @@ from __future__ import annotations from dataclasses import dataclass -from pathlib import Path from typing import Dict, Iterable, Optional, Set from hermes_cli.config import get_env_value, load_config @@ -182,38 +181,12 @@ def _toolset_enabled(config: Dict[str, object], toolset_key: str) -> bool: def _has_agent_browser() -> bool: - import shutil - - from hermes_constants import agent_browser_runnable - - # agent-browser is no longer a root package.json dependency (#43564) — it - # resolves lazily via npx for most installs, which a bare PATH + - # node_modules probe can't see. Mirror the local-CLI tail of - # :func:`tools.browser_tool.check_browser_requirements` (same cascade) so - # the setup/status surfaces can't diverge from what browser tools actually - # find at runtime; validate=False keeps this a cheap existence check with - # no subprocess spawn. + # Read the runtime's choice; a broken resolver is not permission to + # advertise an unchecked binary through a second discovery ladder. try: from tools.browser_tool_install import _find_agent_browser - except Exception: - # Runtime probe unavailable: fall back to binary presence rather than crashing. Rungs: PATH; - # Hermes-managed Node dirs ($HERMES_HOME/node, prepended to PATH at runtime but usually absent - # from the *probe* process's PATH); local node_modules/.bin (PATHEXT-aware ``shutil.which`` so - # Windows picks the ``.cmd`` shim). The hit must also run: a dangling symlink is reported by - # ``which`` but fails at exec. - # See #48521. - from hermes_constants import with_hermes_node_path - - local_bin_dir = Path(__file__).parent.parent / "node_modules" / ".bin" - search_paths = [None, with_hermes_node_path().get("PATH", ""), str(local_bin_dir) if local_bin_dir.is_dir() else ""] - return any( - (hit := shutil.which("agent-browser", **({} if path is None else {"path": path}))) and agent_browser_runnable(hit) - for path in search_paths if path != "" - ) - - try: _find_agent_browser(validate=False) - except FileNotFoundError: + except (ImportError, OSError): return False return True diff --git a/hermes_cli/npm_engine.py b/hermes_cli/npm_engine.py index 1a154512c2..676467e3a0 100644 --- a/hermes_cli/npm_engine.py +++ b/hermes_cli/npm_engine.py @@ -1,161 +1,10 @@ -"""Recover from npm ``EBADENGINE`` failures with the pm-pinned npm. +"""Stop an already-running old updater before its retired npm retry path.""" -We react to the failure rather than predict it: npm states the required range in the error, so the -recovery reads the constraint out of the output it just produced (no semver matcher, no probe). +from typing import NoReturn -Rather than predicting the failure (which would mean a semver range matcher and -an ``npm --version`` probe before work that usually succeeds), we react to it: -npm states the required range in the error, so the recovery reads the -constraint straight out of the output it just produced. - -Scope of the repair is deliberately narrow. A system / nvm / brew / Nix npm -belongs to the user and their other projects; Hermes never modifies those. -When the failing npm is a foreign install, Hermes ensures its own pm-pinned -node/npm packages are installed and hands the caller pm's npm to retry with — -leaving the user's toolchain untouched. When the failing npm already *is* -pm's npm, the lockfile pin itself is out of range and no runtime action can -fix that; the caller gets the manual guidance instead. -""" - -from __future__ import annotations - -import json -import re -import sys -from pathlib import Path - -__all__ = [ - "is_ebadengine", - "required_npm_range", - "maybe_repair_npm_engine", -] - -# `npm error notsup Required: {...}` on npm >= 10, `npm ERR! notsup Required: {...}` on older. -_REQUIRED_RE = re.compile(r"Required:\s*(\{.*?\})") -_ACTUAL_RE = re.compile(r"Actual:\s*(\{.*?\})") - -def is_ebadengine(output: str) -> bool: - """Return True when *output* is an npm engine-compatibility failure.""" - return bool(output) and ("EBADENGINE" in output or "Unsupported engine" in output) +from hermes_cli._old_updater import stop_for_relaunch -def _npm_fields(pattern: re.Pattern[str], output: str) -> list[str]: - """``npm`` values of every well-formed JSON block matching *pattern*, in order.""" - values: list[str] = [] - for match in pattern.finditer(output or ""): - try: - parsed = json.loads(match.group(1)) - except ValueError: - continue - if isinstance(parsed, dict) and parsed.get("npm"): - values.append(str(parsed["npm"]).strip()) - return values - - -def required_npm_range(output: str) -> str | None: - """Return the ``engines.npm`` range npm demanded in *output*. - - ``None`` when there is no engine failure or the failure is about Node (upgrading npm cannot fix - that, so the caller must not try). With conflicting ranges the repo's own root constraint wins - (we control it); otherwise the first range, since any is a strict improvement. - """ - if not is_ebadengine(output): - return None - distinct = list(dict.fromkeys(_npm_fields(_REQUIRED_RE, output))) - if not distinct: - return None - if len(distinct) > 1: - repo_range = _repo_npm_range() - if repo_range in distinct: - return repo_range - return distinct[0] - - -def actual_npm_version(output: str) -> str | None: - """Return the npm version npm reported as ``Actual`` in *output*.""" - return next(iter(_npm_fields(_ACTUAL_RE, output)), None) - - -def _repo_npm_range() -> str | None: - """Return ``engines.npm`` from the checkout's root ``package.json``.""" - package_json = Path(__file__).resolve().parent.parent / "package.json" - try: - data = json.loads(package_json.read_text(encoding="utf-8-sig")) - except (OSError, ValueError): - return None - engines = data.get("engines") - value = engines.get("npm") if isinstance(engines, dict) else None - return str(value).strip() if value else None - - -def _pm_npm(*, quiet: bool = False) -> str | None: - """Install the pm-pinned node/npm packages and return pm's npm path.""" - if not quiet: - print( - "→ Provisioning the Hermes-pinned Node.js runtime " - "(the resolved npm belongs to your system and is left alone)…", - flush=True, - ) - try: - import pm - - pm.ensure("npm") - from hermes_constants import _pm_node_executable - - managed = _pm_node_executable("npm") - except Exception: - managed = None - if not managed and not quiet: - print(" ✗ Managed Node.js provisioning failed", file=sys.stderr) - return managed - - -def _print_manual_fix(npm: str, npm_range: str, actual: str | None) -> None: - have = f"npm {actual} " if actual else "This npm " - print( - f"\n✗ {have}does not satisfy the range this project requires: {npm_range}\n" - f" Resolved npm: {npm}\n" - " Hermes could not provision its own Node.js runtime and never\n" - " modifies a system/nvm/brew/Nix npm. Upgrade yours yourself with:\n" - f' npm install -g npm@"{npm_range}"', - file=sys.stderr, - ) - - -def maybe_repair_npm_engine( - npm: str | None, - output: str, - *, - quiet: bool = False, -) -> str | None: - """Repair an ``EBADENGINE`` failure, never touching a foreign toolchain. - - *output* is the combined stdout/stderr of the npm command that just failed. - Returns the npm executable the caller should retry its command with — the - pm-pinned npm, freshly ensured, when the failing npm was a foreign install - (system / nvm / brew / Nix installs are never modified). Returns ``None`` - when no repair happened — not an engine failure, the failing npm already - was pm's own (the lockfile pin is out of range; a runtime install cannot - fix that), or the pm install failed — leaving the original failure to - stand. - - The returned value is truthy exactly when the caller should retry once, - so ``if maybe_repair_npm_engine(...)`` call sites keep working; they just - must run the retry with the returned path. - """ - if not npm or not is_ebadengine(output): - return None - - managed = _pm_npm(quiet=quiet) - if managed: - try: - already_managed = Path(managed).resolve() == Path(npm).resolve() - except OSError: - already_managed = False - if not already_managed: - return managed - - npm_range = required_npm_range(output) - if not quiet and npm_range: - _print_manual_fix(npm, npm_range, actual_npm_version(output)) - return None +def maybe_repair_npm_engine(*args, **kwargs) -> NoReturn: + # Returning would let old callers retry or continue their stale update. + stop_for_relaunch() diff --git a/hermes_cli/plugins_cmd.py b/hermes_cli/plugins_cmd.py index 400c1b09ec..2acd905aa5 100644 --- a/hermes_cli/plugins_cmd.py +++ b/hermes_cli/plugins_cmd.py @@ -375,7 +375,7 @@ def _install_plugin_python_deps( if node_answer in {"y", "yes"}: from pm.workspace import install_node_sidecar - node_reason = install_node_sidecar(target) + node_reason = install_node_sidecar(target, explicit=True) if node_reason: console.print(f"[yellow]⚠[/yellow] Node deps: {node_reason}") else: diff --git a/hermes_cli/runtime_paths.py b/hermes_cli/runtime_paths.py index 89ace6c633..15089574ba 100644 --- a/hermes_cli/runtime_paths.py +++ b/hermes_cli/runtime_paths.py @@ -114,16 +114,22 @@ def activate_dependencies(project_root: Path) -> None: import sys state = install_state_dir(project_root) - if not state.is_dir(): - return - from hermes_cli.runtime_state import runtime_lock, recover_publication, lease_generation - with runtime_lock(project_root): - recover_publication(project_root) - if not runtime_facts_path(project_root).is_file(): - return - environment = selected_venv(project_root) - lease_generation(environment) + if state.is_dir(): + from hermes_cli.runtime_state import runtime_lock, recover_publication, lease_generation + with runtime_lock(project_root): + recover_publication(project_root) + environment = selected_venv(project_root) + lease_generation(environment) + selected = site_packages(environment) + if not selected.is_dir() and not runtime_facts_path(project_root).is_file(): + return + else: + # Older installs and sealed payloads still select once, before imports. + # Never consult VIRTUAL_ENV: it can describe the invoking shell's Python. + environment = base_venv(project_root) selected = site_packages(environment) + if not selected.is_dir(): + return # External/Nix interpreter owns its original sys.path. if not selected.is_dir(): raise RuntimeError(f"dependency environment has no site-packages: {selected}") import site @@ -136,6 +142,10 @@ def activate_dependencies(project_root: Path) -> None: sys.path[:] = [str(project_root.resolve()), str(selected), *[entry for entry in sys.path if Path(entry).resolve() != selected.resolve()]] os.environ["PYTHONPATH"] = os.pathsep.join([str(project_root.resolve()), str(selected)]) + os.environ.pop("VIRTUAL_ENV", None) + executable_dir = environment / ("Scripts" if os.name == "nt" else "bin") + if executable_dir.is_dir(): + os.environ["PATH"] = os.pathsep.join([str(executable_dir), os.environ.get("PATH", "")]) def activation_environment(project_root: Path) -> dict[str, str]: diff --git a/hermes_cli/source_build.py b/hermes_cli/source_build.py index dfc7d75ad1..c59c152af4 100644 --- a/hermes_cli/source_build.py +++ b/hermes_cli/source_build.py @@ -7,7 +7,26 @@ import subprocess import sys -def source_build_env(base_env: dict | None = None) -> dict[str, str]: +def source_product_current(project_root: Path, product: str, out: Path) -> bool: + """Read the compiler's receipt without acquiring tools or dependencies.""" + from pm import env_for + + env = env_for("node") + node = shutil.which("node", path=env.get("PATH", "")) + if not node: + return False + try: + result = subprocess.run( + [node, str(project_root / "scripts/build/freshness.mjs"), + "--source", str(project_root), "--product", product, "--out", str(out)], + cwd=project_root, env=env, capture_output=True, text=True, check=True, + ) + return result.stdout.strip() == "true" + except (OSError, subprocess.SubprocessError): + return False + + +def source_build_env(base_env: dict | None = None, *, explicit: bool = False) -> dict[str, str]: from pm import ensure from hermes_constants import get_hermes_home @@ -17,7 +36,7 @@ def source_build_env(base_env: dict | None = None) -> dict[str, str]: npmrc = get_hermes_home() / "npmrc" if npmrc.is_file(): env.setdefault("NPM_CONFIG_USERCONFIG", str(npmrc)) - return ensure("npm", base_env=env, explicit=True).env + return ensure("npm", base_env=env, explicit=explicit).env def run_source_script(project_root: Path, script: str, *args: str, env: dict) -> None: @@ -27,9 +46,13 @@ def run_source_script(project_root: Path, script: str, *args: str, env: dict) -> ) -def prepare_source_dependencies(project_root: Path, workspaces: tuple[str, ...], *, env: dict) -> None: +def prepare_source_dependencies(project_root: Path, workspaces: tuple[str, ...], *, env: dict, + explicit: bool = False) -> None: + from pm import lazy_installs_allowed + run_source_script( project_root, "scripts/build/node-deps.mjs", "--source", str(project_root), "--reuse", + *(() if explicit or lazy_installs_allowed() else ("--no-install",)), *(arg for workspace in workspaces for arg in ("--workspace", workspace)), env=env, ) @@ -48,27 +71,28 @@ def build_source_tui(project_root: Path, *, env: dict) -> None: run_source_script(project_root, "scripts/build/tui.mjs", env=env) -def build_source_web(project_root: Path, *, env: dict) -> None: - from hermes_cli.main_web_build import _write_web_ui_build_stamp - - run_source_script(project_root, "scripts/generate-icons.mjs", env=env) - run_source_script(project_root, "scripts/build/web.mjs", env=env) - _write_web_ui_build_stamp(project_root, project_root / "web") +def build_source_web(project_root: Path, *, env: dict, icons: Path | None = None, + explicit: bool = False) -> None: + if icons is None: + icons = project_root + run_source_script(project_root, "scripts/generate-icons.mjs", *(() if explicit else ("--on-demand",)), env=env) + run_source_script(project_root, "scripts/build/web.mjs", "--source", str(project_root), + "--icons", str(icons), "--out", str(project_root / "hermes_cli/web_dist"), env=env) def build_update_products(project_root: Path, *, desktop: bool) -> None: """Prepare the selected union once; a failed product aborts the update.""" - env = source_build_env() + env = source_build_env(explicit=True) workspaces = ("ui-tui", "web") + (("apps/desktop",) if desktop else ()) - prepare_source_dependencies(project_root, workspaces, env=env) + prepare_source_dependencies(project_root, workspaces, env=env, explicit=True) build_source_tui(project_root, env=env) - build_source_web(project_root, env=env) + build_source_web(project_root, env=env, explicit=True) if desktop: from hermes_cli.main_desktop import build_prepared_desktop build_prepared_desktop( project_root / "apps/desktop", source_mode=False, - npm=shutil.which("npm", path=env["PATH"]), env=env, + npm=shutil.which("npm", path=env["PATH"]), env=env, icons=project_root, explicit=True, ) diff --git a/hermes_cli/subcommands/computer_use.py b/hermes_cli/subcommands/computer_use.py index ac0d52f4b8..b43047f816 100644 --- a/hermes_cli/subcommands/computer_use.py +++ b/hermes_cli/subcommands/computer_use.py @@ -8,43 +8,28 @@ from hermes_cli.subcommands._shared import add_json_flag def _cu_install(args) -> int: - from hermes_cli.tools_config import _cua_driver_contract_status, install_cua_driver - if not install_cua_driver(upgrade=bool(getattr(args, "upgrade", False))): - return 1 - return 0 if _cua_driver_contract_status().get("ready") else 1 + from hermes_cli.tools_config_cua import install_cua_driver + return 0 if install_cua_driver(upgrade=bool(getattr(args, "upgrade", False))) else 1 def _cu_status(args) -> int: import os as _os - import subprocess - from hermes_cli.tools_config import _cua_driver_contract_status - from tools.computer_use.cua_backend_driver import cua_driver_update_check, resolve_cua_driver_cmd - # Must match the runtime resolver: Desktop/TUI processes can omit - # ~/.local/bin even though the official installer put the driver there. + from hermes_cli.tools_config_cua import _cua_driver_contract_status, _cua_version_summary + from tools.computer_use.cua_backend_driver import resolve_cua_driver_cmd + path = resolve_cua_driver_cmd() override = _os.environ.get("HERMES_CUA_DRIVER_CMD", "").strip() if not path: print("cua-driver: not installed") print(" Run: hermes computer-use install") return 1 - version = "" - try: - from hermes_cli.tools_config import _cua_driver_env - version = subprocess.run( - [path, "--version"], - capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=5, - env=_cua_driver_env(), - ).stdout.strip() - except Exception: - pass - from hermes_cli.tools_config import _cua_version_summary - version = _cua_version_summary(version) + contract = _cua_driver_contract_status(path) + version = _cua_version_summary(contract.get("version") or "") # Name the override here too. Without it the operator is told to repair an # install that `hermes computer-use install` will (correctly) refuse to touch, # with nothing pointing at the env var that actually selected the binary. origin = " [custom binary from HERMES_CUA_DRIVER_CMD]" if override else "" print(f"cua-driver: installed at {path}{origin}" + (f" ({version})" if version else "")) - contract = _cua_driver_contract_status(path) if not contract.get("ready"): print(" ⚠ Repair required: " + (contract.get("reason") or "runtime contract is incomplete")) if override: @@ -54,19 +39,8 @@ def _cu_status(args) -> int: else: print(" Run: hermes computer-use install") return 1 - try: - st = cua_driver_update_check() - if st and st.get("update_available"): - latest = st.get("latest_version") or "?" - print(f" ⬆ Update available: cua-driver {latest}.") - print(" Run: hermes computer-use install --upgrade") - elif st: - print(" ✓ Up to date.") - else: - # Older driver (no check-update verb) or offline. - print(" Refresh to latest: hermes computer-use install --upgrade") - except Exception: - print(" Refresh to latest: hermes computer-use install --upgrade") + print(" ✓ Runtime contract ready (externally managed)." if override + else " ✓ Runtime contract ready (Hermes PM pin).") return 0 @@ -120,8 +94,8 @@ def build_computer_use_parser(subparsers) -> None: description="Install or check the cua-driver binary used by the\n" "`computer_use` toolset. Supported on macOS, Windows, and\n" "Linux.\n\n" - "Use `hermes computer-use install` to fetch and run the\n" - "upstream cua-driver installer. This is equivalent to the\n" + "Use `hermes computer-use install` to prepare the pinned\n" + "cua-driver package and host integration. This is equivalent to the\n" "post-setup hook that `hermes tools` runs when you first\n" "enable the Computer Use toolset, and is a stable target\n" "for re-running the install if it didn't fire (e.g. when\n" @@ -136,10 +110,8 @@ def build_computer_use_parser(subparsers) -> None: "install", help="Install or repair the cua-driver binary (macOS/Windows/Linux)") computer_use_install.add_argument( "--upgrade", action="store_true", - help="Re-run the upstream installer even if cua-driver is already on " - "PATH. The upstream install.sh always pulls the latest release, " - "so this performs an in-place upgrade.") - computer_use_sub.add_parser("status", help="Print whether cua-driver is installed and on PATH") + help="Reconcile cua-driver with Hermes' pinned PM package and repair host setup.") + computer_use_sub.add_parser("status", help="Check the selected cua-driver and its runtime contract") computer_use_doctor = computer_use_sub.add_parser( "doctor", help="Run cua-driver `health_report` and surface the check matrix", description="Drive cua-driver's stable `health_report` MCP tool and render\n" diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index a10dd1fc08..409bf154e2 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -17,10 +17,7 @@ from hermes_cli.toolset_scope import ( # Re-exports: keep ``hermes_cli.tools_config.X`` callers and test patch targets resolving. from hermes_cli.tools_config_cua import ( # noqa: F401 _post_setup_no_window_flags, _cua_driver_cmd, _cua_version_summary, _resolved_cua_driver_cmd, _cua_driver_env, - _cua_driver_contract_status, _cua_driver_install_ready, _cua_install_target_writable, - install_cua_driver, _CUA_INSTALLER_TIMEOUT, _CUA_INSTALLER_DRAIN_GRACE, _CUA_LOCK_STALE_AFTER, - _clear_stale_windows_cua_install_lock, _clear_stale_cua_install_lock, _cua_install_lock_held, - _cua_release_endpoint_reachable, _repair_cua_driver_autostart_windows, _run_cua_driver_installer) + _cua_driver_contract_status, _cua_driver_install_ready) from hermes_cli.tools_config_post_setup import ( # noqa: F401 _ensure_browser_use_cli, _run_post_setup, valid_post_setup_keys, run_post_setup_command, _POST_SETUP_INSTALLED, _post_setup_already_installed, _module_installed, _POST_SETUP_READY) @@ -48,6 +45,14 @@ def _pip_install( logger = logging.getLogger(__name__) + +def install_cua_driver(*args, **kwargs) -> NoReturn: + # A running pre-PM updater can still import the vendor installer here. + # Stop it before any old retry or completion branch can run. + from hermes_cli._old_updater import stop_for_relaunch + + stop_for_relaunch() + # Platforms already warned about an all-invalid platform_toolsets list (warn once, not per resolution). _warned_invalid_platform_toolsets: Set[str] = set() diff --git a/hermes_cli/tools_config_cua.py b/hermes_cli/tools_config_cua.py index 9e4b8b6a70..242e0f33fb 100644 --- a/hermes_cli/tools_config_cua.py +++ b/hermes_cli/tools_config_cua.py @@ -1,81 +1,39 @@ -"""cua-driver installer and lock hygiene for `hermes tools` / -`hermes computer-use install`.""" +"""PM-backed CUA setup and the host integration not supplied by its binary archive.""" from __future__ import annotations -import contextlib -import logging import os -import platform -import re import shutil import subprocess import sys import time -from pathlib import Path from typing import Optional from hermes_cli.cli_output import ( print_info as _print_info, print_success as _print_success, print_warning as _print_warning) -logger = logging.getLogger("hermes_cli.tools_config") - -# One upstream-installer run must outlive the installer's own stale-lock recovery (_install-rust.sh -# force-releases a dead holder's lock only after LOCK_STALE_AFTER_SECONDS=600; a shorter timeout -# kills every run before that fires — a permanent wedge). 660s = 600s + 60s headroom. -# With a shorter Python-side timeout, a stale lock means every run gets killed before the installer's -# recovery can fire — a permanent "always times out" wedge (issue #58762). 660s = 600s lock window + 60s -# headroom for the actual download/swap. -_CUA_INSTALLER_TIMEOUT = 660 -# Bounded pipe drain after a timeout kill: the kill is best-effort (_reap_after_timeout), and a -# surviving descendant holding the inherited stdout would otherwise block the read on an EOF that -# never comes. A successful kill closes the pipe at once, so this costs nothing. -# Grace period for draining the installer's pipes after a timeout kill. A successful kill closes the pipe -# immediately, so this costs nothing in the normal case; it only caps how long a failed one can stall the -# update. See #87703. -_CUA_INSTALLER_DRAIN_GRACE = 15 -# Quiet ``hermes update`` refreshes stay bounded even when upstream waits on Read-Host / a consent -# prompt (explicit ``install --upgrade`` keeps the full ceiling); safe because the lock/network -# preflights make a legitimate long wait impossible here. -_CUA_BACKGROUND_UPDATE_TIMEOUT = 120 -# Upstream's LOCK_STALE_AFTER_SECONDS: the pre-clear never yanks a lock a live install still holds. -_CUA_LOCK_STALE_AFTER = 600 - -_CUA_INSTALL_PS1_URL = ( - "https://raw.githubusercontent.com/trycua/cua/main/libs/cua-driver/scripts/install.ps1") -_CUA_INSTALL_SH_URL = ( - "https://raw.githubusercontent.com/trycua/cua/main/libs/cua-driver/scripts/install.sh") -_CUA_MANUAL_README = "https://github.com/trycua/cua/blob/main/libs/cua-driver/README.md" -_UPGRADE_CMD = "hermes computer-use install --upgrade" - def _run_text(cmd: list, *, timeout, capture_output: bool = True, **kwargs) -> subprocess.CompletedProcess: - """``subprocess.run`` with the utf-8/replace text decoding every helper here uses.""" + """Run a text subprocess with consistent decoding.""" return subprocess.run(cmd, capture_output=capture_output, text=True, encoding="utf-8", errors="replace", timeout=timeout, **kwargs) -def _fail(message: str, *hints: str, warn: bool = True) -> bool: - """Print ``message`` (warning, or info when ``warn=False``) plus info hints; returns False.""" - (_print_warning if warn else _print_info)(message) +def _fail(message: str, *hints: str) -> bool: + _print_warning(message) for hint in hints: _print_info(hint) return False def _print_output_tail(result: subprocess.CompletedProcess, printer=None) -> None: - """Echo the last three lines of a failed command's stderr (or stdout) as indented info. - ``printer`` lets another module route through its own (test-patchable) ``_print_info``.""" for line in (result.stderr or result.stdout or "").strip().splitlines()[-3:]: (printer or _print_info)(f" {line[:200]}") def _post_setup_no_window_flags(*, streams_to_console: bool = False) -> int: - """Win32 creationflags that stop post-setup children flashing a console (0 on POSIX). - CREATE_NO_WINDOW hides console grandchildren (npm, pip, powershell) while keeping stdio - inheritable (unlike DETACHED_PROCESS). ``streams_to_console`` children are only hidden when our - own stdout is not a console, so live installer output is never swallowed.""" + """Hide Windows children unless their output is going to a real console.""" from hermes_cli._subprocess_compat import windows_hide_flags flags = windows_hide_flags() try: @@ -87,37 +45,29 @@ def _post_setup_no_window_flags(*, streams_to_console: bool = False) -> int: def _cua_driver_cmd() -> str: - """Return the configured cua-driver override, or the bare default name.""" return os.environ.get("HERMES_CUA_DRIVER_CMD", "").strip() or "cua-driver" def _cua_version_summary(raw: str, *, limit: int = 120) -> str: - """First non-empty line of ``--version`` output, bounded (an override may print a banner).""" + """Bound an external binary's potentially multiline version banner.""" return next((line.strip()[:limit] for line in (raw or "").splitlines() if line.strip()), "") def _resolved_cua_driver_cmd() -> Optional[str]: - """Resolve cua-driver exactly as the runtime and Desktop status do.""" from tools.computer_use.cua_backend_driver import resolve_cua_driver_cmd return resolve_cua_driver_cmd() def _cua_driver_env() -> dict: - """cua-driver child env with the Hermes telemetry policy applied; falls back to the current - environment if the helper can't be imported, so install/status never break.""" - try: - from tools.computer_use.cua_backend import cua_driver_child_env - return cua_driver_child_env() - except Exception: - return dict(os.environ) + from tools.computer_use.cua_backend import sanitized_cua_driver_env + return sanitized_cua_driver_env() _CUA_DRIVER_CONTRACT_CACHE: dict = {} def _cua_driver_contract_status(binary: Optional[str] = None) -> dict: - """Inspect whether an installed driver supports Hermes' runtime contract (30s cache keyed on the - binary's path/mtime/size fingerprint).""" + """Cache the runtime manifest check by binary identity for UI polling.""" from tools.computer_use.cua_backend_driver import cua_driver_runtime_contract_status resolved = binary or _resolved_cua_driver_cmd() if not resolved: @@ -137,348 +87,114 @@ def _cua_driver_contract_status(binary: Optional[str] = None) -> dict: def _cua_driver_install_ready() -> bool: - """Return whether an existing driver needs no install-time repair.""" - return bool(_cua_driver_contract_status().get("ready")) and ( - sys.platform != "win32" or _cua_driver_autostart_registered_windows()) - - -# No pre-install release/asset probe: cua-driver-rs releases are all prereleases, which GitHub's -# `/releases/latest` skips (zero binary assets → every non-arm64 host skipped the install), and -# re-implementing upstream's tag resolution here would drift. Fresh installs run install.sh directly -# (it errors clean on a missing-arch asset); upgrades ask the binary via cua_driver_update_check(). - - -def _cua_install_target_writable() -> bool: - """Return whether the upstream installer can write its app bundle target.""" - if sys.platform != "darwin": - return True - try: - return not os.path.isdir("/Applications") or os.access("/Applications", os.W_OK) - except Exception: - return True - - -def _cua_driver_version(binary: str) -> Optional[str]: - """`` --version`` stdout (possibly ""), or None when the probe itself fails.""" - try: - return _run_text([binary, "--version"], timeout=5, env=_cua_driver_env(), - creationflags=_post_setup_no_window_flags()).stdout.strip() - except Exception: - return None - - -def _confirmed_update_check(driver_cmd: str, require_confirmed_update: bool) -> tuple: - """Ask the installed driver whether a newer release exists; returns ``(proceed, pin_version)``. - ``proceed=False`` = stop with success (already latest, or indeterminate under - ``require_confirmed_update``). An old driver (no check-update verb) or offline check yields - None: `hermes update` then keeps the installed version — an indeterminate check must never - cost a multi-minute silent reinstall on every update — while explicit `install --upgrade` - falls through.""" - try: - from tools.computer_use.cua_backend_driver import cua_driver_update_check - _state = cua_driver_update_check() - except Exception: - _state = None - if _state is None: - if require_confirmed_update: - _fail(f" Could not confirm a newer {driver_cmd} release (offline, rate-limited, or " - "driver too old to check); keeping the installed version.", - f" Force a refresh with: {_UPGRADE_CMD}", warn=False) - return not require_confirmed_update, None - if not _state.get("update_available"): - _print_success(f" {driver_cmd} is already on the latest release " - f"({_state.get('current_version') or 'unknown'}).") - return False, None - # Windows routine upgrades run unattended-safe (stdin closed, version pinned, ceiling - # _CUA_BACKGROUND_UPDATE_TIMEOUT, preflights skip in seconds); only contract repairs and fresh - # installs stay interactive-only, where upstream needs a human (autostart elevation/SmartScreen). - # Pin to the release check-update confirmed: `latest_version` comes from the GitHub Releases API - # so its assets exist, unlike the installer's baked version on `main` (bumped before assets are - # published → 404s unpinned). Malformed values are ignored → unpinned fallback. - _latest = str(_state.get("latest_version") or "").strip().lstrip("vV") - return True, (_latest if re.fullmatch(r"\d+(\.\d+)*", _latest) else None) - - -def _report_repair_or_upgrade(ok: bool, *, repair_existing: bool, binary, before: str, - driver_cmd: str) -> bool: - """Post-installer verdict: a repair must leave a usable contract; upgrades show before/after.""" - if ok and repair_existing: - repaired = _cua_driver_contract_status() - if not repaired.get("ready"): - return _fail(" cua-driver was reinstalled, but its runtime contract is still " - f"unusable: {repaired.get('reason') or 'unknown error'}.", - " Run: hermes computer-use doctor") - if ok and before: - after = _cua_driver_version(binary) - if after and after != before: - _print_success(f" {driver_cmd} upgraded: {before} → {after}") - elif after: - _print_info(f" {driver_cmd} up to date: {after}") - return ok - - -def install_cua_driver(upgrade: bool = False, require_confirmed_update: bool = False, - show_installer_progress: bool = True) -> bool: - """Install or refresh the cua-driver binary used by Computer Use. - Re-running the upstream installer (always the latest release tag) is the canonical upgrade. - ``upgrade=False`` (toolset enable flow) keeps a compatible installation, repairs an - old/incomplete one and installs when missing; ``upgrade=True`` always refreshes.""" - system = platform.system() - if system not in ("Darwin", "Windows", "Linux"): - if not upgrade: # silent under `hermes update`, which calls this for every user - _print_warning( - " Computer Use (cua-driver) is unsupported on this platform; skipping.") + state = _cua_driver_contract_status() + if not state.get("ready"): return False - is_windows, is_linux = system == "Windows", system == "Linux" - # install.ps1 is fetched via PowerShell's `irm`; macOS/Linux use curl | bash. - fetch_tool = "powershell" if is_windows else "curl" - driver_cmd, binary = _cua_driver_cmd(), _resolved_cua_driver_cmd() - # An explicit override is authoritative even when broken: installing the standard driver - # cannot repair the configured path and would mutate an unrelated installation. - override = os.environ.get("HERMES_CUA_DRIVER_CMD", "").strip() - if override and not binary: - return _fail(f" HERMES_CUA_DRIVER_CMD does not resolve to an executable: {override}", - " Fix or unset the override before running computer-use install.") - - # Not installed → fresh install path (only when caller asked for it). - if not binary and not upgrade: - if not _cua_install_target_writable(): - return _fail(" /Applications is not writable; skipping cua-driver install.", - " Run from an admin account or install cua-driver manually.", - warn=False) - if not shutil.which(fetch_tool): - return _fail(f" {fetch_tool} not found — install manually:", - f" {_CUA_MANUAL_README}") - return _run_cua_driver_installer(label="Installing") - - # A driver failing Hermes' runtime contract (version floor, missing manifest verbs) is repaired - # regardless of mode. Hermes' minimum requirement IS the confirmation an upgrade is needed, so - # this path must not defer to the driver's `check-update` verb — a cached/indeterminate "no - # update" answer would pin users on an unusable driver forever. - contract = _cua_driver_contract_status(binary) if binary else None - repair_existing = bool(binary and contract and not contract.get("ready")) - - # Compatible existing install: no download, just the host-specific setup upstream normally owns. - if binary and not upgrade and not repair_existing: - version = _cua_driver_version(binary) - suffix = "" if version is None else f": {version or 'unknown version'}" - _print_success(f" {driver_cmd} already installed{suffix}.") - if is_windows and not _repair_cua_driver_autostart_windows(binary, verbose=False): - return _fail(" cua-driver is compatible, but Windows autostart repair failed.") - _print_cua_platform_notes(is_windows, is_linux, fresh_install=False) - return True - if repair_existing: - _print_warning(f" Found cua-driver {contract.get('version') or 'unknown version'}, but " - "Hermes cannot use its current runtime contract: " - f"{contract.get('reason') or 'required runtime features are missing'}.") - if override: - return _fail(" Update the binary selected by HERMES_CUA_DRIVER_CMD, or unset the " - f"override and run: {_UPGRADE_CMD}", warn=False) - if is_windows and require_confirmed_update: - return _fail(" Automatic Windows updates cannot safely run cua-driver's interactive " - "repair installer.", - f" Repair it from an interactive terminal with: {_UPGRADE_CMD}", - warn=False) - _print_info(" Repairing it with the current upstream installer.") - - # upgrade=True path — refresh to the latest upstream release. - if not _cua_install_target_writable(): - _print_info(" /Applications is not writable; skipping cua-driver refresh.") - _print_info(f" Run `{_UPGRADE_CMD}` from an admin account to update it.") - return bool(binary) - if not shutil.which(fetch_tool): - _print_warning(f" {fetch_tool} not found — cannot refresh cua-driver.") - return bool(binary) - confirmed_version = None - if binary and not repair_existing: - proceed, confirmed_version = _confirmed_update_check(driver_cmd, require_confirmed_update) - if not proceed: - return True - if is_windows and require_confirmed_update and not binary: - # Missing binary (enabled but never installed, or wiped by a failed install): an automatic - # Windows update must never launch install.ps1, which can demand console/UAC consent the - # hidden updater cannot provide. - return _fail(" cua-driver is not installed; automatic Windows updates cannot safely run " - "its interactive installer.", - f" Install it from an interactive terminal with: {_UPGRADE_CMD}", - warn=False) - before = (_cua_driver_version(binary) or "") if binary else "" # best-effort before/after - ok = _run_cua_driver_installer( - label="Repairing" if repair_existing else "Refreshing", verbose=False, - pin_version=confirmed_version, show_progress=show_installer_progress, - installer_timeout=_CUA_BACKGROUND_UPDATE_TIMEOUT if require_confirmed_update else None) - return _report_repair_or_upgrade(ok, repair_existing=repair_existing, binary=binary, - before=before, driver_cmd=driver_cmd) + if sys.platform == "darwin": + from tools.computer_use.cua_backend_daemon import _resolve_cua_driver_app_path + return bool(_resolve_cua_driver_app_path(state["binary"])) + return sys.platform != "win32" or _cua_driver_autostart_registered_windows() -def _cua_install_home() -> "Path": - """Package home shared by the upstream POSIX and Windows installers.""" - return Path(os.environ.get("CUA_DRIVER_RS_HOME") or str(Path.home() / ".cua-driver")) +def install_cua_driver(upgrade: bool = False, show_installer_progress: bool = True) -> bool: + """Prepare the PM pin and host setup for an explicit install/upgrade command. - -def _cua_install_lock_dir() -> "Path": - """Path of the upstream installer's concurrent-install lock dir.""" - return _cua_install_home() / "packages" / ".install.lock.d" - - -def _cua_windows_install_lock_file() -> "Path": - """Path of install.ps1's FileShare::None lock file.""" - return _cua_install_home() / "install.lock" - - -def _clear_stale_windows_cua_install_lock() -> None: - """Delete install.ps1's lock file only when no process still holds it. - install.ps1 locks with ``FileShare::None``; mirror it with a zero-share ``CreateFileW`` probe - and ``FILE_FLAG_DELETE_ON_CLOSE`` so an unlocked leftover is removed atomically, with no window - in which a new installer could acquire the file between probe and delete.""" - lock_file = _cua_windows_install_lock_file() - try: - if not lock_file.is_file(): - return - import ctypes as _ctypes - from ctypes import wintypes as _wintypes - # Win32 constants used by install.ps1's FileShare::None equivalent. - delete_access, generic_read, generic_write = 0x00010000, 0x80000000, 0x40000000 - open_existing, file_attribute_normal, file_flag_delete_on_close = 3, 0x00000080, 0x04000000 - kernel32 = _ctypes.WinDLL("kernel32", use_last_error=True) - create_file, close_handle = kernel32.CreateFileW, kernel32.CloseHandle - dword = _wintypes.DWORD - create_file.argtypes = [_wintypes.LPCWSTR, dword, dword, _wintypes.LPVOID, dword, dword, - _wintypes.HANDLE] - create_file.restype = _wintypes.HANDLE - close_handle.argtypes, close_handle.restype = [_wintypes.HANDLE], _wintypes.BOOL - handle = create_file( - str(lock_file), generic_read | generic_write | delete_access, 0, # 0 = FileShare::None - None, open_existing, file_attribute_normal | file_flag_delete_on_close, None) - if handle == _wintypes.HANDLE(-1).value: - logger.debug("Windows cua install lock at %s is still held or cannot be removed " - "(winerror %s)", lock_file, _ctypes.get_last_error()) - return - if not close_handle(handle): - logger.debug("could not close Windows cua install lock probe at %s (winerror %s)", - lock_file, _ctypes.get_last_error()) - return - if lock_file.exists(): - logger.debug("Windows cua install lock probe succeeded but %s remains", lock_file) - return - logger.info("Cleared stale Windows cua-driver install lock at %s", lock_file) - _print_info(f" Cleared stale cua-driver install lock ({lock_file}).") - except Exception as e: - logger.debug("stale Windows cua install lock check failed: %s", e) - - -def _clear_stale_cua_install_lock() -> None: - """Best-effort: remove a stale installer lock left by a dead holder. - POSIX stamps the holder pid into ``~/.cua-driver/packages/.install.lock.d/info``; Windows holds - ``~/.cua-driver/install.lock`` open with ``FileShare::None``. Clear either artifact up front - only when its platform-specific liveness check proves that no install still holds it.""" - if sys.platform == "win32": - _clear_stale_windows_cua_install_lock() - return - lock_dir = _cua_install_lock_dir() - try: - if not lock_dir.is_dir(): - return - try: - lines = (lock_dir / "info").read_text(encoding="utf-8-sig", errors="replace").splitlines() - holder_pid = next((int(line.split("=", 1)[1].strip()) for line in lines - if line.startswith("pid=")), None) - except (OSError, ValueError): - holder_pid = None - if holder_pid is not None: - try: - os.kill(holder_pid, 0) # windows-footgun: ok — function early-returns on win32 - return # holder alive → concurrent install running - except ProcessLookupError: - pass # dead holder → stale, clear below - except PermissionError: - return # alive but owned by someone else — treat as live - else: - # No readable pid: only clear if old enough that upstream itself would reclaim it. - try: - if time.time() - lock_dir.stat().st_mtime < _CUA_LOCK_STALE_AFTER: - return - except OSError: - return - shutil.rmtree(lock_dir, ignore_errors=True) - logger.info("Cleared stale cua-driver install lock at %s", lock_dir) - _print_info(f" Cleared stale cua-driver install lock ({lock_dir}).") - except Exception as e: - logger.debug("stale cua install lock check failed: %s", e) - - -def _cua_install_lock_held() -> bool: - """True when the upstream installer's lock is held by a LIVE process. - Called after ``_clear_stale_cua_install_lock()``: anything provably stale is already gone, so a - surviving lock artifact means a concurrent (or orphaned-but-alive) install owns it. - - Upstream waits up to ``LOCK_STALE_AFTER_SECONDS=600`` on a held lock before probing — unattended - refreshes must not eat that wait (the 11-minute hang class, 87703): they skip instead. Best-effort: - unreadable state reports not-held so a probe failure can never block an install. See #87703. + Both CLI modes reconcile the same pin; neither discovers a vendor release. + A configured override is validated, never replaced or acquired by PM. + Unattended callers should use PM ensure directly, without interactive host setup. """ - try: - if sys.platform != "win32": - return _cua_install_lock_dir().is_dir() - lock_file = _cua_windows_install_lock_file() - if not lock_file.is_file(): - return False - # install.ps1 holds the file with FileShare::None — any open fails with a sharing - # violation while held. Surviving the stale-clear = held; confirm with an open probe. + from pm import ensure + + override = os.environ.get("HERMES_CUA_DRIVER_CMD", "").strip() + binary = _resolved_cua_driver_cmd() + fresh_install = binary is None + if override: + if not binary: + return _fail(f" HERMES_CUA_DRIVER_CMD does not resolve to an executable: {override}", + " Fix or unset the override before running computer-use install.") + else: + if show_installer_progress: + _print_info(" Preparing the pinned cua-driver with Hermes PM...") try: - with open(lock_file, "r+b"): - return False # opened fine → not held (racy leftover) - except OSError: # sharing violation surfaces as PermissionError - return True - except Exception as e: - logger.debug("cua install lock probe failed: %s", e) - return False + ensure("cua-driver", explicit=True) + except Exception as exc: + return _fail(f" cua-driver preparation failed: {exc}") + binary = _resolved_cua_driver_cmd() + if not binary: + return _fail(" PM did not select a usable cua-driver executable.") + _CUA_DRIVER_CONTRACT_CACHE.clear() + contract = _cua_driver_contract_status(binary) + if not contract.get("ready"): + hint = (" Update the binary selected by HERMES_CUA_DRIVER_CMD, or unset the override." + if override else " Run: hermes computer-use doctor") + return _fail(" cua-driver runtime contract is unusable: " + f"{contract.get('reason') or 'unknown error'}.", hint) + if sys.platform == "win32" and not _repair_cua_driver_autostart_windows( + binary, verbose=show_installer_progress): + return _fail(" cua-driver is compatible, but Windows autostart setup failed.") + if sys.platform == "darwin": + from tools.computer_use.cua_backend_daemon import ( + _resolve_cua_driver_app_path, _validate_cua_driver_app_signature) -def _cua_release_endpoint_reachable(timeout: float = 5.0) -> bool: - """Fast probe: can we reach GitHub's release download host at all? - When github.com is down the installer dies slowly inside its own retries and eats the whole - unattended ceiling; a 5s HEAD decides in seconds. Only a connection-level failure counts as - unreachable — any HTTP response (even 4xx/5xx) proves the path works.""" - import urllib.error - import urllib.request - try: - req = urllib.request.Request("https://github.com/trycua/cua/releases", method="HEAD") - with urllib.request.urlopen(req, timeout=timeout): - return True - except urllib.error.HTTPError: - return True # server answered → reachable - except Exception as e: - logger.debug("cua release endpoint probe failed: %s", e) - return False + app = _resolve_cua_driver_app_path(binary) + if not app: + return _fail(" macOS computer use requires the signed CuaDriver.app, not a bare binary.", + " The PM cua-driver package must include its signed macOS app bundle.") + try: + _validate_cua_driver_app_signature(app) + result = _run_text([ + "/System/Library/Frameworks/CoreServices.framework/Frameworks/" + "LaunchServices.framework/Support/lsregister", "-f", app], timeout=15) + except (RuntimeError, OSError, subprocess.SubprocessError) as exc: + return _fail(f" cua-driver macOS app registration failed: {exc}") + if result.returncode: + _print_output_tail(result) + return _fail(" cua-driver macOS app registration failed.") + if show_installer_progress: + _print_success(f" cua-driver ready: {contract.get('version') or 'unknown version'}.") + _print_cua_platform_notes(sys.platform == "win32", sys.platform == "linux", + fresh_install=fresh_install) + return True def _ps_single_quote(value: str) -> str: - """Return a PowerShell single-quoted string literal.""" return "'" + value.replace("'", "''") + "'" -def _cua_driver_autostart_registered_windows() -> bool: - """Return whether the Windows cua-driver scheduled task is registered.""" +def _cua_driver_autostart_registered_windows(binary: Optional[str] = None) -> bool: + """A task targeting a previous PM version is not a ready registration.""" if sys.platform != "win32": return False + from xml.etree import ElementTree + + binary = binary or _resolved_cua_driver_cmd() + if not binary: + return False try: - return subprocess.run(["schtasks.exe", "/Query", "/TN", "cua-driver-serve"], - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, - timeout=10).returncode == 0 - except Exception: + result = subprocess.run( + ["schtasks.exe", "/Query", "/TN", "cua-driver-serve", "/XML"], + capture_output=True, timeout=10, creationflags=_post_setup_no_window_flags()) + if result.returncode: + return False + # Parse bytes: schtasks' XML declaration carries the output encoding. + task = ElementTree.fromstring(result.stdout) + commands = task.findall(".//{*}Exec/{*}Command") + return any(os.path.normcase((node.text or "").strip().strip('"')) == os.path.normcase(binary) + for node in commands) + except (OSError, subprocess.SubprocessError, ElementTree.ParseError): return False def _repair_cua_driver_autostart_windows(driver_cmd: str, *, verbose: bool) -> bool: - """Best-effort repair for Windows installer autostart quoting failures. - Older install.ps1 builds interpolated the binary path into a PowerShell command string, which - split at the first space. If the scheduled task is missing, retry via Start-Process's - structured ``-FilePath`` / ``-ArgumentList`` parameters instead.""" - if sys.platform != "win32" or _cua_driver_autostart_registered_windows(): + """Register autostart using structured arguments, including paths with spaces.""" + if sys.platform != "win32": return True binary = shutil.which(driver_cmd) if not binary: return False + if _cua_driver_autostart_registered_windows(binary): + return True ps = shutil.which("powershell") or shutil.which("powershell.exe") or "powershell" ps_cmd = (f"$exe = {_ps_single_quote(binary)}; " "$proc = Start-Process -FilePath $exe -ArgumentList @('autostart','enable') " @@ -493,20 +209,14 @@ def _repair_cua_driver_autostart_windows(driver_cmd: str, *, verbose: bool) -> b except Exception as exc: return _fail(f" cua-driver autostart registration failed: {exc}") if result.returncode == 0: - return True + return _cua_driver_autostart_registered_windows(binary) _print_warning(" cua-driver autostart registration failed.") _print_output_tail(result) - _print_info(" From an elevated shell, run: cua-driver autostart enable") + _print_info(f" From an elevated shell, run: & {_ps_single_quote(binary)} autostart enable") return False -def _remove_quietly(path: str) -> None: - with contextlib.suppress(OSError): - os.remove(path) - - def _print_cua_platform_notes(is_windows: bool, is_linux: bool, *, fresh_install: bool) -> None: - """Host-specific follow-up notes after an install or a compatible-install check.""" if is_windows: _print_info(" cua-driver may spawn a UIAccess worker (cua-driver-uia.exe);") _print_info(" Windows/SmartScreen may prompt the first time it runs.") @@ -517,230 +227,4 @@ def _print_cua_platform_notes(is_windows: bool, is_linux: bool, *, fresh_install else " Grant macOS permissions if not done yet:") _print_info(" System Settings > Privacy & Security > Accessibility") _print_info(" System Settings > Privacy & Security > Screen Recording") - if fresh_install: - _print_info(" Both must allow the terminal / Hermes process.") - - -def _kill_installer_tree(proc, *, is_windows: bool) -> None: - """Kill the installer and its descendants (best-effort).""" - import signal as _signal - try: - if not is_windows: - os.killpg(os.getpgid(proc.pid), _signal.SIGKILL) # windows-footgun: ok — POSIX only - return - # PowerShell may leave download/install helpers alive after its direct process is killed; - # they inherit stdout and can keep communicate() and install.lock wedged → kill leaf-up. - import psutil as _psutil - try: - parent = _psutil.Process(proc.pid) - descendants = parent.children(recursive=True) - except _psutil.NoSuchProcess: - return - except _psutil.Error as e: - logger.debug("could not enumerate cua-driver installer tree for pid %s: %s", - proc.pid, e) - proc.kill() - return - for target, pid in [(c, c.pid) for c in reversed(descendants)] + [(parent, proc.pid)]: - try: - target.kill() - except _psutil.NoSuchProcess: - pass - except _psutil.Error as e: - what = "parent" if target is parent else "child" - logger.debug("could not kill cua-driver installer %s pid %s: %s", what, pid, e) - if target is parent: - proc.kill() - except (OSError, ProcessLookupError): - proc.kill() - - -def _reap_after_timeout(proc, *, is_windows: bool) -> None: - """Kill the installer tree, then drain its pipes under a deadline. - An unbounded drain blocks on an EOF that only arrives when someone kills a surviving descendant - by hand, so ``_CUA_INSTALLER_TIMEOUT`` would stop bounding anything. - - Bound the drain instead: a kill that landed closes the pipe at once, and one that did not costs - ``_CUA_INSTALLER_DRAIN_GRACE`` rather than forever. The caller re-raises the original ``TimeoutExpired`` - either way, so the manual re-run hint still prints and the update unwinds. Losing the tail of a - timed-out installer's log is the cheaper half of that trade. See #87703. - """ - _kill_installer_tree(proc, is_windows=is_windows) - try: - drained_out, _ = proc.communicate(timeout=_CUA_INSTALLER_DRAIN_GRACE) - # Partial output names WHERE the installer was stuck (lock wait, consent prompt, download). - # Diagnosability (#87703 post-mortem): the partial output names WHERE the installer was stuck (lock - # wait, consent prompt, download) — before this, the answer died with the process and the timeout - # line was unactionable. - if drained_out: - logger.warning("cua-driver installer timed out; last output before kill:\n%s", - drained_out[-2000:]) - except subprocess.TimeoutExpired: - # Deliberately not closing proc.stdout: communicate()'s reader threads are still blocked on - # that handle and closing it underneath them races; they are daemon threads. - logger.debug("cua-driver installer pipes still open %ss after the kill — " - "abandoning the drain, a surviving descendant holds the inherited handle", - _CUA_INSTALLER_DRAIN_GRACE) - except (OSError, ValueError) as e: - logger.debug("cua-driver installer drain failed: %s", e) - - -def _cua_installer_command(is_windows: bool): - """Return ``(install_cmd, manual_hint, script_path)``; all None if the POSIX download fails.""" - if is_windows: - ps_oneliner = f"irm {_CUA_INSTALL_PS1_URL} | iex" # mirrors cua_driver_install_hint() - return (["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", ps_oneliner], - f'powershell -NoProfile -ExecutionPolicy Bypass -Command "{ps_oneliner}"', None) - - # Download-then-exec instead of `bash -c "$(curl …)"`: no shell=True, no command substitution, - # and the script lands in a mkstemp file (unpredictable name, 0600) rather than a fixed /tmp - # path — avoiding both shell injection and a symlink/TOCTOU race. The manual hint stays the - # upstream one-liner. - import tempfile as _tempfile - manual_hint = f'/bin/bash -c "$(curl -fsSL {_CUA_INSTALL_SH_URL})"' - fd, script_path = _tempfile.mkstemp(prefix="cua-driver-install-", suffix=".sh") - os.close(fd) - try: - dl = _run_text(["curl", "-fsSL", "-o", script_path, _CUA_INSTALL_SH_URL], timeout=120) - failure = None if dl.returncode == 0 else (dl.stderr or "").strip()[:200] - except (subprocess.TimeoutExpired, OSError) as e: - failure = str(e) - if failure is not None: - _print_warning(f" cua-driver installer download failed: {failure}") - _remove_quietly(script_path) - return None, None, None - return ["/bin/bash", script_path], manual_hint, script_path - - -def _unattended_installer_preflight(install_cmd: list, is_windows: bool): - """Fail FAST on the two conditions that otherwise consume the whole unattended ceiling: - (1) install lock held by a live process — upstream would poll it for up to - LOCK_STALE_AFTER_SECONDS=600 before probing the holder (the 11-minute silent hang class); - (2) release host unreachable — the installer dies slowly inside its own retries; a 5s HEAD - answers. Returns the (possibly rewritten) install command, or None to skip this refresh. - Explicit `install --upgrade` runs never come here and keep upstream's full lock-recovery.""" - if _cua_install_lock_held(): - _fail(" Another cua-driver install is in progress (upstream install lock is held) — " - "skipping this refresh.", - f" If no install is really running, retry with: {_UPGRADE_CMD}", warn=False) - return None - if not _cua_release_endpoint_reachable(): - _print_info(" github.com is unreachable — skipping cua-driver refresh " - "(will retry on the next update).") - return None - if is_windows: - # -NoAutoStart skips Register-CuaDriverAutostart — the ONLY branch of install.ps1 that - # self-elevates (UAC). Cost: an existing cua-driver-serve task keeps pointing at the - # previous binary until the next interactive upgrade. Scriptblock invocation (not `| iex`) - # is what lets us pass the parameter. - install_cmd = [ - "powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", - f"$sc = irm {_CUA_INSTALL_PS1_URL}; & ([scriptblock]::Create($sc)) -NoAutoStart"] - return install_cmd - - -def _installer_popen_kwargs(is_windows: bool, verbose: bool, env: dict) -> dict: - """Popen kwargs for the upstream installer. - POSIX: own process group so a timeout kill takes out the whole `curl | bash` pipeline (and the - exec'd _install-rust.sh), not just the outer shell — surviving grandchildren would keep - holding the install lock and wedge every later run. Non-verbose (`hermes update` refresh): - capture the chatty "Next steps" wall and log it so a failure stays debuggable; verbose - interactive installs stream live.""" - kwargs: dict = {"shell": False, "env": env} - if not is_windows: - kwargs["start_new_session"] = True - if verbose: - kwargs["creationflags"] = _post_setup_no_window_flags(streams_to_console=True) - else: - kwargs.update(stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, - text=True, encoding="utf-8", errors="replace", - creationflags=_post_setup_no_window_flags()) - return kwargs - - -def _record_installer_output(out: str, returncode: int) -> None: - """Keep a captured (non-verbose) installer transcript without echoing it to the terminal. - During `hermes update`, sys.stdout is the mirroring _UpdateOutputStream whose `_log` handle is - ~/.hermes/logs/update.log — write straight to it so the full output is kept (success AND - failure).""" - _update_log = getattr(sys.stdout, "_log", None) - if _update_log is not None: - try: - _update_log.write("\n--- cua-driver installer output ---\n" + out + "\n") - _update_log.flush() - except Exception: - pass - if returncode != 0: - logger.debug("cua-driver installer output:\n%s", out) - - -def _run_cua_driver_installer(label: str = "Installing", verbose: bool = True, - pin_version: Optional[str] = None, show_progress: bool = True, - installer_timeout: Optional[float] = None) -> bool: - """Run the upstream cua-driver installer (idempotent: always the latest release, so re-running - upgrades). ``installer_timeout`` lets quiet callers use a shorter ceiling without weakening the - explicit install path's stale-lock recovery window.""" - system = platform.system() - is_windows, is_linux = system == "Windows", system == "Linux" - install_cmd, manual_hint, script_path = _cua_installer_command(is_windows) - if install_cmd is None: - return False - if show_progress: - _print_info(f" {label} cua-driver (background computer-use)..." if verbose - else f"→ {label} cua-driver (Computer Use)...") - driver_cmd = _cua_driver_cmd() - timeout = _CUA_INSTALLER_TIMEOUT if installer_timeout is None else installer_timeout - installer_env = _cua_driver_env() - if pin_version: # both upstream installers honour CUA_DRIVER_RS_VERSION over the baked default - installer_env["CUA_DRIVER_RS_VERSION"] = pin_version - # A previous timed-out install can leave upstream's concurrent-install lock behind; clear it - # when provably stale so the refresh doesn't wedge waiting on a dead holder. - # See #58762. - _clear_stale_cua_install_lock() - - # Unattended refreshes (installer_timeout set by `hermes update`) preflight and may skip. - # Unattended refreshes (installer_timeout set by `hermes update`) fail FAST on the two conditions that - # otherwise consume the whole ceiling: 1. Install lock held by a live process — upstream would poll it - # for up to LOCK_STALE_AFTER_SECONDS=600 before probing the holder. That is the 11-minute silent hang - # class (#87703; observed live 2026-08-25: "cua-driver refreshing timed out after 660s"). 2. Release - # host unreachable (outage/DNS/firewall) — the installer would die slowly inside its own retries. A 5s - # HEAD answers now. Explicit `computer-use install --upgrade` runs keep upstream's full lock-recovery - # semantics — a human is watching and can wait or Ctrl-C. - if installer_timeout is not None: - install_cmd = _unattended_installer_preflight(install_cmd, is_windows) - if install_cmd is None: - return False - popen_kwargs = _installer_popen_kwargs(is_windows, verbose, installer_env) - try: - proc = subprocess.Popen(install_cmd, **popen_kwargs) - try: - communicated = proc.communicate(timeout=timeout) - except subprocess.TimeoutExpired: - _reap_after_timeout(proc, is_windows=is_windows) - raise - out = None if verbose else communicated[0] - if out: - _record_installer_output(out, proc.returncode) - installed_binary = _resolved_cua_driver_cmd() - if proc.returncode == 0 and installed_binary: - if is_windows and not _repair_cua_driver_autostart_windows(installed_binary, - verbose=verbose): - _print_warning(" cua-driver installed, but auto-start was not registered.") - if verbose: - _print_success(f" {driver_cmd} installed.") - _print_cua_platform_notes(is_windows, is_linux, fresh_install=True) - return True - return _fail(f" cua-driver {label.lower()} did not complete. Re-run manually:", - f" {manual_hint}") - except subprocess.TimeoutExpired: - _print_warning(f" cua-driver {label.lower()} timed out after {timeout}s.") - if not is_windows: - _print_info(" If this repeats, a stale installer lock may be present — check " - f"{_cua_install_lock_dir()}") - _print_info(f" Re-run manually: {manual_hint}") - return False - except Exception as e: - return _fail(f" cua-driver {label.lower()} failed: {e}") - finally: - if script_path: - _remove_quietly(script_path) + _print_info(" Allow CuaDriver.app; run `hermes computer-use permissions grant` for guidance.") \ No newline at end of file diff --git a/hermes_cli/tools_config_post_setup.py b/hermes_cli/tools_config_post_setup.py index 05ff2149b5..f9b5a0e98e 100644 --- a/hermes_cli/tools_config_post_setup.py +++ b/hermes_cli/tools_config_post_setup.py @@ -2,25 +2,17 @@ from __future__ import annotations -import logging import os +import shlex import shutil -import subprocess import sys -from pathlib import Path from typing import Set from hermes_cli.cli_output import ( print_error as _print_error, print_info as _print_info, print_success as _print_success, print_warning as _print_warning) from hermes_cli.config import get_env_value -from hermes_cli.tools_config_cua import ( - _cua_driver_install_ready, _post_setup_no_window_flags, _run_text, install_cua_driver, -) - -logger = logging.getLogger("hermes_cli.tools_config") - -PROJECT_ROOT = Path(__file__).parent.parent.resolve() +from hermes_cli.tools_config_cua import _cua_driver_install_ready, install_cua_driver def _info_lines(*lines: str) -> None: @@ -66,126 +58,63 @@ def _post_setup_lightpanda() -> None: _print_info(" Lightpanda has no native Windows build; run Hermes under WSL2.") -def _install_chromium() -> None: - """Install the managed full Chromium package, without a second headless shell.""" - _print_info(" Installing pinned Chromium...") - try: - import pm - pm.ensure("chromium", explicit=True) - _print_success(" Chromium installed") - # Invalidate the cached "missing" flag so later check_browser_requirements() calls see the install. - import tools.browser_tool as _bt - _bt._cached_chromium_installed = None - except Exception as exc: - _print_warning(f" Chromium install failed: {exc}") - _print_info(" Run manually: hermes pm install chromium") - return - - # Preserve --with-deps on apt-based Linux without invoking a browser downloader. - if sys.platform != "linux" or not shutil.which("apt-get"): - return - try: - from tools.browser_tool_install import _resolve_npx_bin - npx_bin = _resolve_npx_bin() - if not npx_bin: - _print_warning(" npx not found - Chromium system dependencies were not installed") - else: - from tools.browser_tool import _build_browser_env - - env = _build_browser_env() - env["PATH"] = f"{Path(npx_bin).parent}{os.pathsep}{env.get('PATH', '')}" - _print_info(" Installing Chromium system dependencies...") - result = _run_text( - [npx_bin, "--ignore-scripts", "-y", "playwright@1.62.1", "install-deps", "chromium"], - cwd=str(PROJECT_ROOT), timeout=600, - env=env, - creationflags=_post_setup_no_window_flags()) - if result.returncode == 0: - return - _print_warning(" Chromium system dependency install failed:") - for line in (result.stderr or result.stdout or "").strip().splitlines()[-3:]: - _print_info(f" {line[:200]}") - except Exception as exc: - _print_warning(f" Chromium system dependency install failed: {exc}") - _print_info(" Run manually: npx playwright install-deps chromium") - - def _post_setup_agent_browser(post_setup_key: str) -> None: - """``agent_browser`` (local Chromium) and ``browserbase`` (cloud rows) hooks. - agent-browser is not a root package.json dependency — it resolves lazily via npx (or a - global/Hermes-managed install), so there is no ``npm install`` step here.""" + """PM owns the driver and Chromium; Termux and Docker own their native payloads.""" # Every non-Camofox backend drives through the Browser Use CLI — install it here too. _ensure_browser_use_cli() try: - # Lazy import so the tools_config UI doesn't pull in browser_tool at import time. - # agent-browser resolves lazily via npx on the default install (#43564), invisible to the - # PATH/node_modules probes above. Mirror the rung hermes_cli.doctor uses so this probe can't diverge - # from it, including the Termux carve-out (bare npx is too fragile to advertise as ready there — see - # check_browser_requirements). - # agent-browser is no longer a root package.json dependency (#43564) — it resolves lazily via npx - # for most installs, which a bare PATH + node_modules probe can't see. Mirror the local-CLI tail of - # :func:`tools.browser_tool_install.check_browser_requirements` (same cascade, same Termux carve-out) so the - # setup/status surfaces can't diverge from what browser tools actually find at runtime; - # validate=False keeps this a cheap existence check with no subprocess spawn. - # agent-browser is no longer a root package.json dependency (#43564) — it resolves lazily via npx - # (or a global/Hermes-managed install) instead of a local `npm install`, so there's no node_modules/ - # population step here anymore. from tools.browser_tool_install import ( - _chromium_installed, _running_in_docker, _find_agent_browser) + _browser_install_hint, _chromium_installed, _running_in_docker, _find_agent_browser) + from hermes_constants import is_termux except Exception as exc: # pragma: no cover — defensive _print_warning(f" Could not check Chromium status: {exc}") return - # Reuse the runtime resolution cascade (PATH -> Homebrew/Hermes-managed node -> npx) rather than - # a bare shutil.which — Hermes-managed-Node-only setups resolve agent-browser/npx only that way. + termux = is_termux() + docker = _running_in_docker() + if termux or docker: + try: + _find_agent_browser(validate=False) + except FileNotFoundError: + _print_warning(f" agent-browser is missing. Install it explicitly: {_browser_install_hint()}") + return + if docker and post_setup_key == "agent_browser" and not _chromium_installed(): + _print_warning(" Chromium is missing but you're running in Docker.") + _info_lines("Pull the latest image to get the bundled Chromium:", + " docker pull ghcr.io/nousresearch/hermes-agent:latest") + return + try: - _find_agent_browser(validate=False) - except FileNotFoundError: - _print_warning(" npx not found - browser tools require Node.js: https://nodejs.org") + import pm + # Chromium is a declared dependency; do not acquire it a second time. + pm.ensure("agent-browser", explicit=True) + except Exception as exc: + _print_warning(f" agent-browser install failed: {exc}") + _info_lines("Retry with: hermes tools post-setup " + post_setup_key) return + _print_success(" Managed agent-browser and Chromium are ready") - # Only the local provider needs Chromium on disk; cloud providers host their own. - if post_setup_key != "agent_browser": - return - - # Without Chromium the CLI hangs on first use until the command timeout fires. Skip inside - # Docker — the image bakes Chromium in, and runtime users usually can't write PLAYWRIGHT_BROWSERS_PATH. - if _chromium_installed(): - _print_success(" Chromium browser already installed, nothing to do") - return - - if _running_in_docker(): - _print_warning(" Chromium is missing but you're running in Docker.") - _info_lines("Pull the latest image to get the bundled Chromium:", - " docker pull ghcr.io/nousresearch/hermes-agent:latest") - return - - _install_chromium() + # OS libraries are host-owned. Never download another package manager to install them. + if post_setup_key == "agent_browser" and sys.platform == "linux": + _info_lines("Chromium also needs system libraries supplied by your distribution.") + if shutil.which("apt-get") and _module_installed("playwright"): + command = shlex.join([sys.executable, "-m", "playwright", "install-deps", "chromium"]) + _info_lines(f"Install missing system libraries with: {command}") + else: + _info_lines("System dependency installation guide:", + " https://playwright.dev/python/docs/browsers#install-system-dependencies") def _post_setup_camofox() -> None: - from hermes_constants import find_node_executable + from tools.browser_camofox import check_camofox_available - camofox_dir = PROJECT_ROOT / "node_modules" / "@askjo" / "camofox-browser" - _npm_bin = find_node_executable("npm") - if camofox_dir.exists(): - _print_success(" Camofox already installed, nothing to do") - elif _npm_bin: - _print_info(" Installing Camofox browser server...") - # Absolute npm path so the .cmd shim executes on Windows; --workspaces=false avoids resolving apps/desktop. - result = _run_text([_npm_bin, "install", "--silent", "--workspaces=false"], timeout=None, - cwd=str(PROJECT_ROOT), creationflags=_post_setup_no_window_flags()) - if result.returncode == 0: - _print_success(" Camofox installed") - else: - _print_warning(" npm install failed - run manually: npm install --workspaces=false") - if camofox_dir.exists(): - _info_lines("Start the Camofox server:", " npx @askjo/camofox-browser", - "First run downloads the Camoufox engine (~300MB)", - "Or use Docker: docker run -p 9377:9377 -e CAMOFOX_PORT=9377 jo-inc/camofox-browser") - elif not _npm_bin: - _print_warning(" Node.js not found. Install Camofox via Docker:") - _print_info(" docker run -p 9377:9377 -e CAMOFOX_PORT=9377 jo-inc/camofox-browser") + _info_lines("Camofox is an externally managed server; Hermes does not install or start it.") + if check_camofox_available(): + _print_success(" Configured Camofox server is reachable") + return + _print_warning(" Camofox server is not reachable. Start your server and check CAMOFOX_URL.") + _info_lines("Server setup: https://github.com/jo-inc/camofox-browser", + "Docker: docker run -p 9377:9377 -e CAMOFOX_PORT=9377 jo-inc/camofox-browser") # The hook key is the UI provider identifier; extra names belong to pyproject.toml. @@ -214,33 +143,20 @@ _PYTHON_POST_SETUP_HOOKS: dict = { "Pair with an extract provider if you also need web_extract."))} -def _importable(module: str) -> bool: - try: - __import__(module) - return True - except ImportError: - return False - - def _post_setup_python(spec: dict) -> None: """Enable one Python provider through the application dependency transaction.""" import pm label = spec["label"] - lines = list(spec["always"]) - if _importable(spec["module"]): - _print_success(f" {label} is already installed") - else: - _print_info(f" {spec['installing']}") - try: - pm.sync_venv([spec["extra"]], explicit=True) - except (pm.InstallError, OSError, ValueError) as exc: - _print_warning(f" {label} install failed: {exc}") - _info_lines("Retry with: hermes tools") - return - _print_success(f" {label} installed. Restart Hermes to use it.") - lines = list(spec["on_install"]) + lines - _info_lines(*lines) + _print_info(f" {spec['installing']}") + try: + pm.sync_venv([spec["extra"]], explicit=True) + except (pm.InstallError, OSError, ValueError) as exc: + _print_warning(f" {label} install failed: {exc}") + _info_lines("Retry with: hermes tools") + return + _print_success(f" {label} dependencies ready. Restart Hermes to use them.") + _info_lines(*spec["on_install"], *spec["always"]) def _post_setup_spotify() -> None: @@ -268,31 +184,23 @@ def _post_setup_spotify() -> None: def _post_setup_langfuse() -> None: - if _importable("langfuse"): - _print_success(" langfuse SDK already installed") - else: - _print_info(" Installing langfuse SDK...") - import pm - try: - pm.sync_venv(["langfuse"], explicit=True) - except (pm.InstallError, OSError, ValueError) as exc: - _print_warning(f" langfuse SDK install failed: {exc}") - _info_lines("Retry with: hermes tools") - return - _print_success(" langfuse SDK installed. Restart Hermes to use it.") - # The bundled observability/langfuse plugin is opt-in (standalone plugins don't load until enabled). + import pm + + # The bundled plugin has no dependency member; its SDK is an application extra. + _print_info(" Preparing langfuse SDK...") try: - from hermes_cli.plugins_cmd import _get_enabled_set, _save_enabled_set - enabled = _get_enabled_set() - if "observability/langfuse" in enabled or "langfuse" in enabled: - _print_success(" Plugin observability/langfuse already enabled") - else: - enabled.add("observability/langfuse") - _save_enabled_set(enabled) - _print_success(" Plugin observability/langfuse enabled") - except Exception as exc: + pm.sync_venv(["langfuse"], explicit=True) + except (pm.InstallError, OSError, ValueError) as exc: + _print_warning(f" langfuse SDK install failed: {exc}") + _info_lines("Retry with: hermes tools") + return + try: + from hermes_cli.plugins_cmd import cmd_enable + cmd_enable("observability/langfuse") + except (Exception, SystemExit) as exc: _print_warning(f" Could not enable plugin automatically: {exc}") _info_lines("Run manually: hermes plugins enable observability/langfuse") + return _info_lines("Restart Hermes for tracing to take effect.", "Verify: hermes plugins list") @@ -436,16 +344,13 @@ def _agent_browser_installed() -> bool: setup" flips to installed only when re-running it would be a no-op.""" from hermes_cli.nous_subscription import _local_browser_runnable - # The hook runs in a spawned process; this probe runs in the long-lived web-server/CLI process whose - # browser_tool may have cached a stale "Chromium missing" result. Drop the cache so the pill flips to Ready. - if (bt := sys.modules.get("tools.browser_tool")) is not None: - bt._cached_chromium_installed = None return _local_browser_runnable() def _camofox_installed() -> bool: - """True when the Camofox npm package ``_run_post_setup("camofox")`` installs is in node_modules.""" - return (PROJECT_ROOT / "node_modules" / "@askjo" / "camofox-browser").exists() + """Readiness belongs to the configured external server, not root node_modules.""" + from tools.browser_camofox import check_camofox_available + return check_camofox_available() def _lightpanda_installed() -> bool: diff --git a/hermes_cli/update_cmd_maint.py b/hermes_cli/update_cmd_maint.py index 94491e1fa2..d5e5b2d11c 100644 --- a/hermes_cli/update_cmd_maint.py +++ b/hermes_cli/update_cmd_maint.py @@ -28,6 +28,9 @@ def _prepare_updated_checkout(project_root: Path, *, desktop: bool) -> None: import pm pm.sync_venv(explicit=True, project_root=project_root) + from hermes_cli.venv_sync import publish_launchers + + publish_launchers(project_root) from hermes_cli.runtime_paths import activation_environment, selected_venv # The updater still holds pre-pull imports. Build only in the newly selected Python. @@ -892,22 +895,29 @@ def _sync_profiles_after_update() -> None: def _refresh_cua_driver_after_update() -> None: - """cua-driver refresh, no-op unless on PATH; tied to update for a predictable cadence - without a per-launch GitHub API call.""" - refresh_cua_driver = True - with _best_effort('Could not read updates.refresh_cua_driver: %s'): - refresh_cua_driver = bool(_load_updates_cfg().get("refresh_cua_driver", True)) + """Reconcile an installed optional package, never a user-selected external binary.""" + import pm - if ( - refresh_cua_driver and sys.platform in ("darwin", "win32", "linux") and shutil.which("cua-driver") - ): - from hermes_cli.tools_config import install_cua_driver - print() - print("→ Refreshing cua-driver (Computer Use)...") - # require_confirmed_update: install only when check-update positively reports a - # newer release (update must stay fast; `computer-use install --upgrade` forces). - # Windows defers even confirmed updates (installer may need console/UAC consent). - install_cua_driver(upgrade=True, require_confirmed_update=True, show_installer_progress=False) + if not _load_updates_cfg().get("refresh_cua_driver", True): + return + if os.environ.get("HERMES_CUA_DRIVER_CMD", "").strip(): + return + if pm.installed_package("cua-driver", allow_outdated=True) is None: + return + if sys.platform == "win32": + # The scheduled task targets a versioned binary. Selecting a new pin + # without re-registering leaves it stale; registration requires UAC. + print("\n→ Windows cua-driver refresh deferred (autostart registration requires UAC).") + print(" Run `hermes computer-use install --upgrade` in an interactive terminal.") + return + print("\n→ Preparing pinned cua-driver (Computer Use)...") + if sys.platform == "darwin": + # PM preserves the signed app; setup validates and registers its new path + # with LaunchServices. This path never requests permissions or elevation. + from hermes_cli.tools_config_cua import install_cua_driver + install_cua_driver(show_installer_progress=False) + else: + pm.ensure("cua-driver", explicit=True) def _print_plugin_compat_notice() -> None: diff --git a/hermes_cli/update_cmd_validation.py b/hermes_cli/update_cmd_validation.py index c12fc83377..3c77e17fe8 100644 --- a/hermes_cli/update_cmd_validation.py +++ b/hermes_cli/update_cmd_validation.py @@ -1,11 +1,9 @@ """Source import-integrity checks for update and stash restoration.""" -from contextlib import suppress import json import subprocess -import sys from pathlib import Path -from hermes_constants import venv_python_path +from hermes_cli._launchers import runtime_command # Modules imported on every startup. Unlike _UPDATE_CRITICAL_FILES (only parsed) these are # *imported*, catching cross-module breakage (a name pulled from a sibling no longer exists). @@ -17,11 +15,11 @@ def _critical_module_import_failures( """Import each ``_UPDATE_CRITICAL_MODULES`` entry in a subprocess; return failures in probe order. Syntax validation only *parses*: a partially-updated tree (Windows ZIP copy loop) parses yet - dies with ``ImportError: cannot import name``. The subprocess (venv interpreter when present — - the updater may run under another Python) keeps import side effects out of our ``sys.modules``. + dies with ``ImportError: cannot import name``. The boot-selected subprocess + keeps import side effects out of the updater's ``sys.modules``. Generic import-time exceptions are tolerated unless ``report_runtime_errors=True``. """ - from hermes_cli.update_cmd import _UPDATE_CRITICAL_MODULES, _m + from hermes_cli.update_cmd import _UPDATE_CRITICAL_MODULES from hermes_constants import FIRST_PARTY_MODULE_ROOTS import secrets marker = f"__HERMES_IMPORT_HEALTH_{secrets.token_hex(16)}__" @@ -48,13 +46,8 @@ def _critical_module_import_failures( % (_UPDATE_CRITICAL_MODULES, tuple(sorted(FIRST_PARTY_MODULE_ROOTS)), report_runtime_errors, report_runtime_errors, marker)) try: - interpreter = sys.executable - with suppress(Exception): - venv_python = venv_python_path(Path(root) / "venv", windows=_m()._is_windows()) - if venv_python.exists(): - interpreter = str(venv_python) result = subprocess.run( - [interpreter, "-c", probe], cwd=str(root), capture_output=True, text=True, + runtime_command(Path(root), code=probe), cwd=str(root), capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=120) except subprocess.TimeoutExpired: return _probe_failure("TimeoutExpired", "timed out before reporting import health") diff --git a/hermes_cli/venv_sync.py b/hermes_cli/venv_sync.py index 8c44d2879c..cd312df88a 100644 --- a/hermes_cli/venv_sync.py +++ b/hermes_cli/venv_sync.py @@ -49,6 +49,22 @@ def _is_sealed(project_root: Path) -> bool: return True +def publish_launchers(project_root: Path) -> None: + """Refresh the durable source command before an old Python can be collected.""" + from hermes_cli._launchers import ENTRY_POINTS, ensure_install_launchers, resolve_store_python + from hermes_cli.steward import read_install_stamp + + root = Path(project_root) + if (_is_sealed(root) or read_install_stamp(root).get("updateMechanism") == "external" + or resolve_store_python(root) is None): + return # Sealed and external/Nix interpreters retain their own launchers. + written = ensure_install_launchers(root, root / ".hermes" / "bin") + if len(written) != len(ENTRY_POINTS): + from pm.package import InstallError + + raise InstallError("launchers", "source launcher publication failed", "retry the source update") + + def sync(project_root: Path | None = None, *, check: bool = False) -> dict: """Report or sync dependencies. A malformed install stamp is a build error.""" root = Path(project_root) if project_root is not None else _project_root() @@ -60,10 +76,13 @@ def sync(project_root: Path | None = None, *, check: bool = False) -> dict: import pm if pm.venv_is_current(project_root=root): + if not check: + publish_launchers(root) return {"state": "current", "ok": True} if check: return {"state": "would-sync", "ok": True} pm.sync_venv(explicit=True, project_root=root) + publish_launchers(root) return {"state": "synced", "ok": True} except Exception as exc: return {"state": "failed", "ok": False, "detail": str(exc)} @@ -123,6 +142,7 @@ def prepare_launch(project_root: Path, argv: list[str]) -> Path | None: if python is None: raise RuntimeError("source update has no managed Python; run `hermes pm install`") if not current or python.absolute() != Path(sys.executable).absolute(): + publish_launchers(root) return python return None diff --git a/hermes_cli/web_routers/memory_providers.py b/hermes_cli/web_routers/memory_providers.py index 0234c05957..ab8644c633 100644 --- a/hermes_cli/web_routers/memory_providers.py +++ b/hermes_cli/web_routers/memory_providers.py @@ -36,7 +36,6 @@ get_hermes_home = late("get_hermes_home", "hermes_cli.config") load_config = late("load_config", "hermes_cli.config") save_config = late("save_config", "hermes_cli.config") save_env_value = late("save_env_value", "hermes_cli.config") -_dependency_importable = late("_dependency_importable", "hermes_cli.web_server_memory") load_env = late("load_env", "hermes_cli.config") # Sentinel: remove this key so it falls back to the host or built-in default. _UNSET: Any = object() @@ -329,45 +328,15 @@ def _command_result( } -def _install_memory_provider_pip_dependencies(name: str, dependencies: List[str]) -> List[Dict[str, Any]]: - import pm +def _install_memory_provider_python_dependencies(name: str) -> List[Dict[str, Any]]: + from hermes_cli.memory_setup import prepare_memory_provider_dependencies - manifest = _memory_provider_manifest(name) - extra = str(manifest.get("extra") or "").strip() - if not dependencies and not extra: - return [] - missing = [dep for dep in dependencies if not _dependency_importable(dep)] - if not missing and (not extra or pm.available(extra)): - if not dependencies: - return [] - return [_command_result(kind="pip", name=", ".join(dependencies), status="already_installed")] - # Setup precedes config selection. Include the candidate without dropping - # active providers; PM resolves legacy declarations and pyprojects alike. - target = ", ".join(missing) or extra command = "hermes pm install" try: - from hermes_cli.plugins_admission import candidate_member_dirs - from pm.workspace import _is_member_candidate - from plugins.memory import find_provider_dir - - plugin_dir = find_provider_dir(name) - member = plugin_dir is not None and _is_member_candidate(plugin_dir) - if not extra and not member: - return [_command_result( - kind="pip", name=target, status="failed", command=command, - error="no declared extra and no plugin directory to materialize declared dependencies from", - )] - inputs = {"plugin_dirs": lambda: candidate_member_dirs((), extra_dirs=[plugin_dir])} if member else {} - pm.sync_venv([extra] if extra else None, explicit=True, **inputs) + _manifest, status = prepare_memory_provider_dependencies(name) except Exception as exc: - return [_command_result(kind="pip", name=target, status="failed", command=command, error=str(exc))] - still_missing = [dep for dep in missing if not _dependency_importable(dep)] - if still_missing or (extra and not pm.available(extra)): - # The environment is selected at boot: a sync that succeeded outside - # this interpreter's sight is NOT immediate import success — report - # the truth instead of stamping installed without the deps visible. - return [_command_result(kind="pip", name=target, status="restart_required", command=command)] - return [_command_result(kind="pip", name=target, status="installed", command=command)] + return [_command_result(kind="pip", name=name, status="failed", command=command, error=str(exc))] + return [_command_result(kind="pip", name=name, status=status, command=command)] if status else [] def _run_setup_step(results: list, kind: str, name: str, command: str, status_of, **kwargs) -> Optional[int]: @@ -410,8 +379,12 @@ def _install_memory_provider_setup(name: str) -> Dict[str, Any]: manifest = _memory_provider_manifest(name) if provider is None and not manifest: raise _unknown_provider(name) - setup = _memory_provider_setup_manifest(name) - results = _install_memory_provider_pip_dependencies(name, setup["pip_dependencies"]) + results = _install_memory_provider_python_dependencies(name) + try: + setup, _inputs = _memory_provider_setup_manifest(name) + except Exception as exc: + results.append(_command_result(kind="setup", name=name, status="failed", error=str(exc))) + setup = {"external_dependencies": []} results.extend(_install_memory_provider_external_dependencies(setup["external_dependencies"])) if not results: results.append(_command_result(kind="setup", name=name, status="no_declared_steps")) diff --git a/hermes_cli/web_routers/messaging.py b/hermes_cli/web_routers/messaging.py index 747aabba67..263da92a41 100644 --- a/hermes_cli/web_routers/messaging.py +++ b/hermes_cli/web_routers/messaging.py @@ -359,22 +359,27 @@ def _ensure_whatsapp_bridge_dependencies(bridge_dir: Path) -> None: from hermes_constants import find_node_executable, with_hermes_node_path from utils import env_int + import pm npm = find_node_executable("npm") - if not npm: - raise HTTPException(status_code=500, detail="npm was not found. WhatsApp setup needs Node.js and npm.") try: + env = with_hermes_node_path() + if npm is None: + env = pm.ensure("npm", explicit=True).env + installed = pm.installed_package("npm") + assert installed is not None and installed.binary is not None + npm = str(installed.binary) # npm output is UTF-8; encoding= guards the Windows ANSI-code-page # default against undefined bytes crashing the reader thread. result = subprocess.run( [npm, "install", "--silent"], cwd=str(bridge_dir), capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=env_int("WHATSAPP_NPM_INSTALL_TIMEOUT", 300), - env=with_hermes_node_path(), creationflags=windows_hide_flags(), + env=env, creationflags=windows_hide_flags(), ) except subprocess.TimeoutExpired as exc: raise HTTPException(status_code=500, detail="Installing WhatsApp bridge dependencies timed out.") from exc - except OSError as exc: + except (pm.InstallError, OSError) as exc: raise HTTPException(status_code=500, detail=f"Failed to install WhatsApp bridge dependencies: {exc}") from exc if result.returncode != 0: @@ -390,11 +395,18 @@ def _spawn_whatsapp_pairing_process(session_path: Path, mode: str) -> subprocess bridge_script = bridge_dir / "bridge.js" if not bridge_script.exists(): raise HTTPException(status_code=500, detail=f"WhatsApp bridge script was not found at {bridge_script}.") + _ensure_whatsapp_bridge_dependencies(bridge_dir) node = find_node_executable("node") if not node: - raise HTTPException(status_code=500, detail="Node.js was not found. WhatsApp setup needs Node.js.") + import pm - _ensure_whatsapp_bridge_dependencies(bridge_dir) + try: + pm.ensure("node", explicit=True) + installed = pm.installed_package("node") + assert installed is not None and installed.binary is not None + node = str(installed.binary) + except pm.InstallError as exc: + raise HTTPException(status_code=500, detail=f"Node.js preparation failed: {exc}") from exc session_path.mkdir(parents=True, exist_ok=True) env = with_hermes_node_path() diff --git a/hermes_cli/web_server_gateway.py b/hermes_cli/web_server_gateway.py index 63aacbded8..bc1a58032d 100644 --- a/hermes_cli/web_server_gateway.py +++ b/hermes_cli/web_server_gateway.py @@ -291,38 +291,6 @@ def _terminate_desktop_managed_gateway() -> None: pass # exited between poll() and terminate() -def _dashboard_spawn_executable() -> str: - """Interpreter for detached dashboard actions: the install's venv python when it differs - from ``sys.executable``, else ``sys.executable``. - - Under an SSH remote backend the server runs on the uv BASE interpreter with the venv's - site-packages injected into sys.path at startup, so ``sys.executable`` is dependency-less and - a detached child dies on its first third-party import; the venv launcher resolves the same - dependency set on its own. Paths are compared UNRESOLVED: the venv python is typically a - symlink to the base interpreter, so resolving would make them compare equal (exactly the - case this fixes), and pyvenv.cfg discovery keys off argv0's unresolved location. On Windows - the console python plus ``windows_detach_flags()`` keeps the action invisible without - pythonw.exe (which makes every console descendant flash its own conhost). - - See #90026. - Falls back to ``sys.executable`` when no venv interpreter exists next to the install (in-process dev - runs, exotic layouts). See #54220, #56747. - """ - from hermes_cli.web_server import PROJECT_ROOT - exe = Path(sys.executable) - try: - for rel in ("venv/bin/python", "venv/Scripts/python.exe"): - candidate = PROJECT_ROOT / rel - if candidate.is_file(): - if os.path.normcase(os.path.normpath(str(candidate))) == ( - os.path.normcase(os.path.normpath(str(exe)))): - return sys.executable - return str(candidate) - except OSError: - pass - return sys.executable - - def _named_profile_from_action(subcommand: List[str]) -> Optional[str]: """Return the named-profile selector that :func:`_profile_cli_args` puts in front of an action. @@ -404,7 +372,8 @@ def _spawn_hermes_action( log_file = open(_ACTION_LOG_DIR / _ACTION_LOG_FILES[name], "ab", buffering=0) log_file.write(f"\n=== {name} started {time.strftime('%Y-%m-%d %H:%M:%S')} ===\n".encode()) - cmd = [_dashboard_spawn_executable(), "-m", "hermes_cli.main", *subcommand] + from hermes_cli._launchers import runtime_command + cmd = runtime_command(PROJECT_ROOT, subcommand) # Named-profile actions get a scrubbed, pinned environment so the child cannot inherit the # dashboard profile's credentials; see _profile_action_environment (also drops _HERMES_GATEWAY). action_env = _profile_action_environment(subcommand, env_overrides) diff --git a/hermes_cli/web_server_memory.py b/hermes_cli/web_server_memory.py index 7f5ec6213b..29415673f9 100644 --- a/hermes_cli/web_server_memory.py +++ b/hermes_cli/web_server_memory.py @@ -4,10 +4,8 @@ import logging import json import os -import re import shlex import subprocess -import hermes_yaml as yaml from fastapi import HTTPException from pathlib import Path from typing import Any, Dict, List, Optional @@ -15,14 +13,6 @@ from typing import Any, Dict, List, Optional # Same logger the code used before extraction (record parity). _log = logging.getLogger("hermes_cli.web_server") -_MEMORY_PROVIDER_IMPORT_NAMES = { - "honcho-ai": "honcho", - "mem0ai": "mem0", - "hindsight-client": "hindsight_client", - "hindsight-all": "hindsight", -} - - def _normalize_memory_provider_name(name: Any) -> str: provider = str(name or "").strip() return "" if provider.lower() in {"built-in", "builtin", "none"} else provider @@ -40,17 +30,10 @@ def _load_memory_provider(name: str): def _memory_provider_manifest(name: str) -> Dict[str, Any]: try: - from plugins.memory import find_provider_dir + from hermes_cli.memory_setup import memory_provider_dependency_inputs - provider_dir = find_provider_dir(name) - if provider_dir is None: - return {} - manifest_path = provider_dir / "plugin.yaml" - if not manifest_path.exists(): - return {} - with manifest_path.open(encoding="utf-8-sig") as handle: - manifest = yaml.safe_load(handle) or {} - return manifest if isinstance(manifest, dict) else {} + manifest, _inputs = memory_provider_dependency_inputs(name) + return manifest except Exception: _log.debug("Failed to read memory provider manifest for %s", name, exc_info=True) return {} @@ -62,8 +45,10 @@ def _string_list(value: Any) -> List[str]: return [str(item).strip() for item in value if str(item).strip()] -def _memory_provider_setup_manifest(name: str) -> Dict[str, Any]: - manifest = _memory_provider_manifest(name) +def _memory_provider_setup_manifest(name: str) -> tuple[dict, dict]: + from hermes_cli.memory_setup import memory_provider_dependency_inputs + + manifest, inputs = memory_provider_dependency_inputs(name) external_dependencies: List[Dict[str, str]] = [] for raw in manifest.get("external_dependencies") or []: if not isinstance(raw, dict): @@ -72,33 +57,31 @@ def _memory_provider_setup_manifest(name: str) -> Dict[str, Any]: if any(dep.values()): external_dependencies.append(dep) return { - "pip_dependencies": _string_list(manifest.get("pip_dependencies")), + # Display only; neither import names nor these labels determine readiness. + "pip_dependencies": list(dict.fromkeys( + _string_list(manifest.get("pip_dependencies")) + _string_list(manifest.get("python_dependencies")))), + "python_dependencies_declared": bool(inputs), "external_dependencies": external_dependencies, "required_env": _string_list(manifest.get("requires_env")), - } + }, inputs def _memory_provider_setup_info(name: str) -> Dict[str, Any]: - setup = _memory_provider_setup_manifest(name) - setup["dependencies_installed"] = _memory_provider_dependencies_installed(setup) - return setup + import pm - -def _memory_provider_dependency_package(dep: str) -> str: - return re.split(r"[\[<>=!~;]", dep, maxsplit=1)[0].strip() - - -def _memory_provider_import_name(dep: str) -> str: - package = _memory_provider_dependency_package(dep) - return _MEMORY_PROVIDER_IMPORT_NAMES.get(package, package.replace("-", "_")) - - -def _dependency_importable(dep: str) -> bool: - import_name = _memory_provider_import_name(dep) try: - return bool(import_name) and __import__(import_name) is not None - except ImportError: - return False + setup, inputs = _memory_provider_setup_manifest(name) + except Exception: + _log.debug("Invalid dependency declaration for %s", name, exc_info=True) + return {"pip_dependencies": [], "python_dependencies_declared": True, + "external_dependencies": [], "required_env": [], "dependencies_installed": False} + try: + python_ready = not inputs or pm.venv_is_current(**inputs) + except Exception: + _log.debug("Could not read dependency state for %s", name, exc_info=True) + python_ready = False + setup["dependencies_installed"] = python_ready and _memory_provider_external_dependencies_installed(setup) + return setup def _memory_provider_setup_env() -> Dict[str, str]: @@ -135,8 +118,7 @@ def _run_setup_command( ) -def _memory_provider_dependencies_installed(setup: Dict[str, Any]) -> bool: - pip_ok = all(_dependency_importable(dep) for dep in _string_list(setup.get("pip_dependencies"))) +def _memory_provider_external_dependencies_installed(setup: Dict[str, Any]) -> bool: external_ok = True for dep in setup.get("external_dependencies") or []: if not isinstance(dep, dict): @@ -153,7 +135,7 @@ def _memory_provider_dependencies_installed(setup: Dict[str, Any]) -> bool: continue if completed.returncode != 0: external_ok = False - return pip_ok and external_ok + return external_ok def _schema_field_kind(raw: Dict[str, Any], choices: list) -> str: @@ -335,7 +317,7 @@ def _memory_provider_is_configured(name: str, provider: Any) -> bool: def _memory_provider_status(row: Dict[str, Any], setup: Dict[str, Any], configured: bool, schema_fields: list) -> str: if row["missing"]: return "missing" - if not row["available"] and not setup.get("dependencies_installed", True): + if not setup.get("dependencies_installed", True): return "unavailable" if not configured or (not row["available"] and schema_fields): return "needs_config" diff --git a/hermes_cli/windows_ssh_runtime.py b/hermes_cli/windows_ssh_runtime.py index 975c2b9a89..b318b719fb 100644 --- a/hermes_cli/windows_ssh_runtime.py +++ b/hermes_cli/windows_ssh_runtime.py @@ -341,34 +341,25 @@ def terminate_owned(pid: int, creation_time_ns: int, hermes_path: str, spawn_non return True -def _resolve_direct_interpreter(python_entry: str) -> tuple[str, list[str]]: - """Resolve the venv launcher to (base interpreter, sys.path to reproduce). +def _resolve_direct_command(hermes_path: str) -> list[str]: + """Ask the configured installation for its direct, boot-selecting command. - On Windows a venv Scripts\\python.exe is a stub that spawns the real interpreter as a CHILD - (two PIDs); spawning the base interpreter with the launcher's sys.path injected yields ONE - process that both owns the port and is the one we lock. hermes_cli's parent is prepended - because the launcher finds it via cwd / an editable-install hook a bare PYTHONPATH lacks.""" - query = ( - "import sys,json,os,importlib.util as u;" - "s=u.find_spec('hermes_cli');" - "root=os.path.dirname(os.path.dirname(s.origin)) if s and s.origin else '';" - "print(json.dumps({'base':getattr(sys,'_base_executable','') or sys.executable," - "'path':[p for p in sys.path if p],'root':root}))") - out = subprocess.run([python_entry, "-c", query], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=30) + The port owner must be the process we lock, not a console-launcher child. + No assumption about python.exe beside an external bin launcher is valid. + """ + out = subprocess.run([hermes_path, "--print-runtime-command"], capture_output=True, + text=True, encoding="utf-8", errors="replace", timeout=30) if out.returncode != 0: - raise ValueError("could not resolve the base Python interpreter") - info = json.loads(out.stdout.strip().splitlines()[-1]) - base = info["base"] - if not base or not os.path.isfile(base): - raise ValueError("base Python interpreter was not found") - # keep only real filesystem entries (drops '__editable__.*' finder markers) - py_path = [p for p in info.get("path", []) if os.path.exists(p)] - root = info.get("root") or "" - if not root or not os.path.isdir(root): - raise ValueError("could not locate the hermes_cli package") - if root not in py_path: - py_path.insert(0, root) - return base, py_path + raise ValueError("could not resolve Hermes runtime; refresh this installation's launcher") + try: + command = json.loads(out.stdout) + except ValueError as exc: + raise ValueError("Hermes launcher did not report a runtime command") from exc + if (not isinstance(command, list) or not command + or not all(isinstance(part, str) and "\x00" not in part for part in command) + or not os.path.isabs(command[0]) or not os.path.isfile(command[0])): + raise ValueError("Hermes launcher reported an invalid runtime command") + return command def spawn_backend(payload: dict[str, Any]) -> dict[str, Any]: @@ -382,24 +373,13 @@ def spawn_backend(payload: dict[str, Any]) -> dict[str, Any]: profile = str(payload.get("profile") or "") if len(profile) > 256 or any(ch in profile for ch in "\x00\r\n"): raise ValueError("invalid profile") - venv_dir = os.path.dirname(hermes_path) - python_entry = os.path.join(venv_dir, "python.exe") - if not os.path.isfile(python_entry): - raise ValueError("Hermes Python runtime was not found") - base_python, sys_path = _resolve_direct_interpreter(python_entry) - # Seed sys.path IN-PROCESS via -c rather than PYTHONPATH, which every subprocess the backend - # spawns (terminal tool, user scripts) would inherit, shadowing their imports. - bootstrap = ( - "import sys,runpy;" - f"sys.path[:0]={sys_path!r};" - "runpy.run_module('hermes_cli.main',run_name='__main__',alter_sys=True)") - args = [base_python, "-c", bootstrap] + args = _resolve_direct_command(hermes_path) if profile: args.extend(["--profile", profile]) args.extend(["serve", "--isolated", "--host", "127.0.0.1", "--port", "0", "--ssh-session-token-file", token_path, "--ssh-owner-nonce", spawn_nonce]) env = dict(os.environ) - env["VIRTUAL_ENV"] = os.path.dirname(venv_dir) + env.pop("VIRTUAL_ENV", None) env.pop("PYTHONPATH", None) _ensure_scope(ownership_id) log_path = _log_path(ownership_id, spawn_nonce) diff --git a/hermes_constants.py b/hermes_constants.py index 2f1a90a884..cc2fa2b859 100644 --- a/hermes_constants.py +++ b/hermes_constants.py @@ -329,10 +329,7 @@ def get_hermes_dir(new_subpath: str, old_name: str, *, home: Path | None = None) def iter_hermes_node_dirs(home: Path | None = None) -> list[Path]: - """Hermes-managed Node dirs in lookup order; both Windows and POSIX shapes so migrated installs work. - - Keep in sync with hermesManagedNodePathEntries() in apps/desktop/electron/backend-env.ts. - """ + """Historical layout for old-updater diagnostics, never runtime selection.""" node_dir = (home or get_hermes_home()) / "node" return [node_dir, node_dir / "bin"] if sys.platform == "win32" else [node_dir / "bin", node_dir] @@ -349,26 +346,6 @@ def _candidate_node_command_names(command: str) -> list[str]: # Prefer npm.cmd: PowerShell may block npm.ps1 by policy; CreateProcess cannot launch a bare .ps1. return _WINDOWS_NODE_SHIMS.get(base.lower(), [f"{base}.cmd", f"{base}.exe", base]) - -def _iter_managed_node_candidates(names: list[str], home: Path | None = None): - """Yield existing (and on POSIX, executable) ``/`` files.""" - for directory in iter_hermes_node_dirs(home): - for name in names: - candidate = directory / name - if candidate.is_file() and (sys.platform == "win32" or os.access(candidate, os.X_OK)): - yield candidate - - -def _first_runnable_managed(names: list[str]) -> tuple[str | None, bool]: - """Return ``(first runnable candidate, saw a broken one)``.""" - broken = False - for candidate in map(str, _iter_managed_node_candidates(names)): - if node_tool_runnable(candidate): - return candidate, broken - broken = True - return None, broken - - def _run_version_probe(argv: list[str], **kwargs): """Run a hidden ``--version`` probe; ``None`` when it cannot run.""" import subprocess @@ -387,10 +364,6 @@ def _version_probe_ok(path: str) -> bool: return result is not None and result.returncode == 0 -_HERMES_NODE_TARGET_MAJOR = int(os.environ.get("HERMES_NODE_TARGET_MAJOR", "22")) -_managed_node_heal_attempted = False -_NODE_BOOTSTRAP_SCRIPT = Path(__file__).resolve().parent / "scripts" / "lib" / "node-bootstrap.sh" - # Install tree root (this file lives at /hermes_constants.py). Used by secure_parent_dir() to # skip chmod on the install dir — chmodding it 0700 breaks hermes-user traversal in Docker (UID 10000). See # #25821, #93050. @@ -411,335 +384,58 @@ def node_tool_runnable(path: str | None) -> bool: def hermes_managed_node_tree_present(home: Path | None = None) -> bool: - """Return True when any Hermes-managed node/npm/npx shim exists on disk.""" + """Read-only legacy artifact detection for already-running old updaters.""" names = [n for c in ("node", "npm", "npx") for n in _candidate_node_command_names(c)] - return next(_iter_managed_node_candidates(names, home), None) is not None - - -def _path_under_any(path: str, roots: list[str]) -> bool: - """True when *path* sits inside one of *roots* (same drive). - - Compared via ``normcase``: psutil and env vars can disagree on Windows drive-letter casing. - """ - path_norm = os.path.normcase(os.path.normpath(path)) - for root in roots: - root_norm = os.path.normcase(os.path.normpath(root)) - try: - if os.path.commonpath([path_norm, root_norm]) == root_norm: - return True - except ValueError: # different drives on Windows - continue - return False - - -def managed_node_tree_in_use(home: Path | None = None) -> bool: - """True when a running process executes from the managed Node tree. - - Windows locks running executables against delete/overwrite, so the updater must not rewrite - ``%HERMES_HOME%\\node`` while the desktop app holds it (``[WinError 5]`` on ``npm.cmd``). - - Always ``False`` on POSIX, which has no equivalent lock semantics. See #80926. - """ - if sys.platform != "win32": - return False - try: - import psutil - except Exception: - return False - dirs: list[str] = [] - for directory in iter_hermes_node_dirs(home): - try: - dirs.append(str(Path(directory).resolve())) - except OSError: - continue - if not dirs: - return False - try: - procs = psutil.process_iter(["exe", "cmdline"]) - except Exception: - return False - for proc in procs: - try: - info = proc.info - except Exception: - continue - exe = info.get("exe") - if exe: - try: - exe = str(Path(exe).resolve()) - except (OSError, ValueError): - exe = str(exe) - if any(_path_under_any(p, dirs) for p in ([exe] if exe else []) + list(info.get("cmdline") or [])): - return True - return False - - -_managed_node_in_use_notice_printed = False - - -def _print_managed_node_in_use_notice() -> None: - """Print the managed-Node deferral notice once per process.""" - global _managed_node_in_use_notice_printed - if _managed_node_in_use_notice_printed: - return - _managed_node_in_use_notice_printed = True - print( - "→ Hermes-managed Node.js is in use by a running app; deferring its " - "upgrade until the app is closed (re-run `hermes update` afterwards).", flush=True, - ) - - -def _fetch_url(url: str, timeout: int) -> bytes | None: - import urllib.request - - try: - with urllib.request.urlopen(url, timeout=timeout) as response: - return response.read() - except OSError: - return None - - -def _stage_windows_node_zip(home: Path, node_arch: str) -> Path | None: - """Download the target-major portable Node zip into a sibling ``node.new-*`` dir. - - A sibling makes the later swap a same-volume rename. ``None`` on any failure. - """ - import tempfile - import uuid - import zipfile - - index_url = f"https://nodejs.org/dist/latest-v{_HERMES_NODE_TARGET_MAJOR}.x/" - index_bytes = _fetch_url(index_url, 60) - if index_bytes is None: - return None - pattern = rf"node-v{_HERMES_NODE_TARGET_MAJOR}\.\d+\.\d+-win-{node_arch}\.zip" - match = re.search(pattern, index_bytes.decode("utf-8", errors="replace")) - if not match: - return None - zip_name = match.group(0) - zip_bytes = _fetch_url(f"{index_url}{zip_name}", 300) - if zip_bytes is None: - return None - staged = home / f"node.new-{uuid.uuid4().hex[:8]}" - try: - with tempfile.TemporaryDirectory() as tmp_dir: - tmp_path = Path(tmp_dir) - (tmp_path / zip_name).write_bytes(zip_bytes) - extract_dir = tmp_path / "extract" - extract_dir.mkdir() - with zipfile.ZipFile(tmp_path / zip_name) as archive: - archive.extractall(extract_dir) - extracted = next(extract_dir.glob("node-v*"), None) - if extracted is None or not extracted.is_dir(): - return None - shutil.move(str(extracted), str(staged)) - except OSError: - return None - return staged - - -def _swap_node_tree(target: Path, staged: Path) -> bool | None: - """Rename the live tree aside (``node.old-*``) and *staged* into place. - - ``None`` when the OS refuses to move the live tree (in use; untouched, retried next time), - ``False`` when the staged tree cannot be moved in (live tree rolled back). - """ - backup = target.parent / f"node.old-{staged.name.removeprefix('node.new-')}" - had_live = target.exists() - if had_live: - try: - os.replace(str(target), str(backup)) - except OSError: - _print_managed_node_in_use_notice() - shutil.rmtree(staged, ignore_errors=True) - return None - # Rename preserves mtime: touch the backup (best-effort) so a concurrent heal's - # litter sweep never removes it mid-swap. - with contextlib.suppress(OSError): - os.utime(backup, None) - try: - os.replace(str(staged), str(target)) - except OSError: - if had_live: # roll the live tree back - with contextlib.suppress(OSError): - os.replace(str(backup), str(target)) - shutil.rmtree(staged, ignore_errors=True) - return False - if had_live: - # Locked files may keep the old tree on disk until the next heal; safe. - shutil.rmtree(backup, ignore_errors=True) - return True - - -def _heal_managed_node_windows(home: Path | None = None) -> bool | None: - """Redownload the portable Node zip into ``%HERMES_HOME%\\node`` on Windows. - - ``True`` on success, ``False`` on genuine failure (offline, bad archive), ``None`` when the - tree is in use and the heal is deferred — callers must not record the once-per-process attempt - for ``None``. Staging-first (extract to ``node.new-*``, rename live aside, rename staged in) so - an interrupted heal cannot gut the install; a refused rename *is* the in-use signal. - - The replacement is staging-first: the new tree is fully downloaded and extracted to a sibling - ``node.new-*`` directory, then the live tree is renamed aside (``node.old-*``) and the staged tree - renamed into place. The live tree is never deleted before its replacement is ready, so an interrupted - heal cannot gut the running installation. Windows allows renaming a tree whose executables are running - (images are mapped with ``FILE_SHARE_DELETE`` — the same mechanism as the hermes.exe quarantine); when - the OS refuses the rename, that refusal *is* the in-use signal and the heal defers instead of forcing - the write and crashing with ``PermissionError: [WinError 5]`` on ``npm.cmd`` (#80926). - """ - import time - - arch = (os.environ.get("PROCESSOR_ARCHITEW6432") or os.environ.get("PROCESSOR_ARCHITECTURE", "")).lower() - node_arch = {"amd64": "x64", "x86_64": "x64", "arm64": "arm64", "x86": "x86"}.get(arch) - if node_arch is None: - return False - home = home or get_hermes_home() - target = home / "node" - # Cheap pre-check; the rename-based swap is the authoritative guard. - if managed_node_tree_in_use(home): - _print_managed_node_in_use_notice() - return None - # Sweep litter from interrupted runs; only dirs >10 min old so a concurrent in-flight swap survives. - cutoff = time.time() - 600 - for stale in (*home.glob("node.old-*"), *home.glob("node.new-*")): - try: - if stale.stat().st_mtime < cutoff: - shutil.rmtree(stale, ignore_errors=True) - except OSError: - continue - staged = _stage_windows_node_zip(home, node_arch) - if staged is None: - return False - return _swap_node_tree(target, staged) and node_tool_runnable(str(target / "node.exe")) - - -def _run_node_bootstrap(func: str, *, timeout: int, **extra_env: str) -> bool: - """Source ``scripts/lib/node-bootstrap.sh`` and run shell function *func*.""" - if not _NODE_BOOTSTRAP_SCRIPT.is_file(): - return False - import subprocess - try: - result = subprocess.run( - ["bash", "-c", f'source "{_NODE_BOOTSTRAP_SCRIPT}" && {func}'], - env={**os.environ, "HERMES_HOME": str(get_hermes_home()), **extra_env}, - capture_output=True, timeout=timeout, check=False, - ) - except (OSError, subprocess.SubprocessError): - return False - return result.returncode == 0 - - -def bootstrap_hermes_managed_node() -> str | None: - """Install a Hermes-managed Node tree under ``$HERMES_HOME/node`` and return its npm path. - - Hermes never modifies a user-owned toolchain (system, nvm, brew, Nix) that fails ``engines``. - """ - existing = find_hermes_node_executable("npm") - if existing: - return existing - if sys.platform == "win32": - ok = _heal_managed_node_windows() - else: - # HERMES_NODE_SKIP_LINKS=1 keeps node/npm/npx out of ~/.local/bin: never shadow the user toolchain. - ok = _run_node_bootstrap("_nb_install_bundled_node", timeout=600, HERMES_NODE_SKIP_LINKS="1") - if not ok: - return None - return _first_runnable_managed(_candidate_node_command_names("npm"))[0] - - -def heal_hermes_managed_node() -> bool: - """Redownload Hermes-managed Node when the tree exists but is broken; at most once per process. - - A Windows in-use deferral does NOT record the attempt so a later call can heal once free. - - POSIX installs shell out to ``heal_managed_node`` in ``scripts/lib/node-bootstrap.sh``; Windows - downloads the portable zip directly (same source as ``install.ps1``). See #80926. - """ - global _managed_node_heal_attempted - if _managed_node_heal_attempted or not hermes_managed_node_tree_present(): - return False - if sys.platform == "win32": - result = _heal_managed_node_windows() - else: - result = _run_node_bootstrap("heal_managed_node", timeout=300) - if result is None: # in-use deferral: leave the attempt flag clear - return False - _managed_node_heal_attempted = True - return bool(result) - - -def _managed_node_tree_outdated(home: Path | None = None) -> bool: - """True when the managed node runs but is below the target major (heals like a broken tree).""" - for candidate in _iter_managed_node_candidates(_candidate_node_command_names("node"), home): - result = _run_version_probe([str(candidate), "--version"]) - if result is None: - return False # broken, not outdated — the runnable probe handles it - try: - version = result.stdout.decode().strip().lstrip("v") - major = int(version.split(".")[0]) - except (ValueError, IndexError): - return False - # A pre-release is outdated whatever its major: nodejs.org publishes headers only for - # final releases, so node-gyp cannot build node-pty. Mirrors node_satisfies_build() in install.sh. - if "-" in version: - return True - return major < _HERMES_NODE_TARGET_MAJOR - return False - - -def find_hermes_node_executable(command: str) -> str | None: - """Hermes-managed Node/npm path, healing broken/outdated trees; heal failure still returns old Node.""" - names = _candidate_node_command_names(command) - resolved, broken_present = _first_runnable_managed(names) - needs_heal = broken_present or (resolved is not None and _managed_node_tree_outdated()) - if needs_heal and heal_hermes_managed_node(): - healed, _ = _first_runnable_managed(names) - if healed: - return healed - return resolved - - -def find_node_executable_on_path(command: str) -> str | None: - """Node/npm from PATH; on Windows prefer ``.cmd``/``.exe`` (CreateProcess cannot run the bare shim).""" - if sys.platform != "win32": - return shutil.which(command) - command_str = str(command) - if any(sep and sep in command_str for sep in (os.sep, os.altsep, "/", "\\")): - return command_str if Path(command_str).is_file() else None - directories = [d for d in os.environ.get("PATH", "").split(os.pathsep) if d] - for name in _candidate_node_command_names(command_str): - for directory in directories: - if (Path(directory) / name).is_file(): - return str(Path(directory) / name) - return None + return any((directory / name).is_file() for directory in iter_hermes_node_dirs(home) for name in names) def find_node_executable(command: str) -> str | None: - """Resolve a Node command, preferring a healthy managed install. + """Read PM's selected Node/npm/npx, then a user-owned PATH toolchain. - A managed tree that exists but cannot be healed yields ``None`` rather than system Node. + Explicit executable paths remain caller-owned. Discovery never installs, + probes, repairs, or activates the retired ``HERMES_HOME/node`` layout. """ - managed = find_hermes_node_executable(command) - if managed: - return managed - if hermes_managed_node_tree_present(): - return None - return find_node_executable_on_path(command) + command = str(command) + if any(sep in command for sep in ("/", "\\")): + if sys.platform == "win32": + return command if Path(command).is_file() else None + return shutil.which(command) + base = command.lower() + for suffix in (".cmd", ".exe", ".ps1"): + base = base.removesuffix(suffix) + package_name = {"node": "node", "npm": "npm", "npx": "npm"}.get(base) + if package_name is not None: + from pm import installed_package + + installed = installed_package(package_name) + if installed is not None and installed.binary is not None: + if base != "npx": + return str(installed.binary) + for name in _candidate_node_command_names("npx"): + candidate = installed.binary.parent / name + if candidate.is_file(): + return str(candidate) + return None + if sys.platform != "win32": + return shutil.which(command) + directories = [d for d in os.environ.get("PATH", "").split(os.pathsep) if d] + for name in _candidate_node_command_names(command): + for directory in directories: + candidate = Path(directory) / name + if candidate.is_file(): + return str(candidate) + return None def with_hermes_node_path(env: dict[str, str] | None = None) -> dict[str, str]: - """Return *env* with Hermes-managed Node directories prepended to PATH.""" - merged = dict(os.environ if env is None else env) - parts = [p for p in merged.get("PATH", "").split(os.pathsep) if p] - for entry in reversed([str(path) for path in iter_hermes_node_dirs() if path.is_dir()]): - if entry not in parts: - parts.insert(0, entry) - merged["PATH"] = os.pathsep.join(parts) - return merged + """Compose installed PM npm and its Node dependency without provisioning.""" + from pm import env_for + + return env_for("npm", base_env=env) def agent_browser_runnable(path: str | None) -> bool: - """True when *path* is an agent-browser CLI that runs (``--version`` exits 0) or the npx fallback. + """True when *path* is an agent-browser CLI that runs (``--version`` exits 0). Dead/wrong-arch/hung binaries are rejected so callers try the next candidate. @@ -751,9 +447,6 @@ def agent_browser_runnable(path: str | None) -> bool: """ if not path: return False - # The npx fallback is a two-token command string, not a path; npx validates at run time. - if " " in path and path.split()[0].endswith("npx"): - return True return _is_executable_file(path) and _version_probe_ok(path) @@ -1293,47 +986,6 @@ def emit_partial_update_hint(exc: BaseException, *, file=None) -> bool: print(line, file=sys.stderr if file is None else file) return True -def _pm_node_executable(command: str) -> str | None: - """The pm store's node/npm/npx binary for *command*, when installed. - - node and npm are pm packages; npx ships inside npm's store entry, so it - resolves as a sibling of the npm binary. Returns ``None`` when pm has not - installed the package (the caller falls back to PATH). - """ - base = Path(str(command)).name.lower() - for suffix in (".cmd", ".exe", ".ps1"): - if base.endswith(suffix): - base = base[: -len(suffix)] - package_name = {"node": "node", "npm": "npm", "npx": "npm"}.get(base) - if package_name is None: - return None - try: - import pm - - if not pm.is_installed(package_name): - return None - from pm.ensure import _facts, _store - from pm.registry import get_package - from pm.store import current_target - - fact = _facts().get(package_name) - if fact is None: - return None - binary = get_package(package_name).binary( - _store().entry(fact["entry"]), current_target() - ) - if binary is None or not binary.is_file(): - return None - if base == "npx": - for name in _candidate_node_command_names("npx"): - candidate = binary.parent / name - if candidate.is_file(): - return str(candidate) - return None - return str(binary) - except Exception: - pass - return None def normalize_scope(scope: str | Path | None) -> str | None: """Normalize a WRITE-side registry scope key, preserving ``None``. diff --git a/optional-skills/productivity/memento-flashcards/SKILL.md b/optional-skills/productivity/memento-flashcards/SKILL.md index a41252402b..cc27877e70 100644 --- a/optional-skills/productivity/memento-flashcards/SKILL.md +++ b/optional-skills/productivity/memento-flashcards/SKILL.md @@ -202,11 +202,22 @@ python3 ~/.hermes/skills/productivity/memento-flashcards/scripts/youtube_quiz.py This returns `{"title": "...", "transcript": "..."}` or an error. -If the script reports `missing_dependency`, tell the user to install it: +If the script reports `missing_dependency`, use `terminal` with a PM-prepared +Hermes checkout to prepare the declared `youtube` extra, then reactivate: + ```bash -pip install youtube-transcript-api +python -c "import pm; pm.sync_venv(['youtube'], explicit=True)" +source ./activate +python -c "import youtube_transcript_api; print(youtube_transcript_api.__file__)" ``` +Follow the isolated development-home setup in +[Package Management](https://hermes-agent.nousresearch.com/docs/reference/package-management#developer-workflow) +before preparation. Retry `youtube_quiz.py` with that Python and the actual +skill directory returned by `skill_view`. For a remote or sandbox terminal, +prepare an independent helper environment on that host. Never pip-install into +Hermes's selected environment. + **Step 3:** Generate 5 quiz questions from the transcript. Use these rules: ``` @@ -295,7 +306,7 @@ Returns JSON with: - **Never edit `cards.json` directly** — always use the script subcommands to avoid corruption - **Transcript failures** — some YouTube videos have no English transcript or have transcripts disabled; inform the user and suggest another video -- **Optional dependency** — `youtube_quiz.py` needs `youtube-transcript-api`; if missing, tell the user to run `pip install youtube-transcript-api` +- **Optional dependency** — `youtube_quiz.py` needs `youtube-transcript-api`; use the PM preparation and interpreter check above if missing. - **Large imports** — CSV imports with thousands of rows work fine but the JSON output may be verbose; summarize the result for the user - **Video ID extraction** — support both `youtube.com/watch?v=ID` and `youtu.be/ID` URL formats @@ -312,7 +323,7 @@ python3 ~/.hermes/skills/productivity/memento-flashcards/scripts/memento_cards.p If you are testing from the repo checkout, run: ```bash -pytest tests/skills/test_memento_cards.py tests/skills/test_youtube_quiz.py -q +scripts/run_tests.sh tests/skills/test_memento_cards.py tests/skills/test_youtube_quiz.py -q ``` Agent-level verification: diff --git a/optional-skills/productivity/memento-flashcards/scripts/youtube_quiz.py b/optional-skills/productivity/memento-flashcards/scripts/youtube_quiz.py index 5b6f44ca74..1f7c416366 100644 --- a/optional-skills/productivity/memento-flashcards/scripts/youtube_quiz.py +++ b/optional-skills/productivity/memento-flashcards/scripts/youtube_quiz.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Fetch YouTube transcripts for Memento quiz generation. -Requires: pip install youtube-transcript-api +Requires the isolated PM helper environment described in memento-flashcards/SKILL.md. The quiz question *generation* is done by the agent's LLM — this script only fetches transcripts. """ @@ -32,7 +32,7 @@ def cmd_fetch(args: argparse.Namespace) -> None: _out({ "ok": False, "error": "missing_dependency", - "message": "Run: pip install youtube-transcript-api", + "message": "Use the isolated PM helper environment described in memento-flashcards/SKILL.md.", }) sys.exit(1) diff --git a/plugins/memory/hindsight/__init__.py b/plugins/memory/hindsight/__init__.py index d95846b79e..2f788ffc62 100644 --- a/plugins/memory/hindsight/__init__.py +++ b/plugins/memory/hindsight/__init__.py @@ -41,7 +41,7 @@ from .embedded import ( from .embedded_runtime import ensure_daemon_and_url as _start_sideenv_daemon from .settings import ( _DEFAULT_API_URL, _DEFAULT_IDLE_TIMEOUT, _DEFAULT_LOCAL_URL, _DEFAULT_RETAIN_SOURCE, - _DEFAULT_TIMEOUT, _HINDSIGHT_GLYPH, _MIN_CLIENT_VERSION, _MIN_VERSION_FOR_UPDATE_MODE_APPEND, + _DEFAULT_TIMEOUT, _HINDSIGHT_GLYPH, _MIN_VERSION_FOR_UPDATE_MODE_APPEND, _PROVIDER_DEFAULT_MODELS, _VALID_BUDGETS, _daemon_llm_provider, _normalize_observation_scopes, _normalize_retain_tags, _parse_int_setting, _resolve_bank_id_template, @@ -68,23 +68,6 @@ def _cloud_api_key(config: dict) -> str: return config.get("apiKey") or config.get("api_key") or get_secret("HINDSIGHT_API_KEY", "") -def _maybe_upgrade_client() -> None: - """Auto-upgrade an outdated hindsight-client via pm's venv sync (uv.lock owns the pin).""" - try: - from importlib.metadata import version as pkg_version - from packaging.version import Version - installed = pkg_version("hindsight-client") - if Version(installed) < Version(_MIN_CLIENT_VERSION): - logger.warning("hindsight-client %s is outdated (need >=%s), attempting upgrade...", - installed, _MIN_CLIENT_VERSION) - import pm - - pm.sync_venv(["hindsight"]) - logger.info("hindsight-client resynced against uv.lock") - except Exception as exc: - logger.warning("Auto-upgrade unavailable: %s. Run: hermes pm install", exc) - - # update_mode='append' capability (Hindsight >= 0.5.0), cached per (API URL, key fingerprint) # per process so every provider on the same API+key shares one /version round trip. A failed probe # caches False, so the key must include the credential or one profile's 401 would silently downgrade @@ -697,7 +680,6 @@ class HindsightMemoryProvider(MemoryProvider): self._status_callback = kwargs["status_callback"] # session_id stays in tags so processes for one session remain filterable together. self._document_id = _mint_document_id(self._session_id) - _maybe_upgrade_client() self._config = cfg = _load_config() for name in _SESSION_KWARGS: diff --git a/plugins/memory/hindsight/settings.py b/plugins/memory/hindsight/settings.py index 6655b2a5d3..f6d42b695c 100644 --- a/plugins/memory/hindsight/settings.py +++ b/plugins/memory/hindsight/settings.py @@ -13,8 +13,7 @@ logger = logging.getLogger(__name__.rpartition(".")[0]) _DEFAULT_API_URL = "https://api.hindsight.vectorize.io" _DEFAULT_LOCAL_URL = "http://localhost:8888" -# Keep in sync with tools/lazy_deps.py ("memory.hindsight") and plugin.yaml. -_MIN_CLIENT_VERSION = "0.6.1" + _DEFAULT_TIMEOUT = 120 # seconds — cloud API can take 30-40s per request _DEFAULT_IDLE_TIMEOUT = 300 # seconds — Hindsight embedded daemon default # ``metadata.source`` on retained memories is OPT-IN (AGENTS.md forbids diff --git a/plugins/memory/honcho/README.md b/plugins/memory/honcho/README.md index 547c961104..9e0ad3c462 100644 --- a/plugins/memory/honcho/README.md +++ b/plugins/memory/honcho/README.md @@ -6,7 +6,7 @@ AI-native cross-session user modeling with multi-pass dialectic reasoning, sessi ## Requirements -- `pip install honcho-ai` +- The `honcho-ai` SDK, prepared through PM by `hermes memory setup` when you select Honcho. Restart Hermes after preparation; do not install into its selected environment with pip. - A Honcho Cloud account — connect via OAuth sign-in or an API key from [app.honcho.dev](https://app.honcho.dev) — or a self-hosted instance diff --git a/plugins/memory/mem0/README.md b/plugins/memory/mem0/README.md index d97efd9d03..19fa980974 100644 --- a/plugins/memory/mem0/README.md +++ b/plugins/memory/mem0/README.md @@ -4,7 +4,7 @@ Server-side LLM fact extraction with semantic search and hybrid multi-signal ret ## Requirements -- `pip install mem0ai` +- The `mem0ai` SDK, prepared through PM by `hermes memory setup` when you select Mem0. Restart Hermes after preparation; do not install into its selected environment with pip. - Mem0 API key from [app.mem0.ai](https://app.mem0.ai) ## Setup diff --git a/plugins/memory/retaindb/README.md b/plugins/memory/retaindb/README.md index ec1a2d3da9..3f346c58cc 100644 --- a/plugins/memory/retaindb/README.md +++ b/plugins/memory/retaindb/README.md @@ -5,7 +5,7 @@ Cloud memory API with hybrid search (Vector + BM25 + Reranking) and 7 memory typ ## Requirements - RetainDB account ($20/month) from [retaindb.com](https://www.retaindb.com) -- `pip install requests` +- `requests` is part of Hermes's core dependencies; no separate SDK install is needed. For damaged dependencies, use `hermes pm repair` and restart Hermes. ## Setup diff --git a/plugins/memory/supermemory/README.md b/plugins/memory/supermemory/README.md index 3636905d2e..4f026a938f 100644 --- a/plugins/memory/supermemory/README.md +++ b/plugins/memory/supermemory/README.md @@ -4,7 +4,7 @@ Semantic long-term memory with profile recall, semantic search, explicit memory ## Requirements -- `pip install supermemory` +- The `supermemory` SDK, prepared through PM by `hermes memory setup` when you select Supermemory. Restart Hermes after preparation; do not install into its selected environment with pip. - Hosted: API key from [app.supermemory.ai/integrations?connect=hermes](http://app.supermemory.ai/integrations?connect=hermes) - Self-hosted: a running [Supermemory local](https://supermemory.ai/docs/self-hosting/overview) server and the API key it prints on first boot diff --git a/plugins/observability/langfuse/README.md b/plugins/observability/langfuse/README.md index 8800cf7522..a6cd142239 100644 --- a/plugins/observability/langfuse/README.md +++ b/plugins/observability/langfuse/README.md @@ -5,17 +5,16 @@ you explicitly enable it. ## Enable -Pick one: - ```bash -# Interactive: walks you through credentials + SDK install + enable +# Interactive: credentials + PM preparation of the langfuse extra + enable hermes tools # → Langfuse Observability - -# Manual -pip install langfuse -hermes plugins enable observability/langfuse ``` +Restart Hermes after setup. If dependency preparation fails, retry through +`hermes tools`; do not inject the SDK into the selected environment with pip. +For manual source-checkout setup, see the +[plugin guide](../../../website/docs/user-guide/features/built-in-plugins.md#observabilitylangfuse). + ## Required credentials Set these in `~/.hermes/.env` (or via `hermes tools`): diff --git a/plugins/platforms/photon/adapter.py b/plugins/platforms/photon/adapter.py index ce44e43f6b..a6468e7b30 100644 --- a/plugins/platforms/photon/adapter.py +++ b/plugins/platforms/photon/adapter.py @@ -47,6 +47,7 @@ from .auth import load_project_credentials # mirror files. Tests monkeypatch sidecar_paths._SIDECAR_DIR. from .sidecar_paths import _NPM_ERROR_LOG_MAX_CHARS, _lock_newer_than_install, _npm_error_log, _sidecar_dir from .sidecar_paths import dir_writable as _dir_writable +from hermes_constants import find_node_executable, with_hermes_node_path import contextlib logger = logging.getLogger(__name__) @@ -212,49 +213,16 @@ def _first_set(*vals: Any) -> Any: return next((val for val in vals if val is not None), None) -def _node_command(command: str) -> Optional[str]: - """Resolve a Node toolchain binary (node/npm), pm store first. - - Photon's sidecar is Node/TypeScript, so the gateway runs node/npm on - the host. Resolution goes through ``hermes_constants.find_node_executable`` - — the pm store's pinned node/npm wins whenever it is installed, with - PATH (Windows shim ordering) as the fallback — never a bare PATH - probe, which would miss the managed install (or resolve a system copy - Hermes does not own). Returns None when the binary is nowhere. - """ - try: - from hermes_constants import find_node_executable - - resolved = find_node_executable(command) - if resolved: - return resolved - except Exception: # pragma: no cover — hermes_constants is always present - pass - # pm.ensure wiring: the store's pinned node/npm is the first choice, but - # when it has never been installed, lazily provision it (respecting the - # lazy-install policy — InstallError is swallowed and PATH is tried) so - # the sidecar works on a fresh install without a manual `hermes pm - # install node`. - try: - import pm - - pm.ensure("node") - from hermes_constants import find_node_executable as _fne - - resolved = _fne(command) - if resolved: - return resolved - except Exception: - pass - return shutil.which(command) - def check_requirements() -> bool: - """Return True when both Python deps and the Node sidecar are available.""" + """Report readiness or permission to prepare at connect; never provision here.""" + from pm import lazy_installs_allowed + + can_prepare = lazy_installs_allowed() if not HTTPX_AVAILABLE: logger.warning("photon: httpx not installed — pip install httpx") return False - if not (_get_scoped_secret("PHOTON_NODE_BIN") or _node_command("node")): + if not (_get_scoped_secret("PHOTON_NODE_BIN") or find_node_executable("node") or can_prepare): logger.warning("photon: node binary not found on PATH, in the pm store, or via PHOTON_NODE_BIN") return False if not sidecar_deps_installed(): @@ -272,7 +240,7 @@ def check_requirements() -> bool: # user has no CLI to run `hermes photon setup`, so the connect path # must self-heal). Otherwise keep returning False so # `hermes setup` / status surface the missing-deps state. - if bool(_node_command("npm")) and _dir_writable(_sidecar_dir()): + if (find_node_executable("npm") or can_prepare) and _dir_writable(_sidecar_dir()): return True # DEBUG, not WARNING: normal pre-setup state, and check_fn is polled from hot paths. npm_error = "" @@ -310,16 +278,25 @@ def _reinstall_sidecar_deps() -> None: Best-effort — a failure here just leaves the (stale) deps in place and the normal ``_start_sidecar`` readiness check reports the real error. """ - npm = _node_command("npm") - if not npm: - logger.warning("[photon] cannot reinstall stale sidecar deps: npm not available (pm store or PATH)") + import pm + + try: + npm = find_node_executable("npm") + env = with_hermes_node_path() + if npm is None: + env = pm.ensure("npm").env + installed = pm.installed_package("npm") + assert installed is not None and installed.binary is not None + npm = str(installed.binary) + except pm.InstallError as exc: + logger.warning("[photon] cannot prepare sidecar dependencies: %s", exc) return from hermes_cli._subprocess_compat import windows_hide_flags # no console flash on Windows def _run(verb: str) -> subprocess.CompletedProcess: return subprocess.run( # noqa: S603 [npm, verb], cwd=str(_sidecar_dir()), capture_output=True, text=True, encoding="utf-8", - errors="replace", check=False, timeout=_NPM_REINSTALL_TIMEOUT, creationflags=windows_hide_flags()) + errors="replace", check=False, env=env, timeout=_NPM_REINSTALL_TIMEOUT, creationflags=windows_hide_flags()) try: result = _run("ci") if result.returncode != 0: @@ -564,7 +541,7 @@ class PhotonAdapter(BasePlatformAdapter): self._autostart_sidecar = str( _get_scoped_secret("PHOTON_SIDECAR_AUTOSTART", "true") ).lower() not in ("0", "false", "no") - self._node_bin = _get_scoped_secret("PHOTON_NODE_BIN") or _node_command("node") or "node" + self._node_bin = _get_scoped_secret("PHOTON_NODE_BIN") or find_node_executable("node") # Presence watchdog. spectrum-ts only reconnects when its inbound # iterator throws or ends; a half-open ("zombie") gRPC socket makes the @@ -1019,7 +996,7 @@ class PhotonAdapter(BasePlatformAdapter): subprocess.run, # noqa: S603 [self._node_bin, str(_sidecar_dir() / "patch-spectrum-mixed-attachments.mjs"), str(_sidecar_dir())], capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=10, check=False, - creationflags=hide_flags) + creationflags=hide_flags, env=with_hermes_node_path()) if patch.returncode != 0: raise RuntimeError((patch.stderr or patch.stdout or "").strip()) if patch.stderr.strip(): @@ -1028,9 +1005,19 @@ class PhotonAdapter(BasePlatformAdapter): logger.warning("[photon] failed to apply Spectrum mixed attachment patch: %s", exc) async def _start_sidecar(self) -> None: + if self._node_bin is None: + import pm + + try: + await asyncio.to_thread(pm.ensure, "node") + installed = pm.installed_package("node") + assert installed is not None and installed.binary is not None + self._node_bin = str(installed.binary) + except pm.InstallError as exc: + raise PhotonSidecarStartupError(str(exc), code="SIDECAR_NODE_MISSING", retryable=False) from exc await self._ensure_sidecar_deps() await self._reap_stale_sidecar() - env = os.environ.copy() + env = with_hermes_node_path() env.update({ "PHOTON_PROJECT_ID": self._project_id, "PHOTON_PROJECT_SECRET": self._project_secret, "PHOTON_SIDECAR_PORT": str(self._sidecar_port), "PHOTON_SIDECAR_BIND": self._sidecar_bind, diff --git a/plugins/platforms/photon/cli.py b/plugins/platforms/photon/cli.py index c3c56bc6cd..d2f892c055 100644 --- a/plugins/platforms/photon/cli.py +++ b/plugins/platforms/photon/cli.py @@ -15,7 +15,8 @@ from typing import Optional from hermes_cli.colors import Colors, color from . import auth as photon_auth -from .adapter import _node_command, sidecar_deps_installed +from .adapter import sidecar_deps_installed +from hermes_constants import find_node_executable, with_hermes_node_path from .sidecar_paths import _NPM_ERROR_LOG_MAX_CHARS, _npm_error_log, _sidecar_dir import contextlib @@ -254,7 +255,7 @@ def _cmd_status(_args: argparse.Namespace) -> int: # auth.print_credential_summary's emit callback is the only sink that sees # credential-derived strings (keeps cli.py taint-free for CodeQL). photon_auth.print_credential_summary(print) - node_bin = _node_command("node") + node_bin = find_node_executable("node") sidecar_installed = sidecar_deps_installed() print(f" node binary : {node_bin or '✗ missing (install Node 18+)'}") print(f" sidecar deps : {'✓ installed' if sidecar_deps_installed() else '✗ run `hermes photon install-sidecar`'}") @@ -290,13 +291,18 @@ def _cmd_telemetry(args: argparse.Namespace) -> int: def _install_sidecar() -> int: - npm = _node_command("npm") - if not npm: - print( - "npm is not on PATH. Install Node.js 18+ (https://nodejs.org/) " - "and re-run.", - file=sys.stderr, - ) + import pm + + try: + npm = find_node_executable("npm") + env = with_hermes_node_path() + if npm is None: + env = pm.ensure("npm", explicit=True).env + installed = pm.installed_package("npm") + assert installed is not None and installed.binary is not None + npm = str(installed.binary) + except pm.InstallError as exc: + print(f"Could not prepare Photon dependencies: {exc}", file=sys.stderr) return 1 # spectrum-ts is pinned exactly (the SDK ships breaking majors); upgrades are deliberate — # never `@latest` (see README "Upgrading spectrum-ts"). `npm ci` installs the lockfile @@ -307,7 +313,7 @@ def _install_sidecar() -> int: # stdout streams to the terminal; stderr is captured so the failure reason can be # persisted for check_requirements() to surface later. proc = subprocess.run( # noqa: S603 - [npm, verb], cwd=str(_sidecar_dir()), check=False, stderr=subprocess.PIPE, text=True) + [npm, verb], cwd=str(_sidecar_dir()), check=False, stderr=subprocess.PIPE, text=True, env=env) if proc.stderr: print(proc.stderr, end="", file=sys.stderr) return proc diff --git a/plugins/platforms/whatsapp/adapter.py b/plugins/platforms/whatsapp/adapter.py index afc89b88ac..51463db569 100644 --- a/plugins/platforms/whatsapp/adapter.py +++ b/plugins/platforms/whatsapp/adapter.py @@ -213,38 +213,20 @@ def _file_content_hash(path: Path) -> str: return "" -def _pm_ensure_node(command: str) -> str | None: - """Lazily provision node/npm through the pm store, then re-resolve. - - ``find_node_executable`` prefers the pm store's pinned Node but never - installs it. Node/npm ship as pm packages, so when the store is empty - this is the pm.ensure wiring the pm-unified-toolchain plan asks for: - install (respecting the lazy-install policy — raises InstallError and - returns None when lazy installs are refused) and re-resolve. - """ - try: - import pm - - pm.ensure("node" if command == "npm" else command) - return find_node_executable(command) - except Exception: - return None - - def check_whatsapp_requirements() -> bool: """ Check if WhatsApp dependencies are available. WhatsApp requires a Node.js bridge for most implementations. """ - # Prefer Hermes-managed Node/npm so Windows installs are not broken by a - # bad or elevation-triggering system Node on PATH. When the pm store has - # no Node yet, pm.ensure lazily provisions it (policy permitting). - _node = find_node_executable("node") or _pm_ensure_node("node") + _node = find_node_executable("node") if not _node: - return False + from pm import lazy_installs_allowed + + # Let connect prepare a missing runtime, but never install during discovery. + return lazy_installs_allowed() try: - return bool(_node) and subprocess.run([_node, "--version"], timeout=5, **_RUN_TEXT).returncode == 0 + return subprocess.run([_node, "--version"], timeout=5, env=with_hermes_node_path(), **_RUN_TEXT).returncode == 0 except Exception: return False @@ -356,13 +338,19 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): except OSError: pass print(f"[{self.name}] Installing WhatsApp bridge dependencies...") - # Hermes-managed portable Node's npm.cmd first (Windows), then PATH. - # MERGE-CHECK: pm.ensure provisioning kept from ours (pm store PATH wiring). - _npm_bin = find_node_executable("npm") or _pm_ensure_node("npm") or "npm" detail = "" try: # Default 300s accommodates slow systems like an Unraid NAS. + import pm + + _npm_bin = find_node_executable("npm") + env = with_hermes_node_path() + if _npm_bin is None: + env = pm.ensure("npm").env + installed = pm.installed_package("npm") + assert installed is not None and installed.binary is not None + _npm_bin = str(installed.binary) install_result = subprocess.run([_npm_bin, "install", "--silent"], cwd=str(bridge_dir), timeout=env_int("WHATSAPP_NPM_INSTALL_TIMEOUT", 300), - env=with_hermes_node_path(), **_RUN_TEXT) + env=env, **_RUN_TEXT) if install_result.returncode == 0: print(f"[{self.name}] Dependencies installed") with suppress(OSError): # Stamp is an optimization; install still succeeded @@ -370,11 +358,12 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): _dep_stamp.write_text(_pkg_hash, encoding="utf-8") return True print(f"[{self.name}] npm install failed: {install_result.stderr}") + detail = f" ({install_result.stderr.strip()[-500:]})" if install_result.stderr else "" except Exception as e: print(f"[{self.name}] Failed to install dependencies: {e}") detail = f" ({e})" - self._set_fatal_error("whatsapp_npm_install_failed", f"WhatsApp bridge npm install failed{detail}. Run `cd {bridge_dir} && {_npm_bin} install` " - "manually, then restart `hermes gateway`.", retryable=False) + self._set_fatal_error("whatsapp_npm_install_failed", f"WhatsApp bridge npm install failed{detail}. " + "Run `hermes whatsapp`, then restart `hermes gateway`.", retryable=False) return False def _attach_to_bridge(self, managed_process) -> None: @@ -489,6 +478,14 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): async def connect(self, *, is_reconnect: bool = False) -> bool: """Start (or adopt) the Node.js bridge and wait for it to be ready.""" + if find_node_executable("node") is None: + import pm + + try: + await asyncio.to_thread(pm.ensure, "node") + except pm.InstallError as exc: + self._set_fatal_error("whatsapp_node_missing", str(exc), retryable=False) + return False if not self._preflight(): return False bridge_path = Path(self._bridge_script) @@ -511,8 +508,11 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): # Bridge output goes to a log file so QR codes, errors, and reconnection messages survive for troubleshooting. self._bridge_log = self._session_path.parent / "bridge.log" self._bridge_log_fh = bridge_log_fh = open(self._bridge_log, "a", encoding="utf-8") + node = find_node_executable("node") + if node is None: + raise RuntimeError("Node.js is no longer available; run `hermes pm install`") self._bridge_process = subprocess.Popen( - [find_node_executable("node") or "node", str(bridge_path), "--port", str(self._bridge_port), "--session", str(self._session_path), + [node, str(bridge_path), "--port", str(self._bridge_port), "--session", str(self._session_path), "--mode", _wenv("WHATSAPP_MODE", "self-chat")], stdout=bridge_log_fh, stderr=bridge_log_fh, env=self._bridge_env(), **windows_detach_popen_kwargs()) _write_bridge_pidfile(self._session_path, self._bridge_process.pid) if not await self._wait_for_bridge(): diff --git a/pm/cli.py b/pm/cli.py index b4d44e7894..6632e88399 100644 --- a/pm/cli.py +++ b/pm/cli.py @@ -94,6 +94,10 @@ def _install_names(names: list[str], target: str | None = None) -> int: print(f"✓ {name} (staged for {target}: {entry.name})") else: ensure(name, explicit=True, progress=_live_progress(name)) + if name == "python": + from hermes_cli.venv_sync import publish_launchers + + publish_launchers(repo_root()) print(f"✓ {name}", flush=True) except InstallError as e: print(f"✗ {e}", flush=True) diff --git a/pm/client.py b/pm/client.py index 3eb1db05ce..95b6da96e4 100644 --- a/pm/client.py +++ b/pm/client.py @@ -302,13 +302,16 @@ def ensure_python_tool( })) -def venv_is_current(*, project_root: Path | None = None) -> bool: +def venv_is_current(*, extras: list[str] | None = None, plugin_dirs=None, + project_root: Path | None = None) -> bool: """Check through a ready PM, never bootstrap dependencies for a probe.""" if is_runtime() and (project_root is None or Path(project_root).resolve() == paths.repo_root().resolve()): from pm.ensure import venv_is_current as direct - return direct(project_root=project_root) + return direct(extras=extras, plugin_dirs=plugin_dirs, project_root=project_root) + members = plugin_dirs() if callable(plugin_dirs) else plugin_dirs try: - return bool(_request("venv_is_current", {}, project_root=project_root)) + return bool(_request("venv_is_current", {"extras": extras, "plugin_dirs": _members(members)}, + project_root=project_root)) except InstallError as exc: if exc.package == "pm-runtime": return False # Without its checker, currency cannot be established. diff --git a/pm/ensure.py b/pm/ensure.py index c17ddb71d6..b1e2474773 100644 --- a/pm/ensure.py +++ b/pm/ensure.py @@ -121,11 +121,16 @@ def lazy_installs_allowed() -> bool: ): return False try: - from hermes_cli.config import get_config_value + from hermes_cli.config import cfg_get, load_config_readonly, require_readable_config_before_write + except ModuleNotFoundError as exc: + return exc.name in {"hermes_cli", "hermes_cli.config"} except ImportError: - return True + return False try: - return bool(get_config_value("security.allow_lazy_installs", True)) + # The normal loader falls back to defaults on invalid YAML. A broken + # security policy must not grant permission to acquire dependencies. + require_readable_config_before_write() + return cfg_get(load_config_readonly(), "security", "allow_lazy_installs", default=True) is True except Exception: return False @@ -506,8 +511,9 @@ def _runtime_state_matches(fact: dict, stamp: str, *, project_root: Path | None return (environment / "pyvenv.cfg").is_file() -def venv_is_current(*, project_root: Path | None = None) -> bool: - """Use recorded inputs without starting PM or downloading prerequisites.""" +def venv_is_current(*, extras: list[str] | None = None, plugin_dirs=None, + project_root: Path | None = None) -> bool: + """Probe the requested union without changing recorded dependency state.""" from hermes_cli.runtime_paths import runtime_facts_path from pm.packages import Venv @@ -522,7 +528,11 @@ def venv_is_current(*, project_root: Path | None = None) -> bool: or not isinstance(fact.get("extras"), list) or any(not isinstance(extra, str) for extra in fact["extras"])): raise ValueError("invalid recorded dependency state") - return _runtime_state_matches(fact, package.expected_stamp(fact["extras"]), project_root=root) + enabled = sorted(set(fact["extras"]) | set(extras or [])) + members = plugin_dirs() if callable(plugin_dirs) else plugin_dirs + inputs = {} if members is None else {"plugin_dirs": members} + stamp = package.expected_stamp(enabled, **inputs) + return _runtime_state_matches(fact, stamp, project_root=root) def sync_venv(extras: Optional[list[str]] = None, *, explicit: bool = False, plugin_dirs=None, before_publish=None, repair: bool = False) -> None: diff --git a/pm/lock.json b/pm/lock.json index 5378e0bfdd..502dccfda2 100644 --- a/pm/lock.json +++ b/pm/lock.json @@ -41,12 +41,12 @@ "cua-driver": { "artifacts": { "darwin-arm64": { - "sha256": "5e327e58f6ce81d5c117fe5edec5f267e87e1b921e8c5a8aa4f7f21cbcf5f273", - "url": "https://github.com/trycua/cua/releases/download/cua-driver-rs-v0.21.0/cua-driver-rs-0.21.0-darwin-universal-binary.tar.gz" + "sha256": "f827a393c0dae24943ddb454d85abb6b592d8636e6a99f6c349f81f5f516975f", + "url": "https://github.com/trycua/cua/releases/download/cua-driver-rs-v0.21.0/cua-driver-rs-0.21.0-darwin-universal.tar.gz" }, "darwin-x64": { - "sha256": "5e327e58f6ce81d5c117fe5edec5f267e87e1b921e8c5a8aa4f7f21cbcf5f273", - "url": "https://github.com/trycua/cua/releases/download/cua-driver-rs-v0.21.0/cua-driver-rs-0.21.0-darwin-universal-binary.tar.gz" + "sha256": "f827a393c0dae24943ddb454d85abb6b592d8636e6a99f6c349f81f5f516975f", + "url": "https://github.com/trycua/cua/releases/download/cua-driver-rs-v0.21.0/cua-driver-rs-0.21.0-darwin-universal.tar.gz" }, "linux-arm64": { "sha256": "6b3a308c6741dd8291aad22a308bd2ec8eb52eab64687be007ac86ceaf7e3307", diff --git a/pm/packages.py b/pm/packages.py index 8b8f550fd8..a9491e77b4 100644 --- a/pm/packages.py +++ b/pm/packages.py @@ -756,7 +756,12 @@ class Ripgrep(BinaryPackage): class CuaDriver(BinaryPackage): name = "cua-driver" optional = True - binary_rel = {"win32": "cua-driver.exe", "posix": "cua-driver"} + binary_rel = { + "darwin-arm64": "CuaDriver.app/Contents/MacOS/cua-driver", + "darwin-x64": "CuaDriver.app/Contents/MacOS/cua-driver", + "win32": "cua-driver.exe", + "posix": "cua-driver", + } def fetch_url(self, version: str, target: str) -> str: arch = { @@ -768,9 +773,13 @@ class CuaDriver(BinaryPackage): "win32-arm64": "windows-arm64", }[target] ext = "zip" if target.startswith("win32") else "tar.gz" + # Only the directory archive contains the signed macOS app identity + # needed by TCC and private sessions. Other targets' binary archives + # already carry their runtime helpers (including Windows UIAccess). + variant = "" if target.startswith("darwin") else "-binary" return ( f"https://github.com/trycua/cua/releases/download/cua-driver-rs-v{version}/" - f"cua-driver-rs-{version}-{arch}-binary.{ext}" + f"cua-driver-rs-{version}-{arch}{variant}.{ext}" ) def latest_versions(self, target: str, locked=None) -> list[str]: diff --git a/pm/plugins_state.py b/pm/plugins_state.py index fffca081ca..25fc6cb086 100644 --- a/pm/plugins_state.py +++ b/pm/plugins_state.py @@ -1,11 +1,6 @@ -"""Which plugins are enabled, per profile — pm's read of the plugins -config (order-preserving for the incumbent-wins tiebreak). +"""Read every profile's enabled plugins in config order for the shared union. -pm needs two things the plugins_cmd helpers don't give: EVERY profile's -enabled list (the union is per-install, cross-profile) and the list -ORDER (config order = enable recency; enabling appends). Writes go -through the same config.yaml the plugins CLI owns — pm never invents a -second authority for enabled state. +Plugin admission owns writes; discovery never edits a profile's selection. """ from __future__ import annotations @@ -140,45 +135,3 @@ def _provider_from_config(home: Path, config: dict[str, Any]) -> Optional[str]: return None name = provider.strip() return name if _is_directory(home / "plugins" / name) else None - - -def disable_plugins(names: list[str]) -> dict[str, list[str]]: - """Remove names from EVERY home's enabled list (an operator or - caller decision names the plugin, not the profile — disable where - it's enabled). There is NO automatic bisect in pm today; this is - the explicit write-back path. Returns per-home what was removed. - - Writes go through utils.atomic_roundtrip_yaml_update — the same - atomic, comment-preserving round-trip writer the plugins CLI's - config path uses — pointed at that home's config.yaml (explicit - home scope; pm never derives the target from ambient state). A - write failure RAISES: a disable that didn't land must never be - reported as removed. An EXISTING home config that can't be parsed - also raises — silently skipping it would report success while the - plugin stays enabled in that home. - """ - removed: dict[str, list[str]] = {} - if not names: - return removed - name_set = set(names) - - for home in _all_homes(): - config_path = home / "config.yaml" - config = _read_home_config(home) - if config is None: - continue - plugins_cfg = config.get("plugins") - if not isinstance(plugins_cfg, dict): - continue - enabled = plugins_cfg.get("enabled") - if not isinstance(enabled, list): - continue - hit = [n for n in enabled if isinstance(n, str) and n in name_set] - if not hit: - continue - kept = [n for n in enabled if not (isinstance(n, str) and n in name_set)] - import utils - - utils.atomic_roundtrip_yaml_update(config_path, "plugins.enabled", kept) - removed[str(home)] = hit - return removed diff --git a/pm/receipt.py b/pm/receipt.py index 1a96f2bb55..166ecac185 100644 --- a/pm/receipt.py +++ b/pm/receipt.py @@ -9,7 +9,7 @@ every surface: a failed venv rebuild is as reportable as a failed update. The in-flight receipt lives in a ContextVar, not a module global: the -sync cadence and an ensure bisect can overlap across threads, and a +sync cadence and dependency preparation can overlap across threads, and a shared global lets one run's begin/finalize clobber another's record. Two hazards of ContextVar state are handled explicitly: @@ -146,7 +146,6 @@ def begin(kind: str) -> contextvars.Token: "started_at": _utc_now_iso(), "steps": [], "venv_rebuild": None, - "plugin_bisect": [], "feature_list": None, "platform": None, "outcome": None, @@ -176,10 +175,6 @@ def record_venv_rebuild(ok: bool, reason: str = "") -> None: _record(lambda r: r.__setitem__("venv_rebuild", {"ok": ok, "reason": reason})) -def record_bisect(decisions: list[dict]) -> None: - _record(lambda r: r.__setitem__("plugin_bisect", decisions)) - - def record_feature_list(extras: Optional[list[str]]) -> None: _record(lambda r: r.__setitem__("feature_list", extras)) diff --git a/pm/worker.py b/pm/worker.py index 90b1f51dad..45d7ca3860 100644 --- a/pm/worker.py +++ b/pm/worker.py @@ -122,6 +122,8 @@ def main(): "ensure_environment": python.ensure_environment, "ensure_python_tool": python.ensure_python_tool} arguments = request["arguments"] + if request["operation"] == "venv_is_current": + arguments["plugin_dirs"] = _members(arguments.get("plugin_dirs")) if request["operation"] == "ensure": arguments["pause_event"] = pause for name in ("progress", "download_progress"): diff --git a/pm/workspace.py b/pm/workspace.py index 7e50530787..db37ff6938 100644 --- a/pm/workspace.py +++ b/pm/workspace.py @@ -8,7 +8,7 @@ from __future__ import annotations import hashlib import os -import subprocess +import shutil from collections.abc import Mapping from pathlib import Path from typing import TYPE_CHECKING, Optional @@ -181,14 +181,6 @@ def _generate_pyproject(plugin_dirs: list[Path], root: Optional[Path] = None, *, return root, changed -def build_root(plugin_dirs: list[Path], root: Optional[Path] = None) -> Path: - """(Re)generate the workspace root's pyproject.toml. ``root`` pins a - parent-supplied STAGING workspace (tests, staged syncs); default is - the per-install generated root beside the byte store.""" - generated, _changed = _generate_pyproject(plugin_dirs, root) - return generated - - def _seed_lock(root: Path, seed_lock: Optional[Path] = None, *, source: Optional[Path] = None) -> None: """Seed the generated root's uv.lock with the CURRENT resolution. @@ -336,53 +328,38 @@ def _workspace_member(plugin_dir: Path, root: Path, *, identity: Path | None = N return member -def scan_plugin(plugin_dir: Path) -> dict: - """Auto-pickup scan of one plugin dir: which dep surfaces it declares. - Priority (settled): pyproject (python), package.json (node sidecar), - packages.py (pm store binaries), legacy manifest deps (bridge).""" - found: dict = { - "pyproject": (plugin_dir / "pyproject.toml").is_file(), - "package_json": (plugin_dir / "package.json").is_file(), - "packages_py": (plugin_dir / "packages.py").is_file(), - "legacy_deps": _is_member_candidate(plugin_dir) - and not (plugin_dir / "pyproject.toml").is_file(), - "dir": plugin_dir, - } - return found - - def install_node_sidecar( plugin_dir: Path, *, - npm_bin: Optional[str] = None, - runner=subprocess.run, + explicit: bool = False, ) -> Optional[str]: - """`npm ci` the plugin's package.json into ITS OWN node_modules — - the declared sidecar install (plugin-deps plan §B item 2; wired here). + """Install plugin-local dependencies using PM's paired npm/Node context. - Plugin-local (never a global npm prefix), pm's pinned npm when the - store has one (ambient PATH npm otherwise), gated by the lazy-install - policy, receipt-noted. Returns None on success, else why not. + Explicit user consent permits acquisition even when on-demand installs + are disabled. Returns None on success, otherwise a diagnostic. """ package_json = plugin_dir / "package.json" if not package_json.is_file(): return None # nothing to install + import pm from pm.ensure import lazy_installs_allowed - if not lazy_installs_allowed(): - return "lazy installs are disabled — run `hermes pm install` after enabling" + # Tool availability does not authorize mutation of the sidecar itself. + if not explicit and not lazy_installs_allowed(): + return "lazy installs are disabled — run `hermes plugins install` and approve Node dependencies" # a lockfile means reproducible `npm ci`; plain `npm install` otherwise install_cmd = ["ci"] if (plugin_dir / "package-lock.json").is_file() else ["install"] - if npm_bin is None: - npm_bin = _node_npm_binary("npm") - if npm_bin is None: - return "npm not found (pm store or PATH)" - try: - proc = runner( - [npm_bin, *install_cmd, "--no-audit", "--no-fund"], + runner = pm.ensure("npm", explicit=explicit) + # Resolve inside the composed context, including npm.cmd on Windows; + # CreateProcess does not search a child's replacement PATH itself. + npm = shutil.which("npm", path=runner.env.get("PATH", "")) + if npm is None: + return "npm is missing from the prepared PM environment" + proc = runner.run( + [npm, *install_cmd, "--no-audit", "--no-fund"], cwd=str(plugin_dir), capture_output=True, text=True, @@ -398,28 +375,6 @@ def install_node_sidecar( return None -def _node_npm_binary(name: str) -> Optional[str]: - """pm's pinned npm from the store (store-first), PATH second.""" - from pm.ensure import env_for - - try: - env = env_for("npm") - except Exception: - env = None - if env: - path_value = env.get("PATH", "") - import shutil as _shutil - - for d in path_value.split(os.pathsep): - if d: - candidate = Path(d) / ("npm.cmd" if os.name == "nt" else name) - if candidate.is_file(): - return str(candidate) - import shutil as _shutil - - return _shutil.which(name) - - def lock_and_sync( plugin_dirs: list[Path], extras: Optional[list[str]] = None, diff --git a/pyproject.toml b/pyproject.toml index 44c184780d..36c8b265d0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,14 +26,14 @@ dependencies = [ # captured by `mistralai>=2.3.0,<3` rather than an exact pin, every # install in the hours before the quarantine would have pulled it. # - # When updating: bump the version below AND regenerate uv.lock with - # `uv lock` so the transitive resolution stays consistent. Don't - # introduce ranges back without a written justification. + # After a pin change, run `python -m pm.build_env --source . --lock-only` + # so the transitive resolution stays consistent. Do not introduce ranges + # without a written justification. # # Scope rule: only packages used by EVERY hermes session belong here. # Anything that's provider-specific (`anthropic`, `firecrawl-py`, # `exa-py`, `fal-client`, `edge-tts`, `parallel-web`) belongs in an - # extra and gets lazy-installed via `tools/lazy_deps.py` when the + # extra and gets prepared through PM when the # user picks that backend. Smaller `dependencies` = smaller blast # radius for the next supply-chain attack. "openai==2.24.0", @@ -57,9 +57,8 @@ dependencies = [ # Bundled in core by maintainer decision (read_file is a core tool and # PDF reads are a common first-session action; the previous lazy-only # arrangement dated to the package's uv exclude-newer quarantine, which - # has long expired). tools/lazy_deps.py `tool.doc_extract` remains the - # self-heal path for lean/broken installs — keep the pin below and the - # lazy pin in lockstep. + # has long expired). PM's doc-extract extra repairs lean/broken installs + # from this same dependency declaration. "firecrawl-anydoc==0.2.4", # Bumped from 2.12.5 to 2.13.4 to pull in pydantic-core 2.46.4. # pydantic-core 2.41.5 (pulled by 2.12.5) segfaults when the OpenAI SDK's @@ -75,15 +74,8 @@ dependencies = [ # Applied at index AND query time so morphological variants match # ("issues" finds create_issue). "snowballstemmer==3.1.1", - # ``packaging`` is imported directly on three production paths but was never - # declared, so it only reached users transitively (pip/uv pull it for other - # tools). The slim official Docker image ships without it, where the - # try/except-ImportError fallbacks silently degrade: Hindsight's - # ``_meets_minimum_version`` disables update_mode='append' (#40503), - # tools/lazy_deps.py treats every version constraint as satisfied, and - # hermes_cli/main.py drops to naive requirement parsing. Pure-Python - # py3-none-any wheel, no compiled extensions — safe to ship everywhere. - # Pinned to the version already resolved in uv.lock (no resolution churn). + # Requirement and version checks must work in lean installs without + # relying on another package to bring packaging in transitively. "packaging==26.0", # Markdown -> HTML conversion for rich message delivery (Matrix # `formatted_body`, and the `send_message` tool's HTML path). Now on the @@ -307,11 +299,8 @@ google-chat = ["google-cloud-pubsub==2.39.2; platform_machine != 'ARM64' or sys_ # can re-sync exactly the pin from core. doc-extract = ["firecrawl-anydoc==0.2.4"] trace-upload = ["huggingface-hub==1.24.0"] -# Cloud memory providers — opt-in, lazy-installed via tools/lazy_deps.py -# (memory.supermemory / memory.mem0) at first use. Exact pins MUST match the -# LAZY_DEPS pins (enforced by tests/test_project_metadata.py). Deliberately -# excluded from [all] like honcho/hindsight so a quarantined upstream release -# can't break fresh installs. +# Cloud memory providers are opt-in PM extras. They stay outside [all], +# like honcho/hindsight, so a quarantined release cannot break fresh installs. supermemory = ["supermemory==3.50.0"] # mem0ai pulls qdrant-client → grpcio, which has no win_arm64 wheel — same # win32-arm64 gate as google-chat. mem0 is lazy-installed, so on arm64 the @@ -350,10 +339,8 @@ nemo-relay = [] homeassistant = ["aiohttp==3.14.3"] sms = ["aiohttp==3.14.3"] teams = ["microsoft-teams-apps==2.0.13.4", "aiohttp==3.14.3"] # aiohttp 3.14.3: prior CVEs + GHSA-cq5v-8q36-5273/GHSA-mfx4-hv73-q22v/GHSA-mq44-7p77-q5h7 -# Computer use — macOS background desktop control via cua-driver (MCP stdio). -# The cua-driver binary itself is installed via `hermes tools` post-setup -# (curl install script); this extra just pins the MCP client used to talk -# to it, which is already provided by the `mcp` extra. +# Computer use talks to PM's pinned cua-driver over MCP stdio. This extra +# provides the MCP client, independently of the native driver package. computer-use = ["mcp==2.0.0", "httpx2==2.7.0", "starlette==1.3.1"] # starlette: CVE-2026-48710 acp = ["agent-client-protocol==0.9.0"] # mistral: Voxtral STT + TTS. Pinned to an exact verified-clean version. @@ -367,7 +354,7 @@ acp = ["agent-client-protocol==0.9.0"] mistral = ["mistralai==2.4.8"] # OTLP gateway monitoring export (optional). Provides the OpenTelemetry SDK + # OTLP/HTTP exporter for monitoring.gateway_health_export. Lazy-installed via -# tools/lazy_deps.py on first use; never a core dependency and deliberately +# PM on first use; never a core dependency and deliberately # NOT in [all]. otlp = ["opentelemetry-sdk==1.39.1", "opentelemetry-exporter-otlp-proto-http==1.39.1"] langfuse = ["langfuse==4.15.2"] @@ -384,7 +371,7 @@ termux = [ ] termux-all = [ # Best-effort "install all" profile for Termux. Same policy as [all]: - # only includes extras that aren't covered by `tools/lazy_deps.py`. + # only includes extras that are not prepared on demand by PM. # Backends like telegram/slack/dingtalk/feishu/honcho lazy-install at # first use, so they're no longer eager-installed here. "hermes-agent[termux]", @@ -398,9 +385,8 @@ dingtalk = ["dingtalk-stream==0.24.3", "alibabacloud-dingtalk==2.2.42", "qrcode= feishu = ["lark-oapi==1.6.8", "qrcode==7.4.2"] google = [ # Required by the google-workspace skill (Gmail, Calendar, Drive, Contacts, - # Sheets, Docs). Declared here so dev environments (`uv sync --extra google`) - # and packagers ship them without hitting runtime `pip install` paths that - # fail in environments without pip (e.g. Nix-managed Python). + # Sheets, Docs). Declared here so PM environments and independent + # packagers can include them without mutating an application's runtime. "google-api-python-client==2.194.0", "google-auth==2.55.1", "google-auth-oauthlib==1.3.1", @@ -415,7 +401,7 @@ google = [ youtube = [ # Required by skills/media/youtube-content and # optional-skills/productivity/memento-flashcards (youtube_quiz.py). - # Without this declaration uv sync omits the package and both skills fail + # Without this declaration dependency sync omits the package and both skills fail # at first invocation with ModuleNotFoundError (issue #22243). "youtube-transcript-api==1.2.4", ] @@ -425,12 +411,12 @@ youtube = [ web = ["fastapi==0.133.1", "uvicorn[standard]==0.41.0", "starlette==1.3.1", "python-multipart==0.0.32"] all = [ # Policy (2026-05-12): `[all]` includes only extras that genuinely - # CAN'T be lazy-installed via `tools/lazy_deps.py` — i.e. things every + # CAN'T be prepared on demand by PM — i.e. things every # session can use, things needed before the agent loop is alive # (terminal/CLI), and skill deps that dev environments need. # Anything an opt-in backend (provider, search, TTS, image, memory, # messaging platform, terminal sandbox) needs MUST live exclusively in - # `LAZY_DEPS` and resolve at first use — otherwise one quarantined PyPI + # opt-in extras and resolve at first use — otherwise one quarantined PyPI # release breaks every fresh install. # # Removed from [all] on 2026-05-12 (covered by lazy-install): @@ -465,8 +451,8 @@ test = ["distlib==0.4.3; sys_platform == 'win32'"] [build-system] # setuptools.build_meta + our setup.py bdist_wheel guard import wheel during -# PEP 517 isolated builds (uv sync / uv pip install -e .). Without wheel in -# requires, the isolation sandbox only gets setuptools and Windows installer +# PEP 517 isolated builds. Without wheel in +# requires, the isolation sandbox only gets setuptools and the build # fails with ModuleNotFoundError: wheel.cli (#96488). requires = ["setuptools==83.0.0", "wheel"] build-backend = "setuptools.build_meta" diff --git a/scripts/build/README.md b/scripts/build/README.md index 402d46f7b2..074e83d754 100644 --- a/scripts/build/README.md +++ b/scripts/build/README.md @@ -56,6 +56,9 @@ Node/npm versions, OS/architecture, and exact workspace union. It also checks npm's installed-tree lock and the presence of its recorded package directories. A missing or mismatched receipt runs a clean `npm ci`; failed installs cannot leave a reusable receipt. Omit `--reuse` to force a clean dependency install. +Source launchers add `--no-install` when PM's lazy-install policy is disabled. +That mode still reuses a matching completed receipt, but rejects stale or missing +dependencies before mutating the tree. Explicit build/update operations may install. The receipt does not validate arbitrary edits inside installed packages and never skips product compilation. CI saves the prepared tree before packaging can mutate it, and before unrelated build/signing failures can discard it. @@ -79,8 +82,31 @@ node scripts/build/desktop.mjs --source /work/source \ Each output is the product directory itself. The desktop output is a `dist` directory, not an application package. The exported functions are `buildTui`, -`buildWeb`, and `buildDesktop`. They return output paths, not a new provenance -manifest. +`buildWeb`, and `buildDesktop`. They return output paths and publish the build-input +receipt described below. + +Each compiler publishes `hermes-build.json` inside its output (inside `dist/` +for TUI). `freshness.mjs` owns this receipt and all source input selection. +It records product/host identity, content hashes of workspace/shared sources and +build inputs, and the exact supplied icon directory, desktop install stamp, and +native-dependency tree. Inputs are checked again before publication: a concurrent +input change fails the build and preserves the previous output. Output validation +checks renderer/main/preload/public bytes and the native file inventory; native +bytes may change through signing after compilation. Native ABI verification remains +with the native provider and desktop compiler. + +Source launchers query this owner without provisioning tools: + +```sh +node scripts/build/freshness.mjs --source /work/source --product web --out /work/products/web +node scripts/build/freshness.mjs --source /work/source --product tui --out /work/products/tui/dist +``` + +The result is a JSON boolean. Missing receipts, changed inputs (including supplied +inputs outside source), missing prepared trees, or damaged outputs are stale. +Receipts describe a source build, not a portable dependency cache; immutable +distributions use their existing prebuilt launch path instead. They replace the +old Python per-profile hashes and TUI mtime lists, not PM's dependency receipts. The compilers resolve modules from the supplied workspace. They do not run npm, uv, PM installation, or icon preparation. TypeScript/Vite scratch files @@ -124,7 +150,9 @@ node scripts/generate-icons.mjs --source /work/source --out /work/products/icons ``` It uses the isolated `icon-build` dependency group and -`SOURCE/.cache/icon-build`. Both icon commands accept `--check`. Without +`SOURCE/.cache/icon-build`. Both icon commands accept `--check`. The convenience +wrapper also accepts `--on-demand`, which preserves PM's lazy-install admission +policy rather than treating an automatic stale build as explicit installation. Without explicit paths, they use the source checkout as the output root. The desktop native tree contains prepared packages, including `node-pty` with @@ -154,6 +182,12 @@ npm run build --workspace apps/desktop | Web build | `hermes_cli/web_dist/` | npm `prebuild` prepares icons | | Desktop build | `apps/desktop/dist/` | Icons, root-install assertion, install stamp, and native-dependency staging | +Compositions prepare icons once and pass `npm run build -- --icons /prepared/root` +to the desktop's source-development driver. It copies prepared packaging artwork +and passes the same root to the compiler. A standalone `npm run build` still +prepares its own icons. Source desktop launch runs the already-prepared Electron +binary directly; `--skip-build` does not provision Node, npm, or Electron. + TUI and web scripts support a no-argument development mode. Explicit product mode requires the arguments in the earlier table. The desktop product script has no no-argument mode. The Node product parsers expose no `--help` flag. diff --git a/scripts/build/desktop.mjs b/scripts/build/desktop.mjs index e9f015384d..e140bf5158 100644 --- a/scripts/build/desktop.mjs +++ b/scripts/build/desktop.mjs @@ -7,6 +7,7 @@ import { bundleElectronMain } from '../../apps/desktop/scripts/bundle-electron-m import { checkDistBuilt } from '../../apps/desktop/scripts/assert-dist-built.mjs' import { classifyNativeBinary } from '../../apps/desktop/scripts/stage-native-deps.mjs' import { frontendArgs, isMain, productOutput, withProduct, workspaceTool } from './frontend-common.mjs' +import { recordProduct, buildInputs } from './freshness.mjs' function validateNativeTree(nativeDeps, platform) { const pty = join(nativeDeps, 'node-pty') @@ -35,6 +36,7 @@ export async function buildDesktop({ source, out, icons, stamp, nativeDeps, type const publicIcons = join(resolve(icons), app, 'public') if (!existsSync(join(publicIcons, 'apple-touch-icon.png'))) throw new Error(`Missing desktop icon: ${join(publicIcons, 'apple-touch-icon.png')}`) validateNativeTree(resolve(nativeDeps), platform) + const inputs = buildInputs(source, 'desktop', { icons: publicIcons, stamp, nativeDeps }) await withProduct(out, async (product, scratch) => { const publicDir = join(scratch, 'public') const sourcePublic = join(source, app, 'public') @@ -57,6 +59,7 @@ export async function buildDesktop({ source, out, icons, stamp, nativeDeps, type cpSync(resolve(nativeDeps), join(product, 'node_modules'), { recursive: true, dereference: true }) const result = checkDistBuilt(product) if (!result.ok) throw new Error(result.error) + recordProduct({ source, product: 'desktop', out: product, inputs }) }, { source }) return { out } } diff --git a/scripts/build/freshness.mjs b/scripts/build/freshness.mjs new file mode 100644 index 0000000000..05a4b39232 --- /dev/null +++ b/scripts/build/freshness.mjs @@ -0,0 +1,94 @@ +// Product receipts belong to the compilers, not PM dependency receipts or profiles. +import { createHash } from 'node:crypto' +import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { parseArgs } from 'node:util' +import { isMain } from './frontend-common.mjs' + +const receiptName = 'hermes-build.json' +const workspaces = { tui: 'ui-tui', web: 'web', desktop: 'apps/desktop' } +const generated = new Set(['node_modules', 'dist', 'build', 'release', '.cache', '.git', 'coverage', 'test-results', 'playwright-report']) + +function treeHash(root, inputs, skip, contents = () => true) { + const hash = createHash('sha256') + function visit(name) { + if (skip(name)) return + const file = join(root, name) + hash.update(name.replaceAll('\\', '/')).update('\0') + if (!existsSync(file)) { hash.update('missing\0'); return } + if (statSync(file).isDirectory()) { + hash.update('directory\0') + for (const child of readdirSync(file).sort()) visit(`${name}/${child}`) + } else { + hash.update(contents(name) ? readFileSync(file) : 'file').update('\0') + } + } + for (const input of inputs) visit(input) + return hash.digest('hex') +} + +export function sourceHash(source, product) { + const workspace = workspaces[product] + if (!workspace) throw new Error(`Unknown frontend product: ${product}`) + return treeHash(source, [ + workspace, 'apps/shared', 'package.json', 'package-lock.json', '.npmrc', 'pm/lock.json', + 'scripts/build', + ...(product === 'tui' ? [] : ['scripts/generate-icons.mjs', 'scripts/generate_icons.py', + 'assets', 'pyproject.toml', 'uv.lock', 'install-stamp.json']), + ], name => { + // Build scripts are inputs; workspace build directories are outputs. + const parts = name.split('/') + return (!name.startsWith('scripts/') && parts.some(part => generated.has(part))) + || parts.some(part => part.startsWith('.dist-') || part.startsWith('.staging-') || part === '__pycache__') + || name.endsWith('.tsbuildinfo') || name.endsWith('.pyc') + }) +} + +function outputHash(out) { + // Native binaries can be signed after compilation. Their ABI validation is + // owned by native preparation; renderer/main/preload bytes must stay intact. + return treeHash(out, readdirSync(out).sort(), name => name === receiptName || name === '.hermes-product', + name => !name.split('/').includes('node_modules')) +} + +export function buildInputs(source, product, prepared = {}) { + return { + sourceHash: sourceHash(source, product), + prepared: Object.entries(prepared).sort().map(([name, path]) => ({ + name, path: resolve(path), hash: treeHash(resolve(path), ['.'], () => false), + })), + } +} + +function preparedPaths(inputs) { + return Object.fromEntries(inputs.prepared.map(({ name, path }) => [name, path])) +} + +export function recordProduct({ source, product, out, inputs }) { + if (JSON.stringify(buildInputs(source, product, preparedPaths(inputs))) !== JSON.stringify(inputs)) { + throw new Error('Build inputs changed during compilation; retry the build') + } + writeFileSync(join(out, receiptName), JSON.stringify({ + schema: 1, product, platform: process.platform, arch: process.arch, node: process.versions.node, + inputs, outputHash: outputHash(out), + }) + '\n') +} + +export function productCurrent({ source, product, out, prepared }) { + try { + const saved = JSON.parse(readFileSync(join(out, receiptName), 'utf8')) + return saved.schema === 1 && saved.product === product + && saved.platform === process.platform && saved.arch === process.arch + && saved.node === process.versions.node + && JSON.stringify(saved.inputs) === JSON.stringify(buildInputs(source, product, prepared ?? preparedPaths(saved.inputs))) + && saved.outputHash === outputHash(out) + } catch { return false } +} + +if (isMain(import.meta.url)) { + const { values } = parseArgs({ options: { + source: { type: 'string' }, product: { type: 'string' }, out: { type: 'string' }, + } }) + if (!values.source || !values.product || !values.out) throw new Error('--source, --product and --out are required') + console.log(JSON.stringify(productCurrent(values))) +} diff --git a/scripts/build/icon_environment.py b/scripts/build/icon_environment.py index f7f2e76a30..d7e82af53b 100644 --- a/scripts/build/icon_environment.py +++ b/scripts/build/icon_environment.py @@ -18,12 +18,15 @@ def main(argv: list[str] | None = None) -> int: argv = list(sys.argv[1:] if argv is None else argv) parser = argparse.ArgumentParser(add_help=False, allow_abbrev=False) parser.add_argument("--source", type=Path, default=ROOT) + parser.add_argument("--on-demand", action="store_true") args, _ = parser.parse_known_args(argv) + if args.on_demand: + argv.remove("--on-demand") source = args.source.resolve() with TemporaryDirectory(prefix="hermes-icon-build-") as temporary: python = pm.build_environment( source=source, out=Path(temporary) / "venv", - groups=["icon-build"], only_groups=True, explicit=True, + groups=["icon-build"], only_groups=True, explicit=not args.on_demand, cache=source / ".cache/icon-build", ) return subprocess.run( diff --git a/scripts/build/node-deps.mjs b/scripts/build/node-deps.mjs index e5566e9e1d..10a5ced933 100644 --- a/scripts/build/node-deps.mjs +++ b/scripts/build/node-deps.mjs @@ -30,7 +30,7 @@ export function npmCommand({ env = process.env } = {}) { } /** Install the full requested workspace union in one strict, locked operation. */ -export function prepareNodeDependencies({ source, workspaces, env = process.env, reuse = false }) { +export function prepareNodeDependencies({ source, workspaces, env = process.env, reuse = false, install = true }) { source = resolve(source) if (!Array.isArray(workspaces) || workspaces.length === 0) { throw new Error('Select at least one workspace; implicit all-workspace installation is not allowed') @@ -84,6 +84,7 @@ export function prepareNodeDependencies({ source, workspaces, env = process.env, return { source, workspaces: selected } } } + if (!install) throw new Error('Workspace dependencies are stale or missing and lazy installs are disabled; run an explicit build/update') // npm can fail during validation before deleting node_modules. Invalidate first. rmSync(receipt, { force: true }) execFileSync(node, [npm, ...args], { cwd: source, env, stdio: 'inherit' }) @@ -97,7 +98,8 @@ if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1] const { values } = parseArgs({ options: { source: { type: 'string' }, workspace: { type: 'string', multiple: true }, reuse: { type: 'boolean', default: false }, + 'no-install': { type: 'boolean', default: false }, } }) if (!values.source) throw new Error('--source is required') - prepareNodeDependencies({ source: values.source, workspaces: values.workspace, reuse: values.reuse }) + prepareNodeDependencies({ source: values.source, workspaces: values.workspace, reuse: values.reuse, install: !values['no-install'] }) } diff --git a/scripts/build/tui.mjs b/scripts/build/tui.mjs index d87965261c..f5db0911db 100644 --- a/scripts/build/tui.mjs +++ b/scripts/build/tui.mjs @@ -5,6 +5,7 @@ import { tmpdir } from 'node:os' import { resolve, join } from 'node:path' import { pathToFileURL } from 'node:url' import { frontendArgs, isMain, productOutput, publishDirectory, repoRoot, withProduct, workspaceTool } from './frontend-common.mjs' +import { recordProduct, buildInputs } from './freshness.mjs' // `react-devtools-core` is only imported when DEV=true at runtime (Ink dev // mode). Stub it out so the bundle doesn't carry the dep. @@ -26,6 +27,7 @@ export async function buildTui(options) { const { source, out: destination } = productOutput(options.source, options.out, ['ui-tui', 'apps/shared', 'node_modules']) const { build } = await import(pathToFileURL(workspaceTool(source, 'ui-tui', 'esbuild')).href) const root = join(source, 'ui-tui') + const inputs = buildInputs(source, 'tui') await withProduct(destination, async product => { const out = join(product, 'dist/entry.js') await build({ @@ -66,6 +68,7 @@ export async function buildTui(options) { writeFileSync(out, body.slice(body.indexOf('\n') + 1)) } writeFileSync(join(product, 'package.json'), JSON.stringify({ type: 'module' }) + '\n') + recordProduct({ source, product: 'tui', out: join(product, 'dist'), inputs }) }) return { out: destination, entry: join(destination, 'dist/entry.js') } } diff --git a/scripts/build/web.mjs b/scripts/build/web.mjs index de63b12c42..020b2c832d 100644 --- a/scripts/build/web.mjs +++ b/scripts/build/web.mjs @@ -4,6 +4,7 @@ import { cpSync, existsSync, mkdirSync, statSync } from 'node:fs' import path from 'node:path' import { pathToFileURL } from 'node:url' import { frontendArgs, isMain, productOutput, repoRoot, withProduct, workspaceTool } from './frontend-common.mjs' +import { recordProduct, buildInputs } from './freshness.mjs' function typecheck(ts, root, scratch) { const diagnostics = [] @@ -36,6 +37,7 @@ export async function buildWeb(options) { // Icon inputs can be outside source but are still read-only build inputs. productOutput(options.icons, out, ['web/public']) const root = path.join(source, 'web') + const inputs = buildInputs(source, 'web', { icons: publicIcons }) const tsModule = await import(pathToFileURL(workspaceTool(source, 'web', 'typescript')).href) const { build } = await import(pathToFileURL(workspaceTool(source, 'web', 'vite')).href) await withProduct(out, async (product, scratch) => { @@ -54,6 +56,7 @@ export async function buildWeb(options) { build: { outDir: product, emptyOutDir: true } }) if (!existsSync(path.join(product, 'index.html'))) throw new Error('Web build did not produce index.html') + recordProduct({ source, product: 'web', out: product, inputs }) }, { source }) return { out, index: path.join(out, 'index.html') } } diff --git a/scripts/bundles/desktop.py b/scripts/bundles/desktop.py index e5707e02fc..cca12232f9 100644 --- a/scripts/bundles/desktop.py +++ b/scripts/bundles/desktop.py @@ -105,11 +105,13 @@ def build(repo: Path, tag: str | None, variant: str, builder_args: list[str], run([node, "scripts/build/node-deps.mjs", "--source", str(repo), "--reuse", *[arg for workspace in workspaces for arg in ("--workspace", workspace)]], cwd=repo, env=env) payload = repo / "apps/desktop/build/agent-payload" + products = repo / "apps/desktop/build/products" + icons = products / "icons" + run([node, "scripts/generate-icons.mjs", "--source", str(repo), "--out", str(icons)], cwd=repo, env=env) if variant == "light": shutil.rmtree(payload, ignore_errors=True) else: - products = repo / "apps/desktop/build/products" - run([node, "scripts/generate-icons.mjs", "--source", str(repo), "--out", str(products / "icons")], cwd=repo, env=env) + run([node, "scripts/build/tui.mjs", "--source", str(repo), "--out", str(products / "tui")], cwd=repo, env=env) run([node, "scripts/build/web.mjs", "--source", str(repo), "--icons", str(products / "icons"), "--out", str(products / "web")], cwd=repo, env=env) @@ -131,7 +133,7 @@ def build(repo: Path, tag: str | None, variant: str, builder_args: list[str], if metadata["file"]: version_args = [f'-c.extraMetadata.shortVersion={metadata["file"]}', f'-c.extraMetadata.shortVersionWindows={metadata["file"]}'] targets = {"win32": ["--win", "msix"], "darwin": ["--mac", "dmg", "zip"], "linux": ["--linux", "AppImage"]}[sys.platform] - run([*npm, "run", "build"], cwd=desktop, env=env) + run([*npm, "run", "build", "--", "--icons", str(icons)], cwd=desktop, env=env) run([*npm, "run", "builder", "--", *targets, f"-c.extraMetadata.version={version}", *version_args, *builder_args], cwd=desktop, env=env) diff --git a/scripts/desktop-update/posix.sh b/scripts/desktop-update/posix.sh index c5beafd675..8bc9e2ef87 100755 --- a/scripts/desktop-update/posix.sh +++ b/scripts/desktop-update/posix.sh @@ -741,25 +741,42 @@ fi sleep 1 start_ui -HERMES_BIN="$INSTALL_ROOT/venv/bin/hermes" -[ -x "$HERMES_BIN" ] || { FINAL_CODE=3 FINAL_MSG="Update aborted: $HERMES_BIN is missing. The install needs repair (run the Hermes installer or hermes doctor)."; log "$FINAL_MSG"; exit 3; } - -# Heal a venv the reverted TCC anchor left bricked BEFORE invoking the CLI: -# venv/bin/hermes execs venv/bin/python3, so a dead alias kills every attempt -# and its retry identically (#95759). macOS-only artifact; probe is cheap. -if [ "$(uname)" = "Darwin" ]; then - if tcc_anchor_heal "$INSTALL_ROOT/venv/bin"; then - case "$TCC_HEAL_STATE" in - healed-*) log "TCC anchor self-heal repaired the venv interpreter ($TCC_HEAL_STATE)" ;; - esac - else - log "TCC anchor self-heal could not repair the venv ($TCC_HEAL_STATE)" +# Current installs publish an installation-bound launcher. Only pre-PM +# checkouts use the old shim/TCC rescue; a damaged PM install must not retarget. +LEGACY_INSTALL=0 +[ -d "$INSTALL_ROOT/pm" ] || LEGACY_INSTALL=1 +select_update_invoke() { + HERMES_BIN="$INSTALL_ROOT/.hermes/bin/hermes" + if [ -x "$HERMES_BIN" ]; then + UPDATE_INVOKE=("$HERMES_BIN") + return 0 fi -fi -tcc_pick_update_invoke "$INSTALL_ROOT/venv/bin" -if [ "${UPDATE_INVOKE[0]}" != "$HERMES_BIN" ]; then - log "venv/bin/python3 still unbootable; invoking the update via ${UPDATE_INVOKE[*]}" -fi + if [ -f "$INSTALL_ROOT/hermes_cli/_launchers.py" ]; then + local candidate version reported expected + expected="$(cd "$INSTALL_ROOT" && pwd -P)" || return 1 + for candidate in "$HOME/.local/bin/hermes" "$HERMES_HOME/bin/hermes"; do + [ -x "$candidate" ] || continue + version="$("$candidate" --version 2>/dev/null)" || continue + reported="$(printf '%s\n' "$version" | sed -n 's/^Install directory: //p')" + [ -d "$reported" ] || continue + [ "$(cd "$reported" && pwd -P)" = "$expected" ] || continue + HERMES_BIN="$candidate" + UPDATE_INVOKE=("$candidate") + return 0 + done + fi + if [ "$LEGACY_INSTALL" -eq 1 ] && [ ! -d "$INSTALL_ROOT/pm" ]; then + HERMES_BIN="$INSTALL_ROOT/venv/bin/hermes" + [ -x "$HERMES_BIN" ] || return 1 + if [ "$(uname)" = Darwin ]; then + tcc_anchor_heal "$INSTALL_ROOT/venv/bin" || log "TCC anchor rescue failed ($TCC_HEAL_STATE)" + fi + tcc_pick_update_invoke "$INSTALL_ROOT/venv/bin" + return 0 + fi + return 1 +} +select_update_invoke || { FINAL_CODE=3 FINAL_MSG="Update aborted: the installation launcher at $HERMES_BIN is missing. Repair this installation."; log "$FINAL_MSG"; exit 3; } # Run FROM the install root: `hermes update` resolves the tree it mutates # from the working directory, and we inherit the Desktop's cwd (which can be @@ -788,7 +805,7 @@ OUT="$("${UPDATE_INVOKE[@]}" update --yes --gateway $KEEP_STASH "${TARGET_ARGS[@ printf '%s\n' "$OUT" >> "$LOG" 2>/dev/null log "hermes update exit code: $CODE" -if [ "$CODE" -ne 0 ] && [ "$CODE" -ne 2 ]; then +if [ "$LEGACY_INSTALL" -eq 1 ] && [ "$CODE" -ne 0 ] && [ "$CODE" -ne 2 ]; then # Retry once: update-boundary class (fresh code on disk, stale in memory). # Exit 2 ("close all Hermes windows") is not retryable. # @@ -806,16 +823,16 @@ if [ "$CODE" -ne 0 ] && [ "$CODE" -ne 2 ]; then fi log "retrying once (freshly pulled fix loads on the second run)" publish_stage "Retrying update" + select_update_invoke || { FINAL_CODE=3 FINAL_MSG="Updated installation launcher is missing; repair this installation."; exit 3; } OUT="$("${UPDATE_INVOKE[@]}" update --yes --gateway $KEEP_STASH "${TARGET_ARGS[@]}" 2>&1)"; CODE=$? printf '%s\n' "$OUT" >> "$LOG" 2>/dev/null log "retry exit code: $CODE" fi trap 'on_signal TERM' TERM -# Truthful completion: `hermes update` calls a GUI build failure non-fatal -# (exit 0). For a Desktop-driven update that would relaunch the OLD build -# and call it success -- retry the build once, propagate honestly. -if [ "$CODE" -eq 0 ] && printf '%s' "$OUT" | grep -q "Desktop build failed"; then +# Pre-PM update code could report a failed desktop build with exit zero. +# Current composition propagates failure and never enters this legacy repair. +if [ "$LEGACY_INSTALL" -eq 1 ] && [ "$CODE" -eq 0 ] && printf '%s' "$OUT" | grep -q "Desktop build failed"; then log "desktop build failed inside hermes update; retrying build" publish_stage "Rebuilding Desktop" "${UPDATE_INVOKE[@]}" desktop --force-build --build-only >> "$LOG" 2>&1 || { @@ -830,7 +847,7 @@ else # The bricked-venv class is fixable and must not read as a generic exit 1: # a dead interpreter with a failed/impossible heal means retrying can never # succeed — tell the user what is actually wrong (#95759). - if ! tcc_probe_python "$INSTALL_ROOT/venv/bin/python3" \ + if [ "$LEGACY_INSTALL" -eq 1 ] && ! tcc_probe_python "$INSTALL_ROOT/venv/bin/python3" \ && ! tcc_probe_python "$INSTALL_ROOT/venv/bin/python"; then FINAL_MSG="Update failed: the Python interpreter inside $INSTALL_ROOT/venv cannot start (heal state: $TCC_HEAL_STATE). Reinstall the runtime with the Hermes installer, or run hermes doctor --fix from a terminal if any hermes command still works." fi diff --git a/scripts/desktop-update/retry-policy.ps1 b/scripts/desktop-update/retry-policy.ps1 deleted file mode 100644 index 004287874c..0000000000 --- a/scripts/desktop-update/retry-policy.ps1 +++ /dev/null @@ -1,16 +0,0 @@ -function Test-HermesUpdateShouldRetry { - param( - [int]$ExitCode, - [string]$InstallRoot - ) - - if ($ExitCode -eq 0) { return $false } - if ($ExitCode -ne 2) { return $true } - - # Exit 2 is shared by non-retryable safety refusals and the self-lock - # deferral. Only the latter writes this marker. The handoff treats it as a - # retry signal for one fresh-process attempt, whose early-recovery pass - # completes core dependencies before native modules load. - $deferredInstallMarker = Join-Path $InstallRoot ".update-incomplete" - return Test-Path -LiteralPath $deferredInstallMarker -} diff --git a/scripts/desktop-update/runtime.ps1 b/scripts/desktop-update/runtime.ps1 new file mode 100644 index 0000000000..95719b359a --- /dev/null +++ b/scripts/desktop-update/runtime.ps1 @@ -0,0 +1,53 @@ +function Get-HermesRuntimeCommand { + param( + [Parameter(Mandatory = $true)][string]$InstallRoot, + [string]$Module = 'hermes_cli.main' + ) + + # The launcher owns interpreter/ABI and generation selection. Never infer + # PM's store layout or borrow a different installation's PATH command. + foreach ($name in @('hermes.exe', 'hermes.cmd')) { + $launcher = Join-Path $InstallRoot ".hermes\bin\$name" + if (Test-Path -LiteralPath $launcher -PathType Leaf) { + $json = & $launcher --print-runtime-command --module $Module + if ($LASTEXITCODE) { throw "Installation launcher failed (exit $LASTEXITCODE): $launcher" } + $command = @((($json -join "`n") | ConvertFrom-Json)) + if ($command.Count -lt 2 -or @($command | Where-Object { $_ -isnot [string] -or -not $_ }).Count) { + throw "Installation launcher returned invalid command: $launcher" + } + return $command + } + } + + # Earlier PM installers published only to user-bin. The established + # --version surface reports the bound source root; never trust PATH alone. + if (Test-Path -LiteralPath (Join-Path $InstallRoot 'hermes_cli/_launchers.py') -PathType Leaf) { + $directories = @( + (Join-Path $env:HERMES_HOME 'bin'), + (Join-Path (Split-Path -Parent $InstallRoot) 'bin') + ) + if ($env:LOCALAPPDATA) { $directories += Join-Path $env:LOCALAPPDATA 'hermes/bin' } + foreach ($directory in ($directories | Select-Object -Unique)) { + foreach ($name in @('hermes.exe', 'hermes.cmd')) { + $legacy = Join-Path $directory $name + if (-not (Test-Path -LiteralPath $legacy -PathType Leaf)) { continue } + $version = (& $legacy --version 2>$null) -join "`n" + if ($LASTEXITCODE -or $version -notmatch '(?m)^Install directory: (.+)\r?$') { continue } + $reported = [IO.Path]::GetFullPath($Matches[1].Trim()).TrimEnd('\', '/') + $expected = [IO.Path]::GetFullPath($InstallRoot).TrimEnd('\', '/') + if (-not [string]::Equals($reported, $expected, [StringComparison]::OrdinalIgnoreCase)) { continue } + if ($Module -ne 'hermes_cli.main') { + throw "This older installation needs its launcher refreshed before running $Module. Run the update through $legacy." + } + return @($legacy) + } + } + } + + # Only an older, pre-PM checkout may use the historical interpreter. + if (-not (Test-Path -LiteralPath (Join-Path $InstallRoot 'pm') -PathType Container)) { + $python = Join-Path $InstallRoot 'venv\Scripts\python.exe' + if (Test-Path -LiteralPath $python -PathType Leaf) { return @($python, '-m', $Module) } + } + throw "Installation launcher is missing under $InstallRoot\.hermes\bin. Repair this installation." +} \ No newline at end of file diff --git a/scripts/desktop-update/windows.ps1 b/scripts/desktop-update/windows.ps1 index 120ba82fd1..85d0c74082 100644 --- a/scripts/desktop-update/windows.ps1 +++ b/scripts/desktop-update/windows.ps1 @@ -1063,6 +1063,15 @@ function Invoke-HermesStep([string]$Exe, [string[]]$HermesArgs, [string]$Tag) { $arguments = ($HermesArgs | ForEach-Object { '"{0}"' -f ($_ -replace '"', '\"') }) -join ' ' # CreateProcess inherits this process's environment. Set Python's encoding # and buffering only for the atomic launch, then restore the hand-off host. + # Historical user-bin publication could be a command file rather than a + # native launcher. Keep the wrapper inside the same supervised job. + if ([IO.Path]::GetExtension($Exe) -eq '.cmd') { + if ($Exe -match '[%!"\x0D\x0A]' -or @($HermesArgs | Where-Object { $_ -match '[%!"\x0D\x0A]' }).Count) { + throw 'The legacy command launcher cannot safely quote this update target; refresh the installation launcher first.' + } + $arguments = '/d /s /c ""' + $Exe + '" ' + $arguments + '"' + $Exe = $env:ComSpec + } $savedPythonIoEncoding = $env:PYTHONIOENCODING $savedPythonUtf8 = $env:PYTHONUTF8 $savedPythonUnbuffered = $env:PYTHONUNBUFFERED @@ -1500,11 +1509,13 @@ try { exit 0 } - # Check only the interpreter here: dependency recovery belongs to update. - $pythonExe = Join-Path $InstallRoot "venv\Scripts\python.exe" - if (-not (Test-Path -LiteralPath $pythonExe -PathType Leaf)) { + . (Join-Path $PSScriptRoot 'runtime.ps1') + $legacyInstall = -not (Test-Path -LiteralPath (Join-Path $InstallRoot 'pm') -PathType Container) + try { + $runtimeCommand = @(Get-HermesRuntimeCommand -InstallRoot $InstallRoot) + } catch { $finalCode = 3 - $finalMsg = "Update aborted: $pythonExe is missing. Repair the installation and review antivirus quarantine before retrying." + $finalMsg = $_.Exception.Message Write-HandoffLog $finalMsg exit $finalCode } @@ -1520,8 +1531,7 @@ try { if ($script:Ui) { [System.Windows.Forms.Application]::DoEvents() } } if (Get-Process -Id $DesktopPid -ErrorAction SilentlyContinue) { - # A live Desktop means a live backend re-locking the venv at any - # moment. Updating under it is how installs brick. Abort. + # The running Desktop still owns application outputs being replaced. $finalCode = 4 $finalMsg = "Update aborted: the Hermes window (pid $DesktopPid) did not exit within 30s. Nothing was changed. Close Hermes fully and try again." Write-HandoffLog $finalMsg @@ -1530,85 +1540,18 @@ try { Write-HandoffLog "desktop exited" } - # -- 2. Wait for the venv shim to unlock (FAIL CLOSED) ------------------ - Publish-UiProgress "Preparing Hermes files" - $shim = Join-Path $InstallRoot "venv\Scripts\hermes.exe" - if (Test-Path -LiteralPath $shim) { - $unlocked = $false - $deadline = (Get-Date).AddSeconds(20) - while ((Get-Date) -lt $deadline) { - try { - $fs = [System.IO.File]::Open($shim, 'Open', 'ReadWrite', 'None') - $fs.Close() - $unlocked = $true - break - } catch { - Start-Sleep -Milliseconds 400 - if ($script:Ui) { [System.Windows.Forms.Application]::DoEvents() } - } - } - if (-not $unlocked) { - # Something still maps the venv. --force-ing past it guarantees a - # half-updated venv (the exact 2026-08-09 Access-denied brick). - $finalCode = 5 - $finalMsg = "Update aborted: another process is still holding the Hermes install open (venv\Scripts\hermes.exe locked after 20s). Nothing was changed. Close other Hermes windows/terminals and try again." - Write-HandoffLog $finalMsg - exit $finalCode - } - Write-HandoffLog "venv shim unlocked" - } - - # -- 3. Run the update from the CURRENT checkout ------------------------ - # --force skips only the hermes.exe shim guard, which step 2 just PROVED - # is unlocked; the venv-python holder guard (orphan reap included) stays - # active. Our marker claim is adopted by the child via update_lock.py's - # process-ancestry rule. - # - # DRIVE THE UPDATE THROUGH venv\Scripts\python.exe, NOT venv\Scripts\hermes.exe. - # `uv pip install -e .` has to replace the console-script shims, so - # _quarantine_running_hermes_exe must first rename the running hermes.exe - # out of the way. On Windows that rename fails whenever ANY child process - # spawned from that hermes.exe is still alive: a child inherits a handle on - # the parent image, and the resulting sharing violation is indistinguishable - # from a user leaving a second Hermes window open. It is the inherited - # handle, not the trampoline itself, that pins the file -- killing the child - # makes the same rename succeed immediately, and the shim flavour (uv - # trampoline vs distlib launcher) makes no difference. - # - # The updater reliably spawns such children itself (npx cache warm, memory - # provider refresh -- hindsight-api runs as a daemon with --idle-timeout - # 300 and outlives the step that started it), so this is a race, not a - # deterministic failure: the same hand-off succeeds on one run and dies on - # the next. Step 2's preflight cannot catch it, because the shim genuinely - # IS unlocked at that moment. - # - # When the rename loses that race there is no recovery: `uv pip install -e .` - # exits 2 and the ZIP fallback repeats the identical sequence, so the desktop - # build stage is never reached and apps/desktop/release is left missing -- an - # install whose Start Menu shortcut points at a Hermes.exe that no longer - # exists. (A reboot-deferred rename was the old last resort here; it needed - # elevation a Desktop-driven update does not have, and freed nothing for the - # install already in flight.) - # - # Running the same code as `python.exe -m hermes_cli.main update` puts the - # inherited handles on python.exe, which uv never has to replace. - # - # posix.sh is deliberately left alone: unlinking a running executable is - # legal there, so the equivalent call is harmless. - $pythonExe = Join-Path $InstallRoot "venv\Scripts\python.exe" - if (-not (Test-Path -LiteralPath $pythonExe)) { - $finalCode = 3 - $finalMsg = "Update aborted: $pythonExe is missing. The install needs repair (run the Hermes installer or `hermes doctor`)." - Write-HandoffLog $finalMsg - exit $finalCode - } - $updateArgs = @("-m", "hermes_cli.main", "update", "--yes", "--gateway", "--force") + $targetArgs + # PM creates a new dependency generation. Live old Python readers do not + # block it; Desktop exit above protects the application output replacement. + $pythonExe = $runtimeCommand[0] + $runtimeArgs = @($runtimeCommand | Select-Object -Skip 1) + $updateArgs = $runtimeArgs + @('update', '--yes', '--gateway') + $targetArgs + if ($legacyInstall) { $updateArgs += '--force' } # --keep-stash: never re-apply local source edits after the update (they # stay parked in git stash). Probe --help first: the flag ships with newer # backends and an unknown flag would abort argparse with exit 2, which # collides with the "close all Hermes windows" sentinel. try { - $updateHelp = & $pythonExe -m hermes_cli.main update --help 2>$null | Out-String + $updateHelp = & $pythonExe @runtimeArgs update --help 2>$null | Out-String if ($updateHelp -match "--keep-stash") { $updateArgs += "--keep-stash" } else { @@ -1622,48 +1565,36 @@ try { $res = Invoke-HermesStep $pythonExe $updateArgs "update" Write-HandoffLog "hermes update exit code: $($res.Code)" - $retryPolicyPath = Join-Path $PSScriptRoot "retry-policy.ps1" - if (Test-Path -LiteralPath $retryPolicyPath) { - . $retryPolicyPath - $shouldRetry = Test-HermesUpdateShouldRetry -ExitCode $res.Code -InstallRoot $InstallRoot - } else { - # The child may have swapped to a checkout without the companion policy - # while this older script is still running in memory. Preserve the - # previous fail-closed behavior instead of calling an undefined function. - Write-HandoffLog "retry policy is unavailable after checkout swap; using legacy retry rules" - $shouldRetry = $res.Code -ne 0 -and $res.Code -ne 2 - } - if ($shouldRetry) { - # One retry for update-boundary failures. Most exit-2 safety refusals - # remain terminal, but self-lock deferral also uses exit 2 and writes - # .update-incomplete after the code swap. That marker is only a retry - # signal here: the fresh process's early-recovery pass finishes core - # dependency sync before native modules load, then `update` continues - # the remaining Desktop/skills stages of the full pipeline. - Write-HandoffLog "first attempt left retryable update state; retrying once in a fresh process" + # Retry only the identified pre-PM update-boundary transition. Current + # update/build failures propagate and must not trigger another owner. + if ($legacyInstall -and $res.Code -ne 0 -and $res.Code -ne 2) { + Write-HandoffLog "legacy update failed; retrying once from the updated installation" Publish-UiProgress "Retrying update" - $res = Invoke-HermesStep $pythonExe $updateArgs "update" - Write-HandoffLog "retry exit code: $($res.Code)" + $runtimeCommand = @(Get-HermesRuntimeCommand -InstallRoot $InstallRoot) + $pythonExe = $runtimeCommand[0] + $runtimeArgs = @($runtimeCommand | Select-Object -Skip 1) + $updateArgs = $runtimeArgs + @('update', '--yes', '--gateway') + $targetArgs + $res = Invoke-HermesStep $pythonExe $updateArgs 'update' } - # -- 4. Truthful completion: don't trust exit 0 ------------------------- - # `hermes update` treats a Desktop GUI build failure as NON-fatal (prints - # a one-line warning, exits 0). For a Desktop-DRIVEN update that warning - # is fatal: we would relaunch the old exe and call it success. Detect it, - # retry the build once, and propagate honestly. + # Pre-PM updates reported a successful exit with a failed build warning. + # Keep that historical transition here only; current failures propagate. $desktopBuildFailed = $false - if ($res.Code -eq 0 -and $res.Output -match "Desktop build failed") { + if ($legacyInstall -and $res.Code -eq 0 -and $res.Output -match "Desktop build failed") { Write-HandoffLog "hermes update reported a desktop build failure (non-fatal there, fatal here); retrying build" Publish-UiProgress "Rebuilding Desktop" - $rebuild = Invoke-HermesStep $pythonExe @("-m", "hermes_cli.main", "desktop", "--force-build", "--build-only") "rebuild" + $runtimeCommand = @(Get-HermesRuntimeCommand -InstallRoot $InstallRoot) + $rebuildArgs = @($runtimeCommand | Select-Object -Skip 1) + @('desktop', '--force-build', '--build-only') + $rebuild = Invoke-HermesStep $runtimeCommand[0] $rebuildArgs 'rebuild' Write-HandoffLog "desktop rebuild exit code: $($rebuild.Code)" if ($rebuild.Code -ne 0) { $desktopBuildFailed = $true } } # A zero-exit update is not proof that the runtime survived the update. if ($res.Code -eq 0 -and -not $desktopBuildFailed) { - $verifyCode = "import hermes_cli.main; from hermes_cli.desktop_update_verify import verify_windows_desktop_update; verify_windows_desktop_update()" - $verify = Invoke-HermesStep $pythonExe @("-c", $verifyCode) "verify" + $verifyCommand = @(Get-HermesRuntimeCommand -InstallRoot $InstallRoot -Module 'hermes_cli.desktop_update_verify') + $verifyArgs = @($verifyCommand | Select-Object -Skip 1) + $verify = Invoke-HermesStep $verifyCommand[0] $verifyArgs 'verify' if ($verify.Code -ne 0) { $finalCode = 8 $finalMsg = "The updated Hermes runtime or Desktop build failed verification. Repair the installation and review antivirus quarantine before retrying." diff --git a/scripts/generate-icons.mjs b/scripts/generate-icons.mjs index 2980f5ffe3..e578a11673 100644 --- a/scripts/generate-icons.mjs +++ b/scripts/generate-icons.mjs @@ -21,7 +21,8 @@ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..' export function generateIcons(args = [], { root = repoRoot, run = spawnSync, env = process.env } = {}) { const { values } = parseArgs({ args, options: { - source: { type: 'string' }, out: { type: 'string' }, check: { type: 'boolean' } + source: { type: 'string' }, out: { type: 'string' }, check: { type: 'boolean' }, + 'on-demand': { type: 'boolean' }, } }) const source = path.resolve(values.source ?? root) const out = path.resolve(values.out ?? source) @@ -31,7 +32,7 @@ export function generateIcons(args = [], { root = repoRoot, run = spawnSync, env delete childEnv.PYTHONHOME const result = run(env.HERMES_PYTHON || 'python', [ path.join(root, 'scripts', 'build', 'icon_environment.py'), '--source', source, '--out', out, - ...(values.check ? ['--check'] : []) + ...(values.check ? ['--check'] : []), ...(values['on-demand'] ? ['--on-demand'] : []) ], { cwd: source, stdio: 'inherit', windowsHide: true, env: childEnv }) if (result.error) { console.error('[generate-icons] failed to launch icon generator:', result.error.message) diff --git a/scripts/hermes-gateway b/scripts/hermes-gateway index 2d39cfbe9d..378b46fd0c 100755 --- a/scripts/hermes-gateway +++ b/scripts/hermes-gateway @@ -1,423 +1,13 @@ #!/usr/bin/env python3 -""" -Hermes Gateway - Standalone messaging platform integration. - -This is the proper entry point for running the gateway as a service. -NOT tied to the CLI - runs independently. - -Usage: - # Run in foreground (for testing) - ./scripts/hermes-gateway - - # Install as systemd service - ./scripts/hermes-gateway install - - # Manage the service - ./scripts/hermes-gateway start - ./scripts/hermes-gateway stop - ./scripts/hermes-gateway restart - ./scripts/hermes-gateway status - - # Uninstall - ./scripts/hermes-gateway uninstall -""" - -import argparse -import asyncio +"""Historical script entry; service policy belongs to ``hermes gateway``.""" import os -import subprocess -import sys from pathlib import Path +import sys -# Add parent directory to path -SCRIPT_DIR = Path(__file__).parent.resolve() -PROJECT_DIR = SCRIPT_DIR.parent -sys.path.insert(0, str(PROJECT_DIR)) - -# Load .env file -from dotenv import load_dotenv -env_path = PROJECT_DIR / '.env' -if env_path.exists(): - load_dotenv(dotenv_path=env_path) - - -# ============================================================================= -# Service Configuration -# ============================================================================= - -SERVICE_NAME = "hermes-gateway" -SERVICE_DESCRIPTION = "Hermes Agent Gateway - Messaging Platform Integration" - -def get_systemd_unit_path() -> Path: - """Get the path for the systemd user service file.""" - return Path.home() / ".config" / "systemd" / "user" / f"{SERVICE_NAME}.service" - -def get_launchd_plist_path() -> Path: - """Get the path for the launchd plist file (macOS).""" - return Path.home() / "Library" / "LaunchAgents" / f"ai.hermes.gateway.plist" - -def get_python_path() -> str: - """Get the path to the Python interpreter.""" - # Prefer the venv if it exists - venv_python = PROJECT_DIR / "venv" / "bin" / "python" - if venv_python.exists(): - return str(venv_python) - return sys.executable - -def get_gateway_script_path() -> str: - """Get the path to this script.""" - return str(Path(__file__).resolve()) - - -# ============================================================================= -# Systemd Service (Linux) -# ============================================================================= - -def generate_systemd_unit() -> str: - """Generate the systemd unit file content.""" - python_path = get_python_path() - script_path = get_gateway_script_path() - working_dir = str(PROJECT_DIR) - - return f"""[Unit] -Description={SERVICE_DESCRIPTION} -After=network.target -StartLimitIntervalSec=600 -StartLimitBurst=5 - -[Service] -Type=simple -ExecStart={python_path} {script_path} run -WorkingDirectory={working_dir} -Restart=on-failure -RestartSec=30 -StandardOutput=journal -StandardError=journal - -# Environment (optional - can also use .env file) -# Environment="TELEGRAM_BOT_TOKEN=your_token" -# Environment="DISCORD_BOT_TOKEN=your_token" - -[Install] -WantedBy=default.target -""" - -def install_systemd(): - """Install the systemd user service.""" - unit_path = get_systemd_unit_path() - unit_path.parent.mkdir(parents=True, exist_ok=True) - - print(f"Installing systemd service to: {unit_path}") - unit_path.write_text(generate_systemd_unit()) - - # Reload systemd - subprocess.run(["systemctl", "--user", "daemon-reload"], check=True) - - # Enable the service (start on boot) - subprocess.run(["systemctl", "--user", "enable", SERVICE_NAME], check=True) - - print(f"✓ Service installed and enabled") - print(f"") - print(f"To start the service:") - print(f" systemctl --user start {SERVICE_NAME}") - print(f"") - print(f"To view logs:") - print(f" journalctl --user -u {SERVICE_NAME} -f") - print(f"") - print(f"To enable lingering (keeps service running after logout):") - print(f" sudo loginctl enable-linger $USER") - -def uninstall_systemd(): - """Uninstall the systemd user service.""" - unit_path = get_systemd_unit_path() - - # Stop and disable first - subprocess.run(["systemctl", "--user", "stop", SERVICE_NAME], check=False) - subprocess.run(["systemctl", "--user", "disable", SERVICE_NAME], check=False) - - # Remove the unit file - if unit_path.exists(): - unit_path.unlink() - print(f"✓ Removed {unit_path}") - - # Reload systemd - subprocess.run(["systemctl", "--user", "daemon-reload"], check=True) - print(f"✓ Service uninstalled") - -def systemd_status(): - """Show systemd service status.""" - subprocess.run(["systemctl", "--user", "status", SERVICE_NAME]) - -def systemd_start(): - """Start the systemd service.""" - subprocess.run(["systemctl", "--user", "start", SERVICE_NAME], check=True) - print(f"✓ Service started") - -def systemd_stop(): - """Stop the systemd service.""" - subprocess.run(["systemctl", "--user", "stop", SERVICE_NAME], check=True) - print(f"✓ Service stopped") - -def systemd_restart(): - """Restart the systemd service.""" - subprocess.run(["systemctl", "--user", "restart", SERVICE_NAME], check=True) - print(f"✓ Service restarted") - - -# ============================================================================= -# Launchd Service (macOS) -# ============================================================================= - -def generate_launchd_plist() -> str: - """Generate the launchd plist file content.""" - python_path = get_python_path() - script_path = get_gateway_script_path() - working_dir = str(PROJECT_DIR) - log_dir = Path.home() / ".hermes" / "logs" - - return f""" - - - - Label - ai.hermes.gateway - - ProgramArguments - - {python_path} - {script_path} - run - - - WorkingDirectory - {working_dir} - - RunAtLoad - - - KeepAlive - - SuccessfulExit - - - - StandardOutPath - {log_dir}/gateway.log - - StandardErrorPath - {log_dir}/gateway.error.log - - EnvironmentVariables - - PATH - /usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin - - - -""" - -def install_launchd(): - """Install the launchd service (macOS).""" - plist_path = get_launchd_plist_path() - plist_path.parent.mkdir(parents=True, exist_ok=True) - - # Ensure log directory exists - log_dir = Path.home() / ".hermes" / "logs" - log_dir.mkdir(parents=True, exist_ok=True) - - print(f"Installing launchd service to: {plist_path}") - plist_path.write_text(generate_launchd_plist()) - - # Load the service - subprocess.run(["launchctl", "load", str(plist_path)], check=True) - - print(f"✓ Service installed and loaded") - print(f"") - print(f"To view logs:") - print(f" tail -f ~/.hermes/logs/gateway.log") - print(f"") - print(f"To manage the service:") - print(f" launchctl start ai.hermes.gateway") - print(f" launchctl stop ai.hermes.gateway") - -def uninstall_launchd(): - """Uninstall the launchd service (macOS).""" - plist_path = get_launchd_plist_path() - - # Unload first - subprocess.run(["launchctl", "unload", str(plist_path)], check=False) - - # Remove the plist file - if plist_path.exists(): - plist_path.unlink() - print(f"✓ Removed {plist_path}") - - print(f"✓ Service uninstalled") - -def launchd_status(): - """Show launchd service status.""" - subprocess.run(["launchctl", "list", "ai.hermes.gateway"]) - -def launchd_start(): - """Start the launchd service.""" - subprocess.run(["launchctl", "start", "ai.hermes.gateway"], check=True) - print(f"✓ Service started") - -def launchd_stop(): - """Stop the launchd service.""" - subprocess.run(["launchctl", "stop", "ai.hermes.gateway"], check=True) - print(f"✓ Service stopped") - -def launchd_restart(): - """Restart the launchd service.""" - launchd_stop() - launchd_start() - - -# ============================================================================= -# Platform Detection -# ============================================================================= - -def is_linux() -> bool: - return sys.platform.startswith('linux') - -def is_macos() -> bool: - return sys.platform == 'darwin' - -def is_windows() -> bool: - return sys.platform == 'win32' - - -# ============================================================================= -# Gateway Runner -# ============================================================================= - -def run_gateway(): - """Run the gateway in foreground.""" - # Startup-liveness watchdog (OOF-298): arm before importing the gateway - # graph so an import-time or pre-loop deadlock still gets respawned. - try: - from hermes_startup_watchdog import arm_startup_watchdog - arm_startup_watchdog() - except Exception: - pass - from gateway.run import start_gateway - print("Starting Hermes Gateway...") - print("Press Ctrl+C to stop.") - print() - asyncio.run(start_gateway()) - - -# ============================================================================= -# Main CLI -# ============================================================================= - -def main(): - parser = argparse.ArgumentParser( - description="Hermes Gateway - Messaging Platform Integration", - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=""" -Examples: - # Run in foreground (for testing) - ./scripts/hermes-gateway run - - # Install as system service - ./scripts/hermes-gateway install - - # Manage the service - ./scripts/hermes-gateway start - ./scripts/hermes-gateway stop - ./scripts/hermes-gateway restart - ./scripts/hermes-gateway status - - # Uninstall - ./scripts/hermes-gateway uninstall - -Configuration: - Set environment variables in .env file or system environment: - - TELEGRAM_BOT_TOKEN - - DISCORD_BOT_TOKEN - - WHATSAPP_ENABLED - - Or create ~/.hermes/gateway.json for advanced configuration. -""" - ) - - parser.add_argument( - "command", - choices=["run", "install", "uninstall", "start", "stop", "restart", "status"], - nargs="?", - default="run", - help="Command to execute (default: run)" - ) - - parser.add_argument( - "--verbose", "-v", - action="store_true", - help="Verbose output" - ) - - args = parser.parse_args() - - # Detect platform and dispatch command - if args.command == "run": - run_gateway() - - elif args.command == "install": - if is_linux(): - install_systemd() - elif is_macos(): - install_launchd() - else: - print("Service installation not supported on this platform.") - print("Please run manually: ./scripts/hermes-gateway run") - sys.exit(1) - - elif args.command == "uninstall": - if is_linux(): - uninstall_systemd() - elif is_macos(): - uninstall_launchd() - else: - print("Service uninstallation not supported on this platform.") - sys.exit(1) - - elif args.command == "start": - if is_linux(): - systemd_start() - elif is_macos(): - launchd_start() - else: - print("Not supported on this platform.") - sys.exit(1) - - elif args.command == "stop": - if is_linux(): - systemd_stop() - elif is_macos(): - launchd_stop() - else: - print("Not supported on this platform.") - sys.exit(1) - - elif args.command == "restart": - if is_linux(): - systemd_restart() - elif is_macos(): - launchd_restart() - else: - print("Not supported on this platform.") - sys.exit(1) - - elif args.command == "status": - if is_linux(): - systemd_status() - elif is_macos(): - launchd_status() - else: - print("Not supported on this platform.") - sys.exit(1) - +root = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(root)) +from hermes_cli._launchers import runtime_command if __name__ == "__main__": - main() + command = runtime_command(root, ["gateway", *(sys.argv[1:] or ["run"])]) + os.execv(command[0], command) \ No newline at end of file diff --git a/scripts/install.ps1 b/scripts/install.ps1 index e1ef469e06..13fa8a3d9c 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -600,14 +600,22 @@ function Stage-Config { Log "config prepared in $HermesHome" } +function Invoke-InstalledHermes([string[]]$CommandArgs) { + . (Join-Path $InstallDir 'scripts/desktop-update/runtime.ps1') + $command = @(Get-HermesRuntimeCommand -InstallRoot $InstallDir) + $runtimeArgs = @($command | Select-Object -Skip 1) + $CommandArgs + & $command[0] @runtimeArgs + if ($LASTEXITCODE) { Fail "hermes $($CommandArgs -join ' ') failed (exit $LASTEXITCODE)" } +} + function Stage-Setup { if ($NonInteractive) { return } - & (Join-Path $InstallDir "venv\Scripts\python.exe") (Join-Path $InstallDir "hermes") setup + Invoke-InstalledHermes @('setup') } function Stage-Gateway { if ($NonInteractive) { return } - & (Join-Path $InstallDir "venv\Scripts\python.exe") (Join-Path $InstallDir "hermes") gateway install + Invoke-InstalledHermes @('gateway', 'install') } function Stage-Desktop { @@ -617,17 +625,16 @@ function Stage-Desktop { # The build is `hermes desktop --build-only`, the same authority as # `hermes gui` and the update flow; the deleted installer-local # npm/Electron helpers must not reappear here. - $venvPython = Join-Path $InstallDir "venv\Scripts\python.exe" - if (-not (Test-Path $venvPython)) { Fail "venv python missing at $venvPython" } + $bootPy = Get-BootstrapPython Push-Location $InstallDir try { Log "ensuring desktop voice/wake dependencies via pm venv sync" - & $venvPython -c "from pm.ensure import sync_venv; sync_venv(['wake', 'voice'], explicit=True)" + & $bootPy -I -c "import sys; sys.path.insert(0, sys.argv[1]); from pm import sync_venv; sync_venv(['wake', 'voice'], explicit=True)" $InstallDir if ($LASTEXITCODE) { Write-Host "[hermes] voice/wake dependency sync failed (exit $LASTEXITCODE) -- they will lazy-install at first use" -ForegroundColor Yellow } Log "building desktop app (hermes desktop --build-only)" - & $venvPython (Join-Path $InstallDir "hermes") desktop --build-only + Invoke-InstalledHermes @('desktop', '--build-only') $code = $LASTEXITCODE if ($code) { Fail "desktop build failed (hermes desktop --build-only exited $code)" } diff --git a/scripts/install.sh b/scripts/install.sh index 66b3de7ae7..13ab282126 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -389,18 +389,18 @@ stage_config() { stage_setup() { if [ "$NON_INTERACTIVE" = true ]; then return 0; fi - "$INSTALL_DIR/venv/bin/python" "$INSTALL_DIR/hermes" setup || true + "$INSTALL_DIR/.hermes/bin/hermes" setup || fail "setup failed" } stage_gateway() { if [ "$NON_INTERACTIVE" = true ]; then return 0; fi - "$INSTALL_DIR/venv/bin/python" "$INSTALL_DIR/hermes" gateway install || true + "$INSTALL_DIR/.hermes/bin/hermes" gateway install || fail "gateway installation failed" } stage_desktop() { # `hermes desktop --build-only` is the current authority (same path as # `hermes gui` / the update flow); no installer-local node/electron code. - "$INSTALL_DIR/venv/bin/python" "$INSTALL_DIR/hermes" desktop --build-only || fail "desktop build failed" + "$INSTALL_DIR/.hermes/bin/hermes" desktop --build-only || fail "desktop build failed" } stage_complete() { diff --git a/skills/autonomous-ai-agents/hermes-agent/SKILL.md b/skills/autonomous-ai-agents/hermes-agent/SKILL.md index 7b4565825f..a184b3a47a 100644 --- a/skills/autonomous-ai-agents/hermes-agent/SKILL.md +++ b/skills/autonomous-ai-agents/hermes-agent/SKILL.md @@ -45,7 +45,7 @@ Never answer "Hermes can't do that" from memory. Hermes ships far more than this ## Quick Start ```bash -# Install (shell installer — sets up uv, Python, the venv, and the launcher) +# Install (shell installer — bootstraps PM, Python, dependencies, and the launcher) curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash # Interactive chat (default surface; set display.interface: tui to launch the Ink TUI instead) diff --git a/skills/media/youtube-content/SKILL.md b/skills/media/youtube-content/SKILL.md index d9a981bc4a..1e87b467aa 100644 --- a/skills/media/youtube-content/SKILL.md +++ b/skills/media/youtube-content/SKILL.md @@ -21,29 +21,42 @@ Extract transcripts from YouTube videos and convert them into useful formats. ## Setup -Use `uv` so the dependency is installed into the same Hermes-managed environment -that runs the helper script: +Use `terminal` with the Python from a PM-prepared Hermes source checkout. The +`youtube` extra declares the helper's dependency; do not install packages into +Hermes with raw pip or project-discovering `uv run`. + +From that checkout, first follow the isolated development-home setup in +[Package Management](https://hermes-agent.nousresearch.com/docs/reference/package-management#developer-workflow), +then prepare the extra and reactivate before running the helper: ```bash -uv pip install youtube-transcript-api +source ./activate +python -c "import pm; pm.sync_venv(['youtube'], explicit=True)" +source ./activate +python -c "import youtube_transcript_api; print(youtube_transcript_api.__file__)" ``` +On Windows, use `. .\activate.ps1` instead of `source ./activate`. If the terminal +runs on a different host or in a sandbox, use an explicitly isolated helper +environment there, not the agent's production environment. Run every command +below with the interpreter whose import check succeeded. + ## Helper Script `SKILL_DIR` is the directory containing this SKILL.md file. The script accepts any standard YouTube URL format, short links (youtu.be), shorts, embeds, live links, or a raw 11-character video ID. ```bash # JSON output with metadata -uv run python SKILL_DIR/scripts/fetch_transcript.py "https://youtube.com/watch?v=VIDEO_ID" +python SKILL_DIR/scripts/fetch_transcript.py "https://youtube.com/watch?v=VIDEO_ID" # Plain text (good for piping into further processing) -uv run python SKILL_DIR/scripts/fetch_transcript.py "URL" --text-only +python SKILL_DIR/scripts/fetch_transcript.py "URL" --text-only # With timestamps -uv run python SKILL_DIR/scripts/fetch_transcript.py "URL" --timestamps +python SKILL_DIR/scripts/fetch_transcript.py "URL" --timestamps # Specific language with fallback chain -uv run python SKILL_DIR/scripts/fetch_transcript.py "URL" --language tr,en +python SKILL_DIR/scripts/fetch_transcript.py "URL" --language tr,en ``` ## Output Formats @@ -69,7 +82,7 @@ After fetching the transcript, format it based on what the user asks for: ## Workflow -1. **Fetch** the transcript using the helper script with `--text-only --timestamps` via `uv run python`. +1. **Fetch** the transcript using `terminal` and the prepared Python with `--text-only --timestamps`. 2. **Validate**: confirm the output is non-empty and in the expected language. If empty, retry without `--language` to get any available transcript. If still empty, tell the user the video likely has transcripts disabled. 3. **Chunk if needed**: if the transcript exceeds ~50K characters, split into overlapping chunks (~40K with 2K overlap) and summarize each chunk before merging. 4. **Transform** into the requested output format. If the user did not specify a format, default to a summary. @@ -80,4 +93,4 @@ After fetching the transcript, format it based on what the user asks for: - **Transcript disabled**: tell the user; suggest they check if subtitles are available on the video page. - **Private/unavailable video**: relay the error and ask the user to verify the URL. - **No matching language**: retry without `--language` to fetch any available transcript, then note the actual language to the user. -- **Dependency missing**: run `uv pip install youtube-transcript-api` and retry. +- **Dependency missing**: repeat PM preparation and reactivation above, then verify the helper uses that Python. Do not repair the selected generation with pip. diff --git a/skills/media/youtube-content/scripts/fetch_transcript.py b/skills/media/youtube-content/scripts/fetch_transcript.py index 73c305cee5..efcd3c9938 100644 --- a/skills/media/youtube-content/scripts/fetch_transcript.py +++ b/skills/media/youtube-content/scripts/fetch_transcript.py @@ -3,7 +3,7 @@ Fetch a YouTube video transcript and output it as structured JSON. Usage: - uv run python3 fetch_transcript.py [--language en,tr] [--timestamps] + python fetch_transcript.py [--language en,tr] [--timestamps] Output (JSON): { @@ -14,7 +14,7 @@ Output (JSON): "timestamped_text": "00:00 first line\n00:05 second line\n..." } -Install dependency: uv pip install youtube-transcript-api +Use the isolated helper interpreter prepared by this skill's PM setup recipe. """ import argparse @@ -56,7 +56,7 @@ def fetch_transcript(video_id: str, languages: list = None): try: from youtube_transcript_api import YouTubeTranscriptApi except ImportError: - print("Error: youtube-transcript-api not installed. Run: uv pip install youtube-transcript-api", + print("Error: youtube-transcript-api not installed. Use the isolated PM helper environment described in youtube-content/SKILL.md.", file=sys.stderr) sys.exit(1) diff --git a/skills/software-development/python-debugpy/SKILL.md b/skills/software-development/python-debugpy/SKILL.md index abf5494b1a..d27b5a8ab1 100644 --- a/skills/software-development/python-debugpy/SKILL.md +++ b/skills/software-development/python-debugpy/SKILL.md @@ -94,24 +94,20 @@ python -m pdb path/to/script.py arg1 arg2 ## Recipe 3: Debug a pytest test -The hermes test runner and pytest both support this: +Use `terminal` and the canonical runner for noninteractive diagnostics: ```bash -# Drop to pdb on failure (or on any raised exception): -scripts/run_tests.sh tests/path/to/test_file.py::test_name --pdb - -# Drop to pdb at the START of the test: -scripts/run_tests.sh tests/path/to/test_file.py::test_name --trace - # Show locals in tracebacks without pdb: scripts/run_tests.sh tests/path/to/test_file.py --showlocals --tb=long ``` -Note: `scripts/run_tests.sh` captures each test file in a separate subprocess through `scripts/run_tests_parallel.py`. Interactive pdb needs a terminal, so use direct pytest only for the interactive debugger: +`scripts/run_tests.sh` captures each file in a separate subprocess, so `--pdb` +or `--trace` cannot provide an interactive prompt there. For an interactive +debugger only, use the independent development/test interpreter prepared in +Recipe 5 (never a production generation): ```bash -source .venv/bin/activate -python -m pytest tests/foo_test.py::test_bar --pdb +.venv/bin/python -m pytest tests/foo_test.py::test_bar --pdb ``` This bypasses the hermetic-env guarantees — fine for debugging, but re-run under the wrapper to confirm before pushing. @@ -148,11 +144,26 @@ For long-lived processes: Hermes gateway, tui_gateway, a daemon, a process that' ### Setup +For Hermes, use a separate development checkout and data home, not a live +production generation. Follow the +[PM developer workflow](https://hermes-agent.nousresearch.com/docs/reference/package-management#developer-workflow) +first. The declared `dev` extra includes debugpy. Through `terminal`, build a +fresh, caller-owned debug/test environment with the prepared checkout's Python: + ```bash -source /.venv/bin/activate -pip install debugpy +python -m pm.build_env --source . --out .venv --extra dev --group test +deactivate +source .venv/bin/activate +python -c "import debugpy; print(debugpy.__file__)" ``` +The output must not already exist. Stop its processes and intentionally remove +only that disposable environment before rebuilding. Keep the same isolated +`HERMES_HOME` for the debug target. The activation above is for this explicitly +built debug environment, not a guessed application venv. Do not add debugpy to +a running production environment; reproduce there only with an already-prepared +debug target or arrange a restart in the development environment. + ### Pattern A: Source-edit — process waits for debugger at launch Add near the top of the entry point (or inside the function you want to debug): @@ -253,9 +264,12 @@ This is fine for one-off automation but painful as an interactive UX. **Option 3: Ditch DAP, use `remote-pdb`** — usually what you actually want from a terminal agent: -```bash -pip install remote-pdb -``` +For an independently owned Python project, declare `remote-pdb` in that +project's development dependencies and prepare its debug environment through +the project's package manager. This is not a Hermes SDK install recipe. For +Hermes, prefer the declared debugpy dependency; the remote-pdb examples below +require a separately declared, freshly built debug environment, never an +in-place pip install into the selected application generation. In your code: ```python @@ -277,7 +291,8 @@ nc 127.0.0.1 4444 See Recipe 3. The wrapper captures subprocess output, so run pytest directly for interactive pdb. ### `run_agent.py` / CLI — one-shot -Easiest: add `breakpoint()` near the suspect line, then run `hermes` normally. Control returns to your terminal at the pause point. +In the prepared debug checkout, add `breakpoint()` near the suspect line, then +run `python hermes`. Control returns to your terminal at the pause point. ### `tui_gateway` subprocess (spawned by `hermes --tui`) The gateway runs as a child of the Node TUI. Options: @@ -289,7 +304,7 @@ import debugpy debugpy.listen(("127.0.0.1", 5678)) debugpy.wait_for_client() ``` -Start `hermes --tui`. The TUI will appear frozen (its backend is waiting). Attach a client; execution resumes when you `continue`. +Start `python hermes --tui` from the prepared debug checkout. The TUI will appear frozen (its backend is waiting). Attach a client; execution resumes when you `continue`. Check the child's interpreter and imports before assuming it inherited the debug environment. **B. Use `remote-pdb` at a specific handler:** ```python @@ -329,7 +344,7 @@ Long-lived. Use `remote-pdb` at a handler, or `debugpy` with `--wait-for-client` ## Verification Checklist -- [ ] After `pip install debugpy`, confirm: `python -c "import debugpy; print(debugpy.__version__)"` +- [ ] In the independently built debug environment, confirm: `python -c "import debugpy; print(debugpy.__version__); print(debugpy.__file__)"` - [ ] For remote debug, confirm the port is actually listening: `ss -tlnp | grep 5678` - [ ] First breakpoint actually hits (if it doesn't, you likely have `PYTHONBREAKPOINT=0`, you're under a parallel/capturing runner, or execution finished before attach) - [ ] `where` / `w` shows the expected call stack diff --git a/tests-js/desktop-builder.test.mjs b/tests-js/desktop-builder.test.mjs index cc421abc38..ba93f804be 100644 --- a/tests-js/desktop-builder.test.mjs +++ b/tests-js/desktop-builder.test.mjs @@ -8,6 +8,20 @@ import { stageGetWindows, stageNodePtyInto } from '../apps/desktop/scripts/stage const repo = resolve(dirname(fileURLToPath(import.meta.url)), '..') const roots = [] + +test('desktop development composition reuses prepared icon pixels instead of provisioning them again', async () => { + const { buildSourceDesktop } = await import('../apps/desktop/scripts/build.mjs') + const input = fixture() + put(join(input.icons, 'apps/desktop/assets/icon.ico'), 'prepared packaging icon') + const commands = [] + buildSourceDesktop({ source: input.source, icons: input.icons, + generate: () => { throw new Error('prepared icons must not regenerate') }, + run: (command, args) => commands.push([command, ...args]), + }) + expect(readFileSync(join(input.source, 'apps/desktop/assets/icon.ico'), 'utf8')).toBe('prepared packaging icon') + const compile = commands.find(command => command.some(arg => arg.endsWith('/scripts/build/desktop.mjs'))) + expect(compile[compile.indexOf('--icons') + 1]).toBe(input.icons) +}) afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) }) function put(path, text) { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, text) } function fixture() { @@ -47,6 +61,20 @@ test('desktop compiler consumes explicit immutable inputs, replaces variants, an const input = fixture() const before = files(input.source) await buildDesktop(input) + const { productCurrent } = await import('../scripts/build/freshness.mjs') + expect(productCurrent({ ...input, product: 'desktop' })).toBe(true) + for (const file of [join(input.icons, 'apps/desktop/public/apple-touch-icon.png'), input.stamp, + join(input.nativeDeps, 'node-pty/package.json')]) { + const original = readFileSync(file) + put(file, 'changed prepared input') + expect(productCurrent({ ...input, product: 'desktop' }), file).toBe(false) + writeFileSync(file, original) + expect(productCurrent({ ...input, product: 'desktop' })).toBe(true) + } + put(join(input.source, 'apps/shared/src/client.ts'), 'export const shared = true') + expect(productCurrent({ ...input, product: 'desktop' })).toBe(false) + rmSync(join(input.source, 'apps/shared'), { recursive: true }) + expect(productCurrent({ ...input, product: 'desktop' })).toBe(true) expect(files(input.source)).toEqual(before) expect(readFileSync(join(input.out, 'apple-touch-icon.png'), 'utf8')).toBe('fresh icon') expect(existsSync(join(input.out, 'assets'))).toBe(true) @@ -69,6 +97,22 @@ test('desktop compiler consumes explicit immutable inputs, replaces variants, an expect(files(input.source).some(([name]) => name.includes('.vite') || name.endsWith('tsbuildinfo'))).toBe(false) }, 60000) +test('a prepared input changing during desktop compilation cannot publish a current receipt', async () => { + const { buildDesktop } = await import('../scripts/build/desktop.mjs') + const input = fixture() + await buildDesktop(input) + const previous = files(input.out) + const changedStamp = { ...JSON.parse(readFileSync(input.stamp, 'utf8')), commit: 'c'.repeat(40) } + put(join(input.source, 'apps/desktop/vite.config.mjs'), ` + import { writeFileSync } from 'node:fs'; + export default { plugins: [{ name: 'change-prepared-input', buildStart() { + writeFileSync(${JSON.stringify(input.stamp)}, ${JSON.stringify(JSON.stringify(changedStamp))}) + }}] } + `) + await expect(buildDesktop(input)).rejects.toThrow(/inputs changed/) + expect(files(input.out)).toEqual(previous) +}, 30000) + test('native preparation stages the selected source into an explicit tree before compilation', async () => { const { prepareDesktopNativeDependencies } = await import('../apps/desktop/scripts/stage-native-deps.mjs') const input = fixture() diff --git a/tests-js/generate-icons.test.mjs b/tests-js/generate-icons.test.mjs index 2e34054ba0..28ab64ef12 100644 --- a/tests-js/generate-icons.test.mjs +++ b/tests-js/generate-icons.test.mjs @@ -28,6 +28,12 @@ test('icon preparation passes explicit source and independent output roots', () expect(options.cwd).toBe(source) }) +test('on-demand icon preparation preserves admission intent at the PM boundary', () => { + const run = vi.fn(() => ({ status: 0 })) + expect(generateIcons(['--on-demand'], { run, env: {} })).toBe(0) + expect(run.mock.calls[0][1]).toContain('--on-demand') +}) + test('the PM driver owns a temporary group-only environment and preserves generator argv and status', () => { const result = spawnSync(process.env.HERMES_PYTHON || 'python', ['-c', ` from pathlib import Path @@ -42,12 +48,13 @@ with TemporaryDirectory() as directory: source.mkdir() argv = ['--source', str(source), '--out', str(Path(directory) / 'icon outputs'), '--check'] outputs = [] + explicit = True def build(**options): output = options.pop('out') assert output.parent.is_dir() and not output.exists() assert output.name == 'venv' assert options == dict(source=source, groups=['icon-build'], only_groups=True, - explicit=True, cache=source / '.cache/icon-build') + explicit=explicit, cache=source / '.cache/icon-build') outputs.append(output) return Path(sys.executable) def generate(command, **options): @@ -59,6 +66,9 @@ with TemporaryDirectory() as directory: assert icon_environment.main(argv) == 7 prepare.assert_called_once() run.assert_called_once() + explicit = False + outputs.clear() + assert icon_environment.main([*argv, '--on-demand']) == 7 assert not outputs[0].parent.exists() `], { cwd: fileURLToPath(new URL('..', import.meta.url)), encoding: 'utf8' }) expect(result.error).toBeUndefined() diff --git a/tests-js/node-deps.test.mjs b/tests-js/node-deps.test.mjs index f2c38d58af..8ad15d8132 100644 --- a/tests-js/node-deps.test.mjs +++ b/tests-js/node-deps.test.mjs @@ -32,6 +32,21 @@ function fixture() { return root } +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() + const options = { source, workspaces: ['web'], reuse: true, + env: { ...process.env, npm_config_offline: 'true', npm_config_cache: join(source, '.npm-cache') } } + expect(() => prepareNodeDependencies({ ...options, install: false })).toThrow(/disabled/) + expect(existsSync(join(source, 'node_modules'))).toBe(false) + prepareNodeDependencies(options) + const receipt = readFileSync(join(source, 'node_modules/.hermes-node-deps')) + prepareNodeDependencies({ ...options, install: false }) + rmSync(join(source, 'node_modules/web-only'), { recursive: true }) + expect(() => prepareNodeDependencies({ ...options, install: false })).toThrow(/disabled/) + expect(readFileSync(join(source, 'node_modules/.hermes-node-deps'))).toEqual(receipt) +}, 30000) + test('one locked preparation retains the requested union without provisioning desktop', async () => { const { prepareNodeDependencies } = await import('../scripts/build/node-deps.mjs') const source = fixture() diff --git a/tests-js/product-builders.test.mjs b/tests-js/product-builders.test.mjs index d7589130ef..1271bad697 100644 --- a/tests-js/product-builders.test.mjs +++ b/tests-js/product-builders.test.mjs @@ -210,10 +210,40 @@ test('TUI failure does not publish or disturb a previous product, and missing pr expect(existsSync(out)).toBe(false) dependency(source, '', 'esbuild') await buildTui({ source, out }) + const { productCurrent } = await import('../scripts/build/freshness.mjs') + expect(productCurrent({ source, product: 'tui', out: path.join(out, 'dist') })).toBe(true) const previous = readFileSync(path.join(out, 'dist/entry.js')) put(source, 'ui-tui/src/entry.tsx', 'const broken = ;') await expect(buildTui({ source, out })).rejects.toThrow() + expect(productCurrent({ source, product: 'tui', out: path.join(out, 'dist') })).toBe(false) expect(readFileSync(path.join(out, 'dist/entry.js'))).toEqual(previous) await expect(buildTui({ source, out: source })).rejects.toThrow(/output/i) expect(existsSync(path.join(source, 'ui-tui/src/entry.tsx'))).toBe(true) }) + +test('built web freshness follows shared sources and build inputs, not mtimes or generated trees', async () => { + const { productCurrent } = await import('../scripts/build/freshness.mjs') + const base = fixture() + const source = path.join(base, 'source') + const icons = path.join(base, 'icons') + const out = path.join(base, 'web') + webSource(source) + dependency(source, '', 'typescript') + dependency(source, '', 'vite') + put(icons, 'web/public/favicon.ico', 'icon') + put(source, 'apps/shared/src/client.ts', 'export const version = 1') + put(source, 'scripts/build/web.mjs', '// build input') + await buildWeb({ source, icons, out }) + expect(productCurrent({ source, product: 'web', out })).toBe(true) + put(source, 'web/node_modules/.tmp/tsbuildinfo', 'generated') + expect(productCurrent({ source, product: 'web', out })).toBe(true) + for (const input of ['apps/shared/src/client.ts', 'scripts/build/web.mjs', 'assets/icon.svg', 'package-lock.json']) { + put(source, input, 'changed input') + expect(productCurrent({ source, product: 'web', out }), input).toBe(false) + await buildWeb({ source, icons, out }) + expect(productCurrent({ source, product: 'web', out })).toBe(true) + } + expect(productCurrent({ source, product: 'desktop', out })).toBe(false) + rmSync(path.join(out, 'favicon.ico')) + expect(productCurrent({ source, product: 'web', out })).toBe(false) +}, 30_000) diff --git a/tests/acp/test_entry.py b/tests/acp/test_entry.py index e112a6eb6a..190514a88d 100644 --- a/tests/acp/test_entry.py +++ b/tests/acp/test_entry.py @@ -1,7 +1,5 @@ """Tests for acp_adapter.entry startup wiring.""" -import sys - import acp import pytest @@ -80,11 +78,24 @@ def test_main_setup_offers_browser_install_when_tty(monkeypatch): def test_main_setup_browser_propagates_browser_failure(monkeypatch): """If browser install fails, exit code is 1.""" - def fake_ensure(dep, interactive=True): - return dep != "browser" # browser fails + import pm - monkeypatch.setattr("hermes_cli.dep_ensure.ensure_dependency", fake_ensure) + def refuse(name, **kwargs): + raise pm.InstallError(name, "download failed") + + monkeypatch.setattr(pm, "ensure", refuse) with pytest.raises(SystemExit) as excinfo: entry.main(["--setup-browser"]) assert excinfo.value.code == 1 + + +def test_setup_browser_is_one_explicit_package_request(monkeypatch): + import pm + + calls = [] + monkeypatch.setattr(pm, "ensure", lambda name, **kwargs: calls.append((name, kwargs))) + + entry.main(["--setup-browser", "--yes"]) + + assert calls == [("agent-browser", {"explicit": True})] diff --git a/tests/agent/lsp/test_python_discovery.py b/tests/agent/lsp/test_python_discovery.py index 074f4f7991..5cd716e1ad 100644 --- a/tests/agent/lsp/test_python_discovery.py +++ b/tests/agent/lsp/test_python_discovery.py @@ -6,39 +6,76 @@ import subprocess import sys from pathlib import Path -from agent.lsp.servers import _detect_python, _pm_store_python +import pm +from pm import paths +from pm.lock import Facts, Lockfile +from pm.registry import get_package +from pm.store import current_target + +from agent.lsp.servers import _detect_python -def _seed_pm_python(tmp_path, monkeypatch, entry="python-3.11.13"): +def _seed_pm_python(tmp_path, monkeypatch): """Stage a pm bundled-install layout: HERMES_RUNTIME_DIR -> store with a manifest sibling (bundled), a python entry, and facts recording it.""" payload = tmp_path / "payload" store = payload / "tools" - python_entry = store / entry + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home")) + monkeypatch.setenv("HERMES_RUNTIME_DIR", str(store)) + lock = Lockfile(paths.lockfile_path()) + package = get_package("python") + target = current_target() + version = lock.version("python") + assert version is not None + python_entry = store / package.store_entry(version, target) python_entry.mkdir(parents=True) - exe = python_entry / ("python.exe" if sys.platform == "win32" else "bin/python3") + exe = package.binary(python_entry, target) + assert exe is not None exe.parent.mkdir(parents=True, exist_ok=True) exe.write_text("", encoding="utf-8") (payload / "manifest.json").write_text("{}", encoding="utf-8") - (store / "facts.json").write_text( - json.dumps( - { - "schema": 1, - "packages": {"python": {"entry": entry, "version": "3.11.13"}}, - } - ), - encoding="utf-8", + Facts(paths.facts_path()).record( + "python", version, python_entry.name, package.env(python_entry, target), store, + target=target, artifacts=[a["sha256"] for a in lock.artifacts("python", target)], ) - monkeypatch.setenv("HERMES_RUNTIME_DIR", str(store)) return exe +def test_pyright_respects_pm_writable_selection_over_old_bundle(tmp_path, monkeypatch): + monkeypatch.delenv("VIRTUAL_ENV", raising=False) + bundled = _seed_pm_python(tmp_path, monkeypatch) + bundled_facts = Facts(paths.facts_path()) + old = bundled_facts.get("python") + assert old is not None + store = paths.writable_store_root() + package = get_package("python") + target = current_target() + entry = store / old["entry"] + binary = package.binary(entry, target) + assert binary is not None + binary.parent.mkdir(parents=True) + binary.write_text("", encoding="utf-8") + Facts(store / "facts.json").record( + "python", old["version"], entry.name, package.env(entry, target), store, + target=target, artifacts=old["artifacts"], + ) + bundled_facts.record( + "python", "old-bundle", old["entry"], {}, paths.store_root(), + target=target, artifacts=old["artifacts"], + ) + before = paths.facts_path().read_bytes(), (store / "facts.json").read_bytes() + selected = pm.installed_package("python") + assert selected is not None and selected.binary == binary + assert _detect_python(str(tmp_path / "workspace")) == str(binary) + assert (paths.facts_path().read_bytes(), (store / "facts.json").read_bytes()) == before + assert bundled.is_file() + + def test_pm_store_python_resolved_without_virtual_env(tmp_path, monkeypatch): """No VIRTUAL_ENV anywhere — the pm store interpreter is the answer.""" monkeypatch.delenv("VIRTUAL_ENV", raising=False) exe = _seed_pm_python(tmp_path, monkeypatch) - assert _pm_store_python() == str(exe) assert _detect_python(str(tmp_path / "some-workspace")) == str(exe) @@ -74,7 +111,7 @@ def test_missing_store_entry_is_not_an_answer(tmp_path, monkeypatch): ) monkeypatch.setenv("HERMES_RUNTIME_DIR", str(store)) - assert _pm_store_python() is None + assert _detect_python(str(tmp_path / "some-workspace")) is None def test_pyright_uses_the_project_interpreter_before_hermes(tmp_path, monkeypatch): @@ -83,7 +120,7 @@ def test_pyright_uses_the_project_interpreter_before_hermes(tmp_path, monkeypatc project = tmp_path / "project" project.mkdir() monkeypatch.delenv("VIRTUAL_ENV", raising=False) - monkeypatch.setattr(servers, "_pm_store_python", lambda: sys.executable) + pm_python = _seed_pm_python(tmp_path, monkeypatch) context = servers.ServerContext( workspace_root=str(project), install_strategy="off", binary_overrides={"pyright": [sys.executable]}, @@ -91,7 +128,8 @@ def test_pyright_uses_the_project_interpreter_before_hermes(tmp_path, monkeypatc server = servers.find_server_for_file(str(project / "app.py")) assert server is not None spec = server.build_spawn(str(project), context) - assert spec.initialization_options["python"]["pythonPath"] == sys.executable + assert spec is not None + assert spec.initialization_options["python"]["pythonPath"] == str(pm_python) for environment in (project / ".venv", tmp_path / "explicit-environment"): subprocess.run( @@ -102,6 +140,7 @@ def test_pyright_uses_the_project_interpreter_before_hermes(tmp_path, monkeypatc if environment.name == "explicit-environment": monkeypatch.setenv("VIRTUAL_ENV", str(environment)) spec = server.build_spawn(str(project), context) + assert spec is not None selected = spec.initialization_options["python"]["pythonPath"] assert Path(selected) == python child = subprocess.run( diff --git a/tests/ci/test_plugin_validate_action.py b/tests/ci/test_plugin_validate_action.py new file mode 100644 index 0000000000..0e17e0e500 --- /dev/null +++ b/tests/ci/test_plugin_validate_action.py @@ -0,0 +1,38 @@ +"""The external action must never install Hermes into the plugin checkout.""" +import os +from pathlib import Path +import subprocess + +import pytest +import hermes_yaml as yaml + + +@pytest.mark.platforms("posix") +def test_external_validator_checkout_uses_requested_ref_and_preserves_caller(tmp_path): + repo = Path(__file__).resolve().parents[2] + action = yaml.safe_load((repo / ".github/actions/plugin-validate/action.yml").read_text()) + source_step = next(step for step in action["runs"]["steps"] if step.get("id") == "source") + ref = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=repo, text=True).strip() + caller = tmp_path / "plugin" + caller.mkdir() + manifest = caller / "pyproject.toml" + manifest.write_text('[project]\nname = "caller-plugin"\nversion = "1.0.0"\n') + before = manifest.read_bytes() + runner = tmp_path / "runner" + runner.mkdir() + output = tmp_path / "output" + env_file = tmp_path / "env" + env = {**os.environ, "RUNNER_TEMP": str(runner), "RUNNER_OS": "Linux", + "GITHUB_OUTPUT": str(output), "GITHUB_ENV": str(env_file), "_HERMES_REF": ref, + "GIT_CONFIG_COUNT": "1", "GIT_CONFIG_KEY_0": f"url.{repo.as_uri()}.insteadOf", + "GIT_CONFIG_VALUE_0": "https://github.com/NousResearch/hermes-agent.git"} + subprocess.run(["bash", "-c", source_step["run"]], cwd=caller, env=env, check=True, + capture_output=True, text=True, timeout=60) + outputs = dict(line.split("=", 1) for line in output.read_text().splitlines()) + source = Path(outputs["source"]) + assert source.is_relative_to(runner) + assert subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=source, text=True).strip() == ref + assert manifest.read_bytes() == before + assert sorted(path.name for path in caller.iterdir()) == ["pyproject.toml"] + assert "python-version" in outputs # the real setup-pm pin reader ran + assert not (source / ".build/validator").exists() # preparation is not installation diff --git a/tests/compat/old_updater_surface.json b/tests/compat/old_updater_surface.json index bd51a34c2c..d84313a3d2 100644 --- a/tests/compat/old_updater_surface.json +++ b/tests/compat/old_updater_surface.json @@ -1,5 +1,5 @@ { - "_comment": "Generated by scripts/audit-old-updater-imports.py --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 hand-trim. History enumeration is complete; static call-graph limits and unresolved_dynamic still require manual review.", + "_comment": "Generated by scripts/audit-old-updater-imports.py --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 hand-trim. History enumeration is complete; static call-graph limits and unresolved_dynamic still require manual review. Current-tree requirements refreshed with audit_tree and unioned without removing any frozen historical requirement; history_ref is unchanged.", "stats": { "mode": "union", "history": { @@ -333,7 +333,6 @@ "gateway/config_loader.py", "gateway/control_socket.py", "gateway/cwd_placeholder.py", - "gateway/display_config.py", "gateway/lifecycle_ledger.py", "gateway/platform_registry.py", "gateway/platforms/_shared.py", @@ -416,7 +415,6 @@ "hermes_cli/sqlite_safe_read.py", "hermes_cli/steward.py", "hermes_cli/subcommands/update.py", - "hermes_cli/tools_config.py", "hermes_cli/tools_config_cua.py", "hermes_cli/toolset_scope.py", "hermes_cli/toolset_validation.py", @@ -437,6 +435,7 @@ "hermes_cli/update_lock.py", "hermes_cli/update_receipt.py", "hermes_cli/urllib_security.py", + "hermes_cli/venv_sync.py", "hermes_cli/version_info.py", "hermes_constants.py", "hermes_state.py", @@ -476,6 +475,7 @@ "pm/update.py", "pm/workspace.py", "tools/computer_use/cua_backend.py", + "tools/computer_use/cua_backend_daemon.py", "tools/computer_use/cua_backend_driver.py", "tools/env_passthrough.py", "tools/environments/local.py", @@ -611,6 +611,7 @@ "hermes_cli/update_lock.py", "hermes_cli/update_receipt.py", "hermes_cli/urllib_security.py", + "hermes_cli/venv_sync.py", "hermes_cli/version_info.py", "hermes_constants.py", "plugins/memory/__init__.py", @@ -646,6 +647,7 @@ "pm/update.py", "pm/workspace.py", "tools/computer_use/cua_backend.py", + "tools/computer_use/cua_backend_daemon.py", "tools/computer_use/cua_backend_driver.py", "tools/env_passthrough.py", "tools/environments/local.py", @@ -662,7 +664,7 @@ "commits_with_audited_changes": 1, "revisions_read": 172, "distinct_file_versions": 172, - "analysis_passes": 216, + "analysis_passes": 214, "versions_prepared": 172, "mode": "tree" } @@ -681,6 +683,8 @@ "hermes_cli/plugins.py:_re_register_config_hooks_after_force: importlib.import_module(module_name)", "hermes_cli/plugins.py:_register_deferred_platform_tools: importlib.import_module(f'{module.__name__}.tools')", "hermes_cli/post_update.py: module object pm.ensure requires manual call-graph review", + "hermes_cli/tools_config_cua.py: module object pm.ensure requires manual call-graph review", + "hermes_cli/update_cmd_maint.py: module object pm.ensure requires manual call-graph review", "hermes_cli/update_cmd_maint.py:_reload_modules: importlib.reload(module)", "pm/client.py: module object pm.build_operations requires manual call-graph review", "pm/client.py: module object pm.operations requires manual call-graph review", @@ -739,9 +743,13 @@ "hermes_cli._early_recovery::recover_if_needed", "hermes_cli._install_repair::_sync_windows_cli_launchers", "hermes_cli._install_repair::migrate_windows_bin_path", + "hermes_cli._launchers::ENTRY_POINTS", + "hermes_cli._launchers::ensure_install_launchers", "hermes_cli._launchers::exe_is_venv_bound", + "hermes_cli._launchers::installation_command", "hermes_cli._launchers::mint_launcher", "hermes_cli._launchers::resolve_store_python", + "hermes_cli._launchers::runtime_command", "hermes_cli._launchers::stage_launcher", "hermes_cli._old_updater::stop_for_relaunch", "hermes_cli._parser::command_argv", @@ -985,6 +993,7 @@ "hermes_cli.steward::read_install_stamp", "hermes_cli.tools_config::_pip_install", "hermes_cli.tools_config::install_cua_driver", + "hermes_cli.tools_config_cua::install_cua_driver", "hermes_cli.update_channel::CHANNEL_STABLE", "hermes_cli.update_channel::handle_metadata_args", "hermes_cli.update_channel::resolve_update_channel", @@ -1084,6 +1093,7 @@ "hermes_cli.update_receipt::record_step", "hermes_cli.urllib_security::open_credentialed_url", "hermes_cli.urllib_security::url_origin", + "hermes_cli.venv_sync::publish_launchers", "hermes_cli.version_info::get_code_identity", "hermes_cli::__version__", "hermes_cli::_early_recovery", @@ -1197,7 +1207,9 @@ "pm::", "pm::InstallError", "pm::build_operations", + "pm::ensure", "pm::ensure_import", + "pm::installed_package", "pm::operations", "pm::paths", "pm::receipt", @@ -1206,6 +1218,9 @@ "tools.browser_tool_install::warm_agent_browser_npx_cache", "tools.computer_use.cua_backend::cua_driver_runtime_contract_status", "tools.computer_use.cua_backend::resolve_cua_driver_cmd", + "tools.computer_use.cua_backend::sanitized_cua_driver_env", + "tools.computer_use.cua_backend_daemon::_resolve_cua_driver_app_path", + "tools.computer_use.cua_backend_daemon::_validate_cua_driver_app_signature", "tools.computer_use.cua_backend_driver::cua_driver_runtime_contract_status", "tools.computer_use.cua_backend_driver::resolve_cua_driver_cmd", "tools.computer_use::cua_backend", @@ -1280,6 +1295,7 @@ "hermes_cli.config::_normalize_root_model_keys", "hermes_cli.config::_sanitize_env_lines", "hermes_cli.config::apply_terminal_config_to_env", + "hermes_cli.config::cfg_get", "hermes_cli.config::get_config_value", "hermes_cli.config::load_config_readonly", "hermes_cli.config::read_raw_config", diff --git a/tests/computer_use/test_cua_no_overlay.py b/tests/computer_use/test_cua_no_overlay.py index 61950644f2..56864a3e07 100644 --- a/tests/computer_use/test_cua_no_overlay.py +++ b/tests/computer_use/test_cua_no_overlay.py @@ -227,7 +227,10 @@ class TestMcpArgsOverlayFlag: class TestEmbeddedDaemonOverlayFlag: + @pytest.mark.platforms("not macos") def test_serve_process_disables_overlay_when_policy_requires_it(self): + from tools.computer_use import cua_backend_daemon + daemon = cua_backend._EmbeddedCuaDaemon("/usr/bin/cua-driver", "unrestricted") process = MagicMock() process.poll.return_value = None @@ -245,7 +248,7 @@ class TestEmbeddedDaemonOverlayFlag: cua_backend.subprocess, "Popen", return_value=process, ) as popen, patch.object( cua_backend.subprocess, "run", return_value=status, - ), patch.object(cua_backend.threading, "Thread"): + ), patch.object(cua_backend_daemon.threading, "Thread"): daemon.start() command = popen.call_args.args[0] diff --git a/tests/computer_use/test_cua_pm_selection.py b/tests/computer_use/test_cua_pm_selection.py new file mode 100644 index 0000000000..8e2343edd9 --- /dev/null +++ b/tests/computer_use/test_cua_pm_selection.py @@ -0,0 +1,208 @@ +"""CUA selection uses PM facts, never a second installer or stale vendor tree.""" + +import sys +from pathlib import Path + +import pytest + + +@pytest.fixture +def cua_home(tmp_path, monkeypatch): + from pm import paths + + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "tools")) + monkeypatch.delenv("HERMES_CUA_DRIVER_CMD", raising=False) + monkeypatch.setenv("HERMES_DISABLE_LAZY_INSTALLS", "1") + monkeypatch.setattr(paths, "lockfile_path", lambda: tmp_path / "lock.json") + return tmp_path + + +def _record_driver(version="0.20.0"): + from pm import Facts, Lockfile, current_target, get_package, paths + from pm.store import tree_digest + + package = get_package("cua-driver") + target = current_target() + root = paths.store_root() + entry = root / package.store_entry(version, target) + entry.mkdir(parents=True) + binary = package.binary(entry, target) + assert binary is not None + binary.parent.mkdir(parents=True, exist_ok=True) + binary.write_bytes(Path(sys.executable).read_bytes()) + binary.chmod(0o755) + artifact = {"url": "https://example.invalid/cua-fixture", "sha256": "a" * 64} + lock = Lockfile(paths.lockfile_path()) + lock.set_pin(package.name, version, {target: artifact}) + lock.save() + Facts(paths.facts_path()).record( + package.name, version, entry.name, package.env(entry, target), root, + target=target, artifacts=[artifact["sha256"]], digest=tree_digest(entry), + ) + return binary + + +def test_pm_selection_ignores_vendor_tree_and_is_passive(cua_home, monkeypatch): + import pm + from pm import paths + from tools.computer_use.cua_backend_driver import resolve_cua_driver_cmd + + def no_install(*args, **kwargs): + pytest.fail("passive CUA lookup attempted acquisition") + + monkeypatch.setattr(pm, "ensure", no_install) + binary = _record_driver() + legacy = cua_home / ".local" / "bin" / binary.name + legacy.parent.mkdir(parents=True) + legacy.write_bytes(binary.read_bytes()) + legacy.chmod(0o755) + monkeypatch.setenv("PATH", str(legacy.parent)) + before = paths.facts_path().read_bytes() + + assert resolve_cua_driver_cmd() == str(binary) + assert resolve_cua_driver_cmd(str(legacy)) == str(legacy) + assert resolve_cua_driver_cmd(str(cua_home / "missing")) is None + assert paths.facts_path().read_bytes() == before + lock = pm.Lockfile(paths.lockfile_path()) + lock.set_pin("cua-driver", "0.21.0", lock.pinned_artifacts("cua-driver")) + lock.save() + assert resolve_cua_driver_cmd() is None + previous = pm.installed_package("cua-driver", allow_outdated=True) + assert previous is not None and previous.binary == binary + binary.unlink() + assert resolve_cua_driver_cmd() is None + + +@pytest.mark.platforms("linux") +def test_setup_acquires_through_pm_and_validates_real_manifest(cua_home, monkeypatch): + import pm + from hermes_cli.tools_config_cua import install_cua_driver + from tools.computer_use.cua_backend_driver import ( + _CUA_DRIVER_RUNTIME_CONTRACT_ARGS, cua_driver_runtime_contract_status, + ) + + monkeypatch.setenv("PATH", "") + calls = [] + + def acquire(name, *, explicit): + calls.append((name, explicit)) + binary = _record_driver() + # A local process fixture, not an upstream CUA installation. + manifest = { + "binary_version": "0.20.0", + "mcp_invocation": {"command": str(binary), "args": ["mcp"]}, + "subcommands": [ + {"name": verb, "args": [{"name": arg} for arg in sorted(args)]} + for verb, args in _CUA_DRIVER_RUNTIME_CONTRACT_ARGS.items() + ], + } + binary.write_text( + f"#!{sys.executable}\nimport json, sys\n" + f"manifest = {manifest!r}\n" + "assert sys.argv[1:] == ['manifest']\n" + "print(json.dumps(manifest))\n", + encoding="utf-8", + ) + return pm.Runner(name, pm.env_for(name)) + + monkeypatch.setattr(pm, "ensure", acquire) + assert install_cua_driver(show_installer_progress=False) + assert calls == [("cua-driver", True)] + state = cua_driver_runtime_contract_status() + assert state["ready"], state + installed = pm.installed_package("cua-driver") + assert installed is not None + assert state["binary"] == str(installed.binary) + + +@pytest.mark.platforms("linux") +@pytest.mark.parametrize("permission_mode", ["bounded", "unrestricted"]) +@pytest.mark.parametrize("preinstalled", [False, True], ids=["cold", "previous-selection"]) +def test_private_start_uses_post_ensure_binary(cua_home, monkeypatch, permission_mode, preinstalled): + """Real PM lookup and manifest processes; only daemon/MCP serving is stubbed.""" + import subprocess + from types import SimpleNamespace + + import pm + from tools.computer_use import cua_backend as backend_module + from tools.computer_use.cua_backend_driver import _CUA_DRIVER_RUNTIME_CONTRACT_ARGS + + monkeypatch.setenv("PATH", "") + manifest_path = cua_home / "capabilities.yaml" + manifest_path.write_text("version: 3\n", encoding="utf-8") + monkeypatch.setattr(backend_module, "_computer_use_cfg", lambda: { + "capability_manifest": str(manifest_path), "no_overlay": False, + }) + if preinstalled: + previous = _record_driver() + previous.write_text(f"#!{sys.executable}\nprint('{{}}')\n", encoding="utf-8") + backend = backend_module.CuaDriverBackend(permission_mode=permission_mode) + assert backend._embedded_daemon is not None + versions = iter(["0.20.1", "0.20.2"]) + acquired = [] + + def acquire(name, **kwargs): + assert name == "cua-driver" and not kwargs.get("explicit", False) + version = next(versions) + binary = _record_driver(version) + manifest = { + "binary_version": version, + "mcp_invocation": {"command": str(binary), "args": ["mcp"]}, + "subcommands": [ + {"name": verb, "args": [{"name": arg} for arg in sorted(args)]} + for verb, args in _CUA_DRIVER_RUNTIME_CONTRACT_ARGS.items() + ], + } + binary.write_text( + f"#!{sys.executable}\nimport json, sys\n" + f"manifest = {manifest!r}\n" + "if sys.argv[1:] == ['manifest']:\n print(json.dumps(manifest))\n" + "else:\n assert sys.argv[1] in ('status', 'stop')\n", + encoding="utf-8", + ) + acquired.append(str(binary)) + return pm.Runner(name, pm.env_for(name)) + + monkeypatch.setattr(pm, "ensure", acquire) + monkeypatch.setattr(pm, "ensure_import", lambda name: None) + monkeypatch.setattr(backend._session, "start", lambda: None) + monkeypatch.setattr(backend._session, "call_tool", lambda *args: None) + spawn = subprocess.Popen + launches = [] + + def capture_serve(command, **kwargs): + if command[1] == "serve": + launches.append(command) + return SimpleNamespace(stderr=(), poll=lambda: None, wait=lambda **kw: 0) + return spawn(command, **kwargs) + + monkeypatch.setattr(subprocess, "Popen", capture_serve) + # Re-start the same backend after PM publishes another version as well. + for _ in range(2): + try: + backend.start() + command = launches[-1] + assert command[0] == acquired[-1] + assert command[command.index("--permission-mode") + 1] == permission_mode + assert command[command.index("--capability-manifest") + 1] == str(manifest_path) + assert "--approve-capability-manifest" in command + assert ("--dangerously-bypass-approvals" in command) == (permission_mode == "unrestricted") + proxy, args = backend._embedded_daemon.proxy_invocation() + assert proxy == acquired[-1] + assert args[args.index("--socket") + 1] == command[command.index("--socket") + 1] + finally: + backend.stop() + + +def test_runtime_cannot_bypass_pm_lazy_install_refusal(cua_home, monkeypatch): + from pm import InstallError + from tools.computer_use.cua_backend import CuaDriverBackend + + monkeypatch.setenv("PATH", "") + backend = CuaDriverBackend() + with pytest.raises(InstallError, match="lazy installs are disabled"): + backend.start() \ No newline at end of file diff --git a/tests/computer_use/test_cua_spawn_env_sanitization.py b/tests/computer_use/test_cua_spawn_env_sanitization.py index a975fdf7e0..8177cfd255 100644 --- a/tests/computer_use/test_cua_spawn_env_sanitization.py +++ b/tests/computer_use/test_cua_spawn_env_sanitization.py @@ -7,8 +7,7 @@ full parent environment (provider API keys included): - ``cua_backend_driver._resolve_mcp_invocation`` (``cua-driver manifest``) — no ``env=`` at all -- ``cua_backend_driver.cua_driver_update_check`` (``check-update --json``) — - telemetry env but no secret sanitization +- ``cua_backend_driver.cua_driver_runtime_contract_status`` (``manifest``) - ``doctor._drive_health_report`` (`` mcp``) — telemetry env only - ``permissions._run`` (every permission probe) — telemetry env only """ @@ -97,7 +96,7 @@ def test_resolve_mcp_invocation_sanitizes_env(monkeypatch): assert captured["creationflags"] == CREATE_NO_WINDOW -def test_update_check_sanitizes_env(monkeypatch): +def test_runtime_contract_check_sanitizes_env(monkeypatch): monkeypatch.setenv("ANTHROPIC_API_KEY", SECRET) monkeypatch.setenv("PATH", "/usr/bin:/bin") monkeypatch.delenv("HERMES_CUA_TELEMETRY", raising=False) @@ -107,21 +106,11 @@ def test_update_check_sanitizes_env(monkeypatch): captured = {} _patch_windows_hide_flags(monkeypatch, cua_backend) - payload = json.dumps({ - "current_version": "1.0.0", - "latest_version": "1.0.0", - "update_available": False, - }) - # PATH is pinned to /usr/bin:/bin above, so the driver won't resolve; - # pin it so the check reaches the (sanitized) subprocess spawn. monkeypatch.setattr( - cua_backend_driver, "resolve_cua_driver_cmd", lambda *a, **k: "cua-driver" - ) - monkeypatch.setattr( - cua_backend.subprocess, "run", _capture_run(captured, stdout=payload) + cua_backend.subprocess, "run", _capture_run(captured, stdout="{}") ) - cua_backend_driver.cua_driver_update_check(timeout=1.0) + cua_backend_driver.cua_driver_runtime_contract_status("cua-driver") _assert_sanitized(captured) assert captured["creationflags"] == CREATE_NO_WINDOW diff --git a/tests/computer_use/test_doctor.py b/tests/computer_use/test_doctor.py index ad7c699c50..2798ca25c8 100644 --- a/tests/computer_use/test_doctor.py +++ b/tests/computer_use/test_doctor.py @@ -22,6 +22,7 @@ from __future__ import annotations import json import sys from io import StringIO +from pathlib import Path from unittest.mock import MagicMock, patch import pytest @@ -81,12 +82,40 @@ def _degraded_report() -> dict: +@pytest.fixture(autouse=True) +def pm_driver(tmp_path, monkeypatch): + """Exercise real passive PM selection; native driver probes are mocked below.""" + import pm + from pm import paths + from pm.store import tree_digest + + monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "tools")) + monkeypatch.delenv("HERMES_CUA_DRIVER_CMD", raising=False) + monkeypatch.setenv("HERMES_DISABLE_LAZY_INSTALLS", "1") + monkeypatch.setattr(pm, "ensure", MagicMock(side_effect=AssertionError("doctor must not install"))) + package, target = pm.get_package("cua-driver"), pm.current_target() + lock = pm.Lockfile(paths.lockfile_path()) + version = lock.version(package.name) + assert version is not None + root = paths.store_root() + entry = root / package.store_entry(version, target) + binary = package.binary(entry, target) + assert binary is not None + binary.parent.mkdir(parents=True) + binary.write_bytes(Path(sys.executable).read_bytes()) + binary.chmod(0o755) + pm.Facts(paths.facts_path()).record( + package.name, version, entry.name, package.env(entry, target), root, + target=target, artifacts=[a["sha256"] for a in lock.artifacts(package.name, target)], + digest=tree_digest(entry), + ) + return binary + + @pytest.fixture(autouse=True) def _default_cli_version_matches_report(monkeypatch): - """Existing tests mock only the MCP Popen handshake. ``subprocess.run`` - (used for ``--version``) goes through Popen too, so without this the - mock breaks version probing. Default to a CLI version that matches - ``_ok_report`` / ``_degraded_report`` (0.5.8); identity tests override. + """Keep CLI identity probing off the native host, like the mocked MCP + process. Identity tests override the version independently of the report. """ from tools.computer_use import doctor @@ -108,8 +137,7 @@ class TestDoctorExitCodes: {"jsonrpc": "2.0", "id": 1, "result": {}}, {"jsonrpc": "2.0", "id": 2, "result": {"structuredContent": _ok_report()}}, ) - with patch("shutil.which", return_value="/fake/cua-driver"), \ - patch("subprocess.Popen", return_value=proc), \ + with patch.object(doctor, "_open_mcp", return_value=proc), \ patch("sys.stdout", new_callable=StringIO): code = doctor.run_doctor() assert code == 0 @@ -121,8 +149,7 @@ class TestDoctorExitCodes: {"jsonrpc": "2.0", "id": 1, "result": {}}, {"jsonrpc": "2.0", "id": 2, "result": {"structuredContent": _degraded_report()}}, ) - with patch("shutil.which", return_value="/fake/cua-driver"), \ - patch("subprocess.Popen", return_value=proc), \ + with patch.object(doctor, "_open_mcp", return_value=proc), \ patch("sys.stdout", new_callable=StringIO): code = doctor.run_doctor() assert code == 1 @@ -138,8 +165,7 @@ class TestDoctorExitCodes: {"jsonrpc": "2.0", "id": 1, "result": {}}, {"jsonrpc": "2.0", "id": 2, "result": {"structuredContent": report}}, ) - with patch("shutil.which", return_value="/fake/cua-driver"), \ - patch("subprocess.Popen", return_value=proc), \ + with patch.object(doctor, "_open_mcp", return_value=proc), \ patch("sys.stdout", new_callable=StringIO): code = doctor.run_doctor() assert code == 1 @@ -159,8 +185,7 @@ class TestDoctorExitCodes: proc.wait = MagicMock(return_value=0) proc.kill = MagicMock() - with patch("shutil.which", return_value="/fake/cua-driver"), \ - patch("subprocess.Popen", return_value=proc): + with patch.object(doctor, "_open_mcp", return_value=proc): code = doctor.run_doctor() assert code == 2 # stderr should mention the failure @@ -179,8 +204,7 @@ class TestResponseShapeParsing: {"jsonrpc": "2.0", "id": 1, "result": {}}, {"jsonrpc": "2.0", "id": 2, "result": {"structuredContent": _ok_report()}}, ) - with patch("shutil.which", return_value="/fake/cua-driver"), \ - patch("subprocess.Popen", return_value=proc), \ + with patch.object(doctor, "_open_mcp", return_value=proc), \ patch("sys.stdout", new_callable=StringIO) as out: doctor.run_doctor() # Header line includes driver version + platform + overall. @@ -196,8 +220,7 @@ class TestResponseShapeParsing: {"jsonrpc": "2.0", "id": 1, "result": {}}, {"jsonrpc": "2.0", "id": 2, "error": {"code": -32601, "message": "method not found"}}, ) - with patch("shutil.which", return_value="/fake/cua-driver"), \ - patch("subprocess.Popen", return_value=proc): + with patch.object(doctor, "_open_mcp", return_value=proc): code = doctor.run_doctor() assert code == 2 assert "method not found" in capsys.readouterr().err @@ -214,8 +237,7 @@ class TestArgPassthrough: {"jsonrpc": "2.0", "id": 1, "result": {}}, {"jsonrpc": "2.0", "id": 2, "result": {"structuredContent": _ok_report()}}, ) - with patch("shutil.which", return_value="/fake/cua-driver"), \ - patch("subprocess.Popen", return_value=proc), \ + with patch.object(doctor, "_open_mcp", return_value=proc), \ patch("sys.stdout", new_callable=StringIO): doctor.run_doctor(include=["binary_version", "tcc_accessibility"]) @@ -233,8 +255,7 @@ class TestArgPassthrough: {"jsonrpc": "2.0", "id": 1, "result": {}}, {"jsonrpc": "2.0", "id": 2, "result": {"structuredContent": _ok_report()}}, ) - with patch("shutil.which", return_value="/fake/cua-driver"), \ - patch("subprocess.Popen", return_value=proc), \ + with patch.object(doctor, "_open_mcp", return_value=proc), \ patch("sys.stdout", new_callable=StringIO): doctor.run_doctor(skip=["bundle_identity"]) writes = [call.args[0] for call in proc.stdin.write.call_args_list] @@ -254,8 +275,7 @@ class TestJsonOutput: {"jsonrpc": "2.0", "id": 1, "result": {}}, {"jsonrpc": "2.0", "id": 2, "result": {"structuredContent": _ok_report()}}, ) - with patch("shutil.which", return_value="/fake/cua-driver"), \ - patch("subprocess.Popen", return_value=proc), \ + with patch.object(doctor, "_open_mcp", return_value=proc), \ patch("sys.stdout", new_callable=StringIO) as out: doctor.run_doctor(json_output=True) # Verify the captured text round-trips through json.loads. Upstream @@ -272,56 +292,72 @@ class TestJsonOutput: class TestDriverCmdResolution: - def test_explicit_driver_cmd_arg_wins(self): + def test_explicit_driver_cmd_arg_wins(self, pm_driver, tmp_path, monkeypatch): from tools.computer_use import doctor + explicit = tmp_path / pm_driver.name + explicit.write_bytes(pm_driver.read_bytes()) + explicit.chmod(0o755) + monkeypatch.setenv("HERMES_CUA_DRIVER_CMD", str(pm_driver)) proc = _fake_proc_with_responses( {"jsonrpc": "2.0", "id": 1, "result": {}}, {"jsonrpc": "2.0", "id": 2, "result": {"structuredContent": _ok_report()}}, ) - with patch("shutil.which", return_value="/fake/explicit-binary") as which_mock, \ - patch("subprocess.Popen", return_value=proc), \ + with patch.object(doctor, "_open_mcp", return_value=proc) as spawn, \ patch("sys.stdout", new_callable=StringIO): - doctor.run_doctor(driver_cmd="/custom/path/cua-driver") - # shutil.which should have been called with the explicit arg, not - # the env-var / default resolver. - which_mock.assert_called_with("/custom/path/cua-driver") + assert doctor.run_doctor(driver_cmd=str(explicit)) == 0 + spawn.assert_called_once_with(str(explicit)) - def test_env_var_used_when_no_arg_given(self, monkeypatch): + def test_env_var_used_when_no_arg_given(self, pm_driver, tmp_path, monkeypatch): from tools.computer_use import doctor - monkeypatch.setenv("HERMES_CUA_DRIVER_CMD", "/env/path/cua-driver") + external = tmp_path / pm_driver.name + external.write_bytes(pm_driver.read_bytes()) + external.chmod(0o755) + monkeypatch.setenv("HERMES_CUA_DRIVER_CMD", str(external)) proc = _fake_proc_with_responses( {"jsonrpc": "2.0", "id": 1, "result": {}}, {"jsonrpc": "2.0", "id": 2, "result": {"structuredContent": _ok_report()}}, ) - with patch("shutil.which", return_value="/env/path/cua-driver") as which_mock, \ - patch("subprocess.Popen", return_value=proc), \ - patch("sys.stdout", new_callable=StringIO), \ - patch("hermes_cli.tools_config._cua_driver_cmd", side_effect=Exception("force env")): - # Force env-var resolution path inside run_doctor. - doctor.run_doctor() - which_mock.assert_called_with("/env/path/cua-driver") + with patch.object(doctor, "_open_mcp", return_value=proc) as spawn, \ + patch("sys.stdout", new_callable=StringIO): + assert doctor.run_doctor() == 0 + spawn.assert_called_once_with(str(external)) - @pytest.mark.skipif(sys.platform == "win32", reason="POSIX user-local path regression") - def test_user_local_driver_is_found_when_path_omits_it(self, tmp_path, monkeypatch): - """Doctor must inspect the same user-local driver as the runtime.""" + def test_pm_driver_is_found_when_path_omits_it(self, pm_driver, monkeypatch): + """Doctor inspects PM's selection without installing or changing its facts.""" + from pm import paths from tools.computer_use import doctor - driver = tmp_path / ".local" / "bin" / "cua-driver" - driver.parent.mkdir(parents=True) - driver.write_text("#!/bin/sh\nexit 0\n") - driver.chmod(0o755) - - monkeypatch.delenv("HERMES_CUA_DRIVER_CMD", raising=False) - monkeypatch.setenv("HOME", str(tmp_path)) - monkeypatch.setenv("PATH", "/usr/bin:/bin:/usr/sbin:/sbin") - - with patch("tools.computer_use.doctor._drive_health_report", return_value=_ok_report()) as health, \ + monkeypatch.setenv("PATH", "") + before = paths.facts_path().read_bytes() + with patch.object(doctor, "_drive_health_report", return_value=_ok_report()) as health, \ patch("sys.stdout", new_callable=StringIO): assert doctor.run_doctor() == 0 - health.assert_called_once_with(str(driver), include=(), skip=(), timeout=12.0) + health.assert_called_once_with(str(pm_driver), include=(), skip=(), timeout=12.0) + assert paths.facts_path().read_bytes() == before + + @pytest.mark.parametrize("use_env", [False, True], ids=["argument", "environment"]) + def test_missing_override_does_not_fall_back_to_pm(self, tmp_path, monkeypatch, use_env, capsys): + from tools.computer_use import doctor + + missing = str(tmp_path / "missing-driver") + if use_env: + monkeypatch.setenv("HERMES_CUA_DRIVER_CMD", missing) + with patch.object(doctor, "_open_mcp") as spawn: + assert doctor.run_doctor(driver_cmd=None if use_env else missing) == 2 + spawn.assert_not_called() + assert "not installed" in capsys.readouterr().out + + def test_missing_pm_binary_exits_2(self, pm_driver, capsys): + from tools.computer_use import doctor + + pm_driver.unlink() + with patch.object(doctor, "_open_mcp") as spawn: + assert doctor.run_doctor() == 2 + spawn.assert_not_called() + assert "hermes computer-use install" in capsys.readouterr().out # ── cua-driver 0.10 unclassified health_report fallback ──────────────────── @@ -401,8 +437,7 @@ class TestDoctorVersionIdentity: {"jsonrpc": "2.0", "id": 2, "result": {"structuredContent": _ok_report()}}, ) # _ok_report claims 0.5.8; CLI says 0.12.6 - with patch("shutil.which", return_value="/fake/cua-driver"), \ - patch("subprocess.Popen", return_value=proc), \ + with patch.object(doctor, "_open_mcp", return_value=proc), \ patch.object(doctor, "_read_cli_version", return_value="cua-driver 0.12.6"), \ patch("sys.stdout", new_callable=StringIO) as out: code = doctor.run_doctor() @@ -420,8 +455,7 @@ class TestDoctorVersionIdentity: {"jsonrpc": "2.0", "id": 1, "result": {}}, {"jsonrpc": "2.0", "id": 2, "result": {"structuredContent": _ok_report()}}, ) - with patch("shutil.which", return_value="/fake/cua-driver"), \ - patch("subprocess.Popen", return_value=proc), \ + with patch.object(doctor, "_open_mcp", return_value=proc), \ patch.object(doctor, "_read_cli_version", return_value="cua-driver 0.5.8"), \ patch("sys.stdout", new_callable=StringIO) as out: code = doctor.run_doctor(json_output=True) diff --git a/tests/computer_use/test_permissions_resolution.py b/tests/computer_use/test_permissions_resolution.py index 932ee107c8..c1cd2809d1 100644 --- a/tests/computer_use/test_permissions_resolution.py +++ b/tests/computer_use/test_permissions_resolution.py @@ -1,32 +1,88 @@ -"""Regression tests for Computer Use readiness under a thin GUI PATH.""" +"""Computer Use readiness resolves PM state even under a thin GUI PATH.""" from __future__ import annotations +import json +import subprocess import sys +from pathlib import Path from unittest.mock import MagicMock, patch import pytest -@pytest.mark.skipif(sys.platform == "win32", reason="POSIX user-local path regression") -def test_status_finds_user_local_driver_when_path_omits_it(tmp_path, monkeypatch): - """Desktop status must agree with the runtime resolver, not bare PATH.""" +@pytest.fixture +def pm_driver(tmp_path, monkeypatch): + """Publish temp PM facts; no acquisition or native permission changes.""" + import pm + from pm import paths + from pm.store import tree_digest + + monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "tools")) + monkeypatch.delenv("HERMES_CUA_DRIVER_CMD", raising=False) + monkeypatch.setenv("HERMES_DISABLE_LAZY_INSTALLS", "1") + monkeypatch.setattr(pm, "ensure", MagicMock(side_effect=AssertionError("status must not install"))) + package, target = pm.get_package("cua-driver"), pm.current_target() + lock = pm.Lockfile(paths.lockfile_path()) + version = lock.version(package.name) + assert version is not None + root = paths.store_root() + entry = root / package.store_entry(version, target) + binary = package.binary(entry, target) + assert binary is not None + binary.parent.mkdir(parents=True) + binary.write_bytes(Path(sys.executable).read_bytes()) + binary.chmod(0o755) + pm.Facts(paths.facts_path()).record( + package.name, version, entry.name, package.env(entry, target), root, + target=target, artifacts=[a["sha256"] for a in lock.artifacts(package.name, target)], + digest=tree_digest(entry), + ) + return binary + + +@pytest.mark.platforms("linux", "macos", "windows") +def test_status_finds_pm_driver_when_path_omits_it(pm_driver, monkeypatch): + """Desktop status probes the same selected binary as the runtime, without installing.""" + from pm import paths from tools.computer_use import permissions - driver = tmp_path / ".local" / "bin" / "cua-driver" - driver.parent.mkdir(parents=True) - driver.write_text("#!/bin/sh\nexit 0\n") - driver.chmod(0o755) + monkeypatch.setenv("PATH", "") + before = paths.facts_path().read_bytes() + check = {"label": "Driver", "status": "pass", "message": "Driver is healthy"} + outputs = { + ("--version",): "cua-driver fixture", + ("doctor", "--json"): json.dumps({"ok": True, "probes": [check]}), + ("permissions", "status", "--json"): json.dumps({ + "accessibility": True, "screen_recording": True, + "screen_recording_capturable": True, + }), + } - monkeypatch.delenv("HERMES_CUA_DRIVER_CMD", raising=False) - monkeypatch.setenv("HOME", str(tmp_path)) - monkeypatch.setenv("PATH", "/usr/bin:/bin:/usr/sbin:/sbin") + def run(binary, *args, timeout): + assert binary == str(pm_driver) + return subprocess.CompletedProcess([binary, *args], 0, stdout=outputs[args], stderr="") - # No platform faking: ``~/.local/bin/cua-driver`` is a POSIX resolution - # candidate on Linux exactly as on macOS, so the regression reproduces on - # the host we actually run on. - with patch.object(permissions, "_run", return_value=MagicMock(stdout="0.0.0")), \ - patch.object(permissions, "_doctor", return_value={"ok": True, "checks": []}): + with patch.object(permissions, "_run", side_effect=run): status = permissions.computer_use_status() assert status["installed"] is True + assert status["version"] == outputs[("--version",)] + assert status["checks"] == [check] + assert status["ready"] is True + assert status["error"] is None + assert paths.facts_path().read_bytes() == before + + +def test_status_missing_pm_binary_is_unknown(pm_driver): + from tools.computer_use import permissions + + pm_driver.unlink() + with patch.object(permissions, "_run") as run: + status = permissions.computer_use_status() + + run.assert_not_called() + assert status["installed"] is False + assert status["ready"] is None + assert status["version"] is None + assert status["checks"] == [] diff --git a/tests/cron/test_cron_script.py b/tests/cron/test_cron_script.py index 8802975f3f..64ba5d85a9 100644 --- a/tests/cron/test_cron_script.py +++ b/tests/cron/test_cron_script.py @@ -170,6 +170,17 @@ class TestRunJobScript: encoding="utf-8", ) monkeypatch.setenv("HERMES_RUNTIME_DIR", str(store)) + # The supplied payload launcher is older than the committed extension + # generation. It must not override the installation's current selection. + from hermes_cli.runtime_paths import install_state_dir, site_packages as dependency_site + repo = Path(sched_script.__file__).resolve().parents[1] + state = install_state_dir(repo) + selected = state / "environments" / "selected" / "venv" + site_packages = dependency_site(selected) + site_packages.mkdir(parents=True) + (selected / "pyvenv.cfg").write_text("home = fixture\n", encoding="utf-8") + (state / "facts.json").write_text( + json.dumps({"packages": {"venv": {"environment": str(selected)}}}), encoding="utf-8") # No ambient VIRTUAL_ENV anywhere: resolution must come from pm. monkeypatch.delenv("VIRTUAL_ENV", raising=False) diff --git a/tests/hermes_cli/test_boot_bootstrap.py b/tests/hermes_cli/test_boot_bootstrap.py index b7f0cb0b8b..699939217f 100644 --- a/tests/hermes_cli/test_boot_bootstrap.py +++ b/tests/hermes_cli/test_boot_bootstrap.py @@ -62,6 +62,25 @@ def test_read_git_head_branch_ref(repo): assert read_git_head(repo) == _head_sha(repo) +def test_git_selection_uses_pm_public_package_reader(repo, monkeypatch): + from types import SimpleNamespace + import pm + + command = shutil.which("git") + assert command is not None + binary = Path(command) + requests = [] + + def installed(name): + requests.append(name) + return SimpleNamespace(binary=binary) + + monkeypatch.setattr(pm, "installed_package", installed) + + assert read_git_head(repo) == _head_sha(repo) + assert requests == ["git"] + + def test_read_git_head_detached(repo): sha = _head_sha(repo) _git(["checkout", "--detach", sha], repo) diff --git a/tests/hermes_cli/test_bundled_desktop_launch.py b/tests/hermes_cli/test_bundled_desktop_launch.py index 91a3c0ab4b..0e63cc923a 100644 --- a/tests/hermes_cli/test_bundled_desktop_launch.py +++ b/tests/hermes_cli/test_bundled_desktop_launch.py @@ -242,9 +242,8 @@ class TestCmdGuiOnABundle: from hermes_cli import main_desktop, source_build monkeypatch.setattr(cli_main, "PROJECT_ROOT", repo) - monkeypatch.setattr(source_build, "source_build_env", lambda env: dict(env)) + monkeypatch.setattr(source_build, "source_build_env", lambda env, **kwargs: dict(env)) monkeypatch.setattr(main_desktop, "_desktop_build_needed", lambda *a, **k: True) - monkeypatch.setattr(main_desktop, "_write_desktop_build_stamp", lambda *a, **k: None) monkeypatch.setattr(main_desktop, "_stop_desktop_processes_locking_build", lambda *a, **k: []) monkeypatch.setattr(main_desktop, "_desktop_linux_sandbox_fixup", lambda *a, **k: launcher_ok) monkeypatch.setattr(main_desktop, "_desktop_linux_needs_no_sandbox", lambda: not launcher_ok) diff --git a/tests/hermes_cli/test_computer_use_cli.py b/tests/hermes_cli/test_computer_use_cli.py index a3e17aade1..5f3fceca03 100644 --- a/tests/hermes_cli/test_computer_use_cli.py +++ b/tests/hermes_cli/test_computer_use_cli.py @@ -1,7 +1,5 @@ """CLI coverage for the public Computer Use command surface.""" -from __future__ import annotations - import subprocess import sys from importlib import import_module @@ -15,14 +13,11 @@ from tools.computer_use import cua_backend_driver def _run(*args: str) -> subprocess.CompletedProcess[str]: return subprocess.run( [sys.executable, "-m", "hermes_cli.main", "computer-use", *args], - capture_output=True, - text=True, - timeout=30, + capture_output=True, text=True, timeout=30, ) -def _invoke(monkeypatch: pytest.MonkeyPatch, *args: str) -> int: - """Run the in-process CLI and normalize its process-style exit status.""" +def _invoke(monkeypatch, *args: str) -> int: cli_main = import_module("hermes_cli.main") monkeypatch.setattr(sys, "argv", ["hermes", "computer-use", *args]) monkeypatch.setattr(cli_main, "_prepare_agent_startup", lambda _args: None) @@ -33,161 +28,68 @@ def _invoke(monkeypatch: pytest.MonkeyPatch, *args: str) -> int: return 0 -def test_computer_use_help_omits_browser_approve() -> None: +def test_computer_use_help_omits_browser_approve(): result = _run("--help") - assert result.returncode == 0 assert "browser-approve" not in result.stdout assert "doctor" in result.stdout assert "permissions" in result.stdout -def test_computer_use_rejects_removed_browser_approve_command() -> None: +def test_computer_use_rejects_removed_browser_approve_command(): result = _run("browser-approve", "--pid", "123") - assert result.returncode == 2 assert "invalid choice: 'browser-approve'" in result.stderr -def test_computer_use_status_returns_zero_for_compatible_driver( - monkeypatch: pytest.MonkeyPatch, -) -> None: - from hermes_cli import tools_config - import hermes_cli.tools_config_cua as tools_config_cua +def test_computer_use_status_reports_pm_without_polling_vendor(monkeypatch, capsys, tmp_path): + from hermes_cli import tools_config_cua as cua - driver = r"C:\Users\tester\.local\bin\cua-driver.exe" monkeypatch.delenv("HERMES_CUA_DRIVER_CMD", raising=False) - monkeypatch.setattr(cua_backend_driver, "resolve_cua_driver_cmd", lambda: driver) - monkeypatch.setattr( - tools_config, - "_cua_driver_contract_status", - lambda _binary=None: {"ready": True}, - ) - monkeypatch.setattr( - tools_config_cua, - "_cua_driver_contract_status", - lambda _binary=None: {"ready": True}, - ) - monkeypatch.setattr( - cua_backend_driver, - "cua_driver_update_check", - lambda: {"update_available": False}, - ) - + monkeypatch.setattr(cua_backend_driver, "resolve_cua_driver_cmd", lambda: str(tmp_path / "cua-driver")) + monkeypatch.setattr(cua, "_cua_driver_contract_status", lambda _binary=None: {"ready": True, "version": "0.20.0"}) assert _invoke(monkeypatch, "status") == 0 + output = capsys.readouterr().out + assert "Hermes PM" in output + assert "latest" not in output -def test_computer_use_status_returns_nonzero_when_driver_is_missing( - monkeypatch: pytest.MonkeyPatch, - capsys: pytest.CaptureFixture[str], -) -> None: - +def test_computer_use_status_returns_nonzero_when_driver_is_missing(monkeypatch, capsys): monkeypatch.setattr(cua_backend_driver, "resolve_cua_driver_cmd", lambda: None) - assert _invoke(monkeypatch, "status") == 1 assert "cua-driver: not installed" in capsys.readouterr().out -def test_computer_use_status_returns_nonzero_for_incompatible_standard_driver( - monkeypatch: pytest.MonkeyPatch, - capsys: pytest.CaptureFixture[str], -) -> None: - from hermes_cli import tools_config - import hermes_cli.tools_config_cua as tools_config_cua +@pytest.mark.parametrize("override", [False, True]) +def test_computer_use_status_reports_unusable_driver(monkeypatch, capsys, tmp_path, override): + from hermes_cli import tools_config_cua as cua - driver = r"C:\Users\tester\.local\bin\cua-driver.exe" - monkeypatch.delenv("HERMES_CUA_DRIVER_CMD", raising=False) + driver = str(tmp_path / "cua-driver") + if override: + monkeypatch.setenv("HERMES_CUA_DRIVER_CMD", driver) + else: + monkeypatch.delenv("HERMES_CUA_DRIVER_CMD", raising=False) monkeypatch.setattr(cua_backend_driver, "resolve_cua_driver_cmd", lambda: driver) - monkeypatch.setattr( - tools_config, - "_cua_driver_contract_status", - lambda _binary=None: { - "ready": False, - "reason": "required runtime features are missing", - }, - ) - monkeypatch.setattr( - tools_config_cua, - "_cua_driver_contract_status", - lambda _binary=None: { - "ready": False, - "reason": "required runtime features are missing", - }, - ) - + monkeypatch.setattr(cua, "_cua_driver_contract_status", lambda _binary=None: { + "ready": False, "reason": "manifest is invalid", + }) assert _invoke(monkeypatch, "status") == 1 output = capsys.readouterr().out assert "Repair required" in output - assert "Run: hermes computer-use install" in output + if override: + assert "custom binary from HERMES_CUA_DRIVER_CMD" in output + assert "unset the override" in output + else: + assert "Run: hermes computer-use install" in output -def test_computer_use_status_returns_nonzero_for_incompatible_custom_driver( - monkeypatch: pytest.MonkeyPatch, - capsys: pytest.CaptureFixture[str], -) -> None: - from hermes_cli import tools_config - import hermes_cli.tools_config_cua as tools_config_cua +@pytest.mark.parametrize("ready", [False, True]) +@pytest.mark.parametrize("upgrade", [False, True]) +def test_computer_use_install_propagates_setup_result(monkeypatch, ready, upgrade): + from hermes_cli import tools_config_cua as cua - driver = r"C:\custom\cmd.exe" - monkeypatch.setenv("HERMES_CUA_DRIVER_CMD", driver) - monkeypatch.setattr(cua_backend_driver, "resolve_cua_driver_cmd", lambda: driver) - monkeypatch.setattr( - tools_config, - "_cua_driver_contract_status", - lambda _binary=None: {"ready": False, "reason": "manifest is invalid"}, - ) - monkeypatch.setattr( - tools_config_cua, - "_cua_driver_contract_status", - lambda _binary=None: {"ready": False, "reason": "manifest is invalid"}, - ) - - assert _invoke(monkeypatch, "status") == 1 - output = capsys.readouterr().out - assert "custom binary from HERMES_CUA_DRIVER_CMD" in output - assert "unset the override" in output - - -@pytest.mark.parametrize(("ready", "expected"), [(True, 0), (False, 1)]) -def test_computer_use_install_checks_resulting_runtime_contract( - monkeypatch: pytest.MonkeyPatch, - ready: bool, - expected: int, -) -> None: - from hermes_cli import tools_config - import hermes_cli.tools_config_cua as tools_config_cua - - install = Mock(return_value=True) - monkeypatch.setattr(tools_config, "install_cua_driver", install) - monkeypatch.setattr( - tools_config, - "_cua_driver_contract_status", - lambda: {"ready": ready}, - ) - monkeypatch.setattr( - tools_config_cua, - "_cua_driver_contract_status", - lambda: {"ready": ready}, - ) - - assert _invoke(monkeypatch, "install") == expected - install.assert_called_once_with(upgrade=False) - - -def test_computer_use_install_returns_nonzero_for_unrepairable_custom_override( - monkeypatch: pytest.MonkeyPatch, -) -> None: - from hermes_cli import tools_config - import hermes_cli.tools_config_cua as tools_config_cua - - driver = r"C:\custom\cmd.exe" - monkeypatch.setenv("HERMES_CUA_DRIVER_CMD", driver) - install = Mock(return_value=False) - contract = Mock(side_effect=AssertionError("failed install must short-circuit")) - monkeypatch.setattr(tools_config, "install_cua_driver", install) - monkeypatch.setattr(tools_config, "_cua_driver_contract_status", contract) - monkeypatch.setattr(tools_config_cua, "_cua_driver_contract_status", contract) - - assert _invoke(monkeypatch, "install") == 1 - install.assert_called_once_with(upgrade=False) - contract.assert_not_called() + install = Mock(return_value=ready) + monkeypatch.setattr(cua, "install_cua_driver", install) + args = ("install", "--upgrade") if upgrade else ("install",) + assert _invoke(monkeypatch, *args) == (0 if ready else 1) + install.assert_called_once_with(upgrade=upgrade) \ No newline at end of file diff --git a/tests/hermes_cli/test_dashboard_spawn_executable.py b/tests/hermes_cli/test_dashboard_spawn_executable.py deleted file mode 100644 index d88db1d982..0000000000 --- a/tests/hermes_cli/test_dashboard_spawn_executable.py +++ /dev/null @@ -1,94 +0,0 @@ -"""Tests for the detached-dashboard-action interpreter choice (#90026). - -Under an SSH remote backend the web server runs on the **uv base -interpreter** with the venv's site-packages injected into ``sys.path`` at -startup. A detached action spawned from ``sys.executable`` (the base -interpreter) inherits no injected path and no PYTHONPATH, so it dies on the -first third-party import. The spawner must prefer the install's own venv -interpreter when it differs from ``sys.executable``. -""" - -from __future__ import annotations - -import sys -from pathlib import Path -from unittest.mock import patch - -import pytest - -import hermes_cli.web_server as web_server -import hermes_cli.web_server_gateway as _web_server_gateway - - -class TestDashboardSpawnExecutable: - def test_same_interpreter_returns_sys_executable(self, tmp_path): - """When sys.executable IS the venv python (normal launch), the - behavior is unchanged — same path back, verbatim.""" - fake_venv = tmp_path / "venv" / "bin" / "python" - fake_venv.parent.mkdir(parents=True) - fake_venv.touch() - with ( - patch.object(web_server, "PROJECT_ROOT", tmp_path), - patch.object(sys, "executable", str(fake_venv)), - ): - assert _web_server_gateway._dashboard_spawn_executable() == str(fake_venv) - - def test_base_interpreter_replaced_by_venv_python(self, tmp_path): - """sys.executable pointing at the dependency-less uv base - interpreter (SSH remote backend) resolves to the install's venv - python instead (#90026).""" - fake_venv = tmp_path / "venv" / "bin" / "python" - fake_venv.parent.mkdir(parents=True) - fake_venv.touch() - base_interp = tmp_path / "uv-base" / "python" - with ( - patch.object(web_server, "PROJECT_ROOT", tmp_path), - patch.object(sys, "executable", str(base_interp)), - ): - chosen = _web_server_gateway._dashboard_spawn_executable() - assert chosen == str(fake_venv) - - def test_windows_layout_resolved(self, tmp_path): - """The Windows venv layout (Scripts/python.exe) is honored.""" - fake_venv = tmp_path / "venv" / "Scripts" / "python.exe" - fake_venv.parent.mkdir(parents=True) - fake_venv.touch() - base_interp = tmp_path / "uv-base" / "python.exe" - with ( - patch.object(web_server, "PROJECT_ROOT", tmp_path), - patch.object(sys, "executable", str(base_interp)), - ): - chosen = _web_server_gateway._dashboard_spawn_executable() - assert Path(chosen).name == "python.exe" - assert "Scripts" in chosen - - def test_no_venv_falls_back_to_sys_executable(self, tmp_path): - """Exotic layouts without an install venv keep the old behavior.""" - base_interp = tmp_path / "uv-base" / "python" - with ( - patch.object(web_server, "PROJECT_ROOT", tmp_path), - patch.object(sys, "executable", str(base_interp)), - ): - assert _web_server_gateway._dashboard_spawn_executable() == str(base_interp) - - @pytest.mark.require_symlinks - def test_venv_symlink_to_base_is_still_preferred_unresolved(self, tmp_path): - """The Linux-standard layout: venv/bin/python is a SYMLINK to the - base interpreter. The chooser must return the UNRESOLVED venv path — - resolving it would compare equal to the base interpreter (missing - the swap) or spawn the base directly (bypassing pyvenv.cfg). This is - the exact layout of the #90026 report.""" - base = tmp_path / "uv-base" / "python" - base.parent.mkdir(parents=True) - base.touch() - venv_py = tmp_path / "venv" / "bin" / "python" - venv_py.parent.mkdir(parents=True) - venv_py.symlink_to(base) - with ( - patch.object(web_server, "PROJECT_ROOT", tmp_path), - patch.object(sys, "executable", str(base)), - ): - chosen = _web_server_gateway._dashboard_spawn_executable() - assert chosen == str(venv_py), ( - "must return the unresolved venv path, not the symlink target" - ) diff --git a/tests/hermes_cli/test_dep_ensure.py b/tests/hermes_cli/test_dep_ensure.py deleted file mode 100644 index 216a243e4a..0000000000 --- a/tests/hermes_cli/test_dep_ensure.py +++ /dev/null @@ -1,72 +0,0 @@ -"""ensure_dependency routes through pm: availability checks stay local, -installs go to pm.ensure, and pm's lazy-install policy owns refusal.""" - -from unittest.mock import patch - -import pytest -from tools import browser_tool_install as bt_install - - -def test_unknown_dep_refused(): - from hermes_cli.dep_ensure import ensure_dependency - - assert ensure_dependency("not-a-dep") is False - - -def test_available_dep_short_circuits(monkeypatch): - from hermes_cli import dep_ensure - - monkeypatch.setitem( - dep_ensure._DEPS, "node", (lambda: True, ("node",)) - ) - called = [] - with patch("pm.ensure", side_effect=lambda *a, **k: called.append(a)): - assert dep_ensure.ensure_dependency("node") is True - assert called == [] - - - - - - -def test_missing_dep_installs_through_pm(monkeypatch): - from hermes_cli import dep_ensure - - state = {"installed": False} - monkeypatch.setitem( - dep_ensure._DEPS, "node", (lambda: state["installed"], ("node",)) - ) - - def fake_ensure(name, **kw): - assert name == "node" - state["installed"] = True - - with patch("pm.ensure", side_effect=fake_ensure): - assert dep_ensure.ensure_dependency("node") is True - - -def test_pm_refusal_reports_and_returns_false(monkeypatch, capsys): - from hermes_cli import dep_ensure - - monkeypatch.setitem( - dep_ensure._DEPS, "node", (lambda: False, ("node",)) - ) - import pm as pm_mod - - def refuse(name, **kw): - raise pm_mod.InstallError(name, "lazy installs are disabled", "run `hermes pm install`") - - with patch("pm.ensure", side_effect=refuse): - assert dep_ensure.ensure_dependency("node", interactive=True) is False - out = capsys.readouterr().out - assert "hermes pm install" in out - - -def test_browser_check_consults_pm(monkeypatch): - from hermes_cli import dep_ensure - - monkeypatch.setattr("shutil.which", lambda *a, **k: None) - with patch("pm.is_installed", return_value=True): - assert dep_ensure._browser_available() is True - with patch("pm.is_installed", return_value=False): - assert dep_ensure._browser_available() is False diff --git a/tests/hermes_cli/test_desktop_exe_integrity.py b/tests/hermes_cli/test_desktop_exe_integrity.py index 9042e6ebc8..87a5b5e90b 100644 --- a/tests/hermes_cli/test_desktop_exe_integrity.py +++ b/tests/hermes_cli/test_desktop_exe_integrity.py @@ -246,9 +246,7 @@ def test_gate_fails_clearly_without_backup(tmp_path, capsys): fake.parent.mkdir(parents=True) fake.write_bytes(b"proxy error" + b" " * 600) - with patch("hermes_cli.main_desktop._purge_electron_build_cache", return_value=[]), \ - patch("hermes_cli.main_desktop._desktop_stamp_path", return_value=tmp_path / "stamp.json"): - verified, rolled_back = main_desktop._ensure_desktop_exe_launchable(desktop_dir, exe) + verified, rolled_back = main_desktop._ensure_desktop_exe_launchable(desktop_dir, exe) assert verified is None assert rolled_back is False @@ -302,6 +300,8 @@ def test_build_only_fails_when_pack_produces_corrupt_exe(tmp_path, monkeypatch, install_ok = subprocess.CompletedProcess(["npm", "ci"], 0) def pack_into_staging(cmd, *args, **kwargs): + if cmd[1:3] != ["run", "builder"]: + return subprocess.CompletedProcess(list(cmd), 0) # electron-builder honours -c.directories.output=; emulate a # pack that "succeeds" but writes a truncated exe there. out_flag = next((a for a in cmd if str(a).startswith("-c.directories.output=")), None) @@ -311,13 +311,10 @@ def test_build_only_fails_when_pack_produces_corrupt_exe(tmp_path, monkeypatch, return subprocess.CompletedProcess(list(cmd), 0) with patch("hermes_cli.main_desktop.shutil.which", return_value="/usr/bin/npm"), \ - patch("hermes_cli.main_install_repair._resolve_node_runtime_npm", return_value="npm.cmd"), \ - patch("hermes_cli.main_web_build._run_npm_install_deterministic", return_value=install_ok), \ + patch("hermes_cli.source_build.source_build_env", return_value={"PATH": "/usr/bin"}), \ + patch("hermes_cli.source_build.prepare_source_dependencies"), \ patch("hermes_cli.main_desktop._desktop_build_needed", return_value=True), \ patch("hermes_cli.main_desktop._stop_desktop_processes_locking_build", return_value=[]), \ - patch("hermes_cli.main_desktop._purge_electron_build_cache", return_value=[]), \ - patch("hermes_cli.main_desktop._desktop_stamp_path", return_value=tmp_path / "stamp.json"), \ - patch("hermes_cli.main_desktop._write_desktop_build_stamp") as mock_stamp, \ patch("hermes_cli.main_desktop._windows_native_machine", return_value="AMD64"), \ patch("hermes_cli.main_desktop.subprocess.run", side_effect=pack_into_staging), \ pytest.raises(SystemExit) as exc: @@ -329,7 +326,6 @@ def test_build_only_fails_when_pack_produces_corrupt_exe(tmp_path, monkeypatch, assert main_desktop._parse_pe_machine(live_exe) == PE_AMD64 # ...the staged corrupt tree was discarded... assert not list((desktop_dir / "release").glob(".staging-*")) - # ...and the poisoned build was never stamped as good. - mock_stamp.assert_not_called() + out = capsys.readouterr().out assert "integrity check" in out diff --git a/tests/hermes_cli/test_desktop_source_build.py b/tests/hermes_cli/test_desktop_source_build.py index 9146f1f800..09fd92cbf9 100644 --- a/tests/hermes_cli/test_desktop_source_build.py +++ b/tests/hermes_cli/test_desktop_source_build.py @@ -23,7 +23,7 @@ def test_desktop_build_only_prepares_once_and_keeps_fresh_launch_fast(desktop_so main_desktop.cmd_gui(Namespace(build_only=True)) app = root / "apps/desktop/release/linux-unpacked/hermes" assert app.read_text() == "desktop" - assert [event["step"] for event in _events(root)] == ["deps", "desktop"] + assert [event["step"] for event in _events(root)] == ["deps", "icons", "desktop"] assert acquired == ["npm"] assert (root / "node_modules/ui-tui").exists() assert (root / "node_modules/web").exists() @@ -31,7 +31,7 @@ def test_desktop_build_only_prepares_once_and_keeps_fresh_launch_fast(desktop_so assert not (root / "node_modules/unrelated").exists() main_desktop.cmd_gui(Namespace(build_only=True)) assert acquired == ["npm"] - assert len(_events(root)) == 2 + assert len(_events(root)) == 3 @pytest.mark.platforms("linux") @@ -46,5 +46,21 @@ def test_failed_pack_exits_without_launching_or_replacing_the_app(desktop_source assert error.value.code != 0 assert app.read_text() == "previous app" assert acquired == ["npm"] - assert [event["step"] for event in _events(root)] == ["deps", "desktop"] + assert [event["step"] for event in _events(root)] == ["deps", "icons", "desktop"] assert not list((root / "apps/desktop").glob(".staging-*")) + + +@pytest.mark.platforms("linux") +def test_skip_build_source_checks_artifacts_without_provisioning(desktop_source): + root, acquired = desktop_source + with pytest.raises(SystemExit): + main_desktop.cmd_gui(Namespace(source=True, skip_build=True, build_only=True)) + assert acquired == [] + dist = root / "apps/desktop/dist" + dist.mkdir() + (dist / "index.html").write_text("prepared renderer") + electron = root / "node_modules/electron" + electron.mkdir(parents=True) + (electron / "package.json").write_text('{}') + main_desktop.cmd_gui(Namespace(source=True, skip_build=True, build_only=True)) + assert acquired == [] diff --git a/tests/hermes_cli/test_desktop_update_verify.py b/tests/hermes_cli/test_desktop_update_verify.py index 701f560a69..0b8d1aef27 100644 --- a/tests/hermes_cli/test_desktop_update_verify.py +++ b/tests/hermes_cli/test_desktop_update_verify.py @@ -5,7 +5,7 @@ import struct import pytest from hermes_cli import desktop_update_verify as verify -from hermes_cli.main_desktop import _write_desktop_build_stamp +from tests.hermes_cli.test_source_build import copy_freshness_scripts, stamp_product @pytest.fixture @@ -29,7 +29,8 @@ def bundle(tmp_path, monkeypatch): # Host-independent artifact contract; executable lookup itself is covered natively. from hermes_cli import main_desktop monkeypatch.setattr(main_desktop, '_desktop_packaged_executable', lambda _: resources.parent / 'Hermes.exe') - _write_desktop_build_stamp(tmp_path, source_mode=False) + copy_freshness_scripts(tmp_path) + stamp_product(tmp_path, "desktop", dist) return tmp_path, archive, dist diff --git a/tests/hermes_cli/test_doctor.py b/tests/hermes_cli/test_doctor.py index e0b7fb6ccb..730a7969fe 100644 --- a/tests/hermes_cli/test_doctor.py +++ b/tests/hermes_cli/test_doctor.py @@ -28,6 +28,19 @@ from hermes_cli import doctor_config from tools import browser_tool_install as bt_install +@pytest.fixture(autouse=True) +def _no_browser_downloads(monkeypatch): + """Unrelated doctor --fix tests must not start an installer worker. + + Browser acquisition/readback is exercised with real temporary PM facts in + test_browser_pm; tests here may replace this boundary deliberately. + """ + def refuse(*args, **kwargs): + raise RuntimeError("PM downloads disabled in doctor unit tests") + + monkeypatch.setattr("pm.client._request", refuse) + + def _tls_out_normalized(out: str) -> str: """Doctor print matcher for TLS rows: key on words, not spacing.""" return " ".join(out.lower().split()) @@ -803,18 +816,10 @@ def _doctor_env_for_agent_browser(monkeypatch, tmp_path): pass -def test_run_doctor_reports_agent_browser_resolves_via_npx(monkeypatch, tmp_path): - """When agent-browser has no local/global install, _find_agent_browser - falls through to 'npx agent-browser' — doctor must report that as OK - (#43564: agent-browser is no longer a root package.json dependency, so - this is the expected common case now, not a warning).""" +def test_run_doctor_reports_installed_agent_browser(monkeypatch, tmp_path): _doctor_env_for_agent_browser(monkeypatch, tmp_path) - monkeypatch.setattr(bt_install, "_find_agent_browser", lambda **_kw: "npx agent-browser") - warm_calls = [] - monkeypatch.setattr( - "tools.browser_tool_install.warm_agent_browser_npx_cache", lambda *a, **kw: warm_calls.append(1) or True - ) + monkeypatch.setattr(bt_install, "_find_agent_browser", lambda **_kw: "/pm/agent-browser") buf = io.StringIO() with contextlib.redirect_stdout(buf): @@ -822,51 +827,50 @@ def test_run_doctor_reports_agent_browser_resolves_via_npx(monkeypatch, tmp_path out = buf.getvalue() assert "agent-browser" in out - assert "resolves via npx on first use" in out + assert "/pm/agent-browser" in out assert "agent-browser not installed" not in out - # --fix was not requested: the warm-up must not fire on a plain check. - assert not warm_calls -def test_run_doctor_fix_warms_npx_cache_when_agent_browser_resolves_via_npx( - monkeypatch, tmp_path -): - """`hermes doctor --fix` must actually call warm_agent_browser_npx_cache() - when agent-browser resolves via npx, and report success.""" +def test_doctor_fix_does_not_claim_success_without_published_binary(monkeypatch, tmp_path): + from hermes_cli import doctor_tools _doctor_env_for_agent_browser(monkeypatch, tmp_path) - monkeypatch.setattr(bt_install, "_find_agent_browser", lambda **_kw: "npx agent-browser") - warm_calls = [] - monkeypatch.setattr( - "tools.browser_tool_install.warm_agent_browser_npx_cache", lambda *a, **kw: warm_calls.append(1) or True - ) + def missing(**kwargs): + raise FileNotFoundError("no published browser") + + monkeypatch.setattr(bt_install, "_find_agent_browser", missing) + monkeypatch.setattr("pm.ensure", lambda *a, **kw: None) buf = io.StringIO() with contextlib.redirect_stdout(buf): - doctor_mod.run_doctor(Namespace(fix=True)) + assert doctor_tools._check_agent_browser(True) is False out = buf.getvalue() - assert warm_calls, "warm_agent_browser_npx_cache() must be called under --fix" - assert "Warmed npx cache for agent-browser" in out - assert "Could not warm npx cache" not in out + assert "agent-browser install failed" in out + assert "no published browser" in out -def test_run_doctor_fix_reports_when_npx_warmup_fails(monkeypatch, tmp_path): - """If warm_agent_browser_npx_cache() fails (offline, npx missing from - PATH at call time, etc.), doctor must say so instead of silently - claiming success — and must not count it as a fix.""" +def test_doctor_fix_reports_pm_install_failure(monkeypatch, tmp_path): + from hermes_cli import doctor_tools + import pm _doctor_env_for_agent_browser(monkeypatch, tmp_path) - monkeypatch.setattr(bt_install, "_find_agent_browser", lambda **_kw: "npx agent-browser") - monkeypatch.setattr("tools.browser_tool_install.warm_agent_browser_npx_cache", lambda *a, **kw: False) + def missing(**kwargs): + raise FileNotFoundError("no published browser") + + def refuse(*args, **kwargs): + raise pm.InstallError("agent-browser", "offline") + + monkeypatch.setattr(bt_install, "_find_agent_browser", missing) + monkeypatch.setattr(pm, "ensure", refuse) buf = io.StringIO() with contextlib.redirect_stdout(buf): - doctor_mod.run_doctor(Namespace(fix=True)) + assert doctor_tools._check_agent_browser(True) is False out = buf.getvalue() - assert "Could not warm npx cache (offline or npx unavailable)" in out - assert "Warmed npx cache for agent-browser" not in out + assert "agent-browser install failed" in out + assert "offline" in out def test_run_doctor_kimi_cn_env_is_detected_and_probe_is_null_safe(monkeypatch, tmp_path): diff --git a/tests/hermes_cli/test_doctor_live.py b/tests/hermes_cli/test_doctor_live.py index bc0189d4e2..26ceed95bc 100644 --- a/tests/hermes_cli/test_doctor_live.py +++ b/tests/hermes_cli/test_doctor_live.py @@ -191,20 +191,18 @@ class TestConfiguredOnlySelection: assert results["Browser"].status == "pass" -class TestBrowserAvailableNpxRung: - """agent-browser resolves lazily via npx on the default install (#43564), - invisible to the bare PATH/node_modules probes _browser_available starts - with. It must fall through to the same cascade `hermes doctor` uses.""" +class TestBrowserAvailable: + """Live probes use the same passive selection as browser execution.""" def _block_path_and_node_modules_checks(self, monkeypatch, tmp_path): monkeypatch.setattr("shutil.which", lambda *a, **k: None) monkeypatch.setattr("hermes_cli.doctor.HERMES_HOME", tmp_path / "home") monkeypatch.setattr("hermes_cli.doctor.PROJECT_ROOT", tmp_path / "root") - def test_true_when_npx_resolves_agent_browser(self, monkeypatch, tmp_path): + def test_true_when_installed_browser_resolves(self, monkeypatch, tmp_path): self._block_path_and_node_modules_checks(monkeypatch, tmp_path) - monkeypatch.setattr(bt_install, "_find_agent_browser", lambda **_kw: "npx agent-browser") + monkeypatch.setattr(bt_install, "_find_agent_browser", lambda **_kw: "/pm/agent-browser") assert _real_browser_available() is True diff --git a/tests/hermes_cli/test_ensure_windows_bin_launchers.py b/tests/hermes_cli/test_ensure_windows_bin_launchers.py index e9b92754ed..4dd6f05fe6 100644 --- a/tests/hermes_cli/test_ensure_windows_bin_launchers.py +++ b/tests/hermes_cli/test_ensure_windows_bin_launchers.py @@ -28,6 +28,9 @@ import json import pytest +# Real launcher serialization is host-dependent, despite injectable registry I/O. +pytestmark = pytest.mark.platforms("windows") + from hermes_cli._install_repair import ( _WINDOWS_BIN_LAUNCHERS, _normalize_windows_path, @@ -99,26 +102,19 @@ def _assert_boot_is_store_not_venv(launcher: Path, root: Path, store: Path): @pytest.fixture def managed_install(tmp_path, monkeypatch): home, root = _make_managed(tmp_path, monkeypatch) - # Keep every test off the real machine store by default. - monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "empty-store")) + _make_store(tmp_path, monkeypatch) return home, root -def test_no_store_python_yet_stages_runtime_resolving_cmd(managed_install): - """A fresh install (store not materialized) still gets working launchers - — they resolve the store python AT BOOT and say so when it is absent.""" +def test_no_store_python_refuses_publication(managed_install, tmp_path, monkeypatch): + """Repair cannot publish a usable launcher before PM commits Python.""" home, root = managed_install + monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "empty-store")) restored = ensure_windows_bin_launchers(root, windows=True, user_path_entries=[]) - assert len(restored) == len(_WINDOWS_BIN_LAUNCHERS) - for name in _WINDOWS_BIN_LAUNCHERS: - body = (home / "bin" / f"{name}.cmd").read_text(encoding="utf-8") - assert "python-*" in body - assert "pm install" in body - # Never a venv interpreter anywhere in the boot path. - assert "venv\\Scripts\\python" not in body - assert str(root / "venv") not in body.split("PYTHONPATH")[0] + assert restored == [] + assert not list((home / "bin").glob("hermes*")) def test_store_python_launcher_boot_the_store_not_the_venv(tmp_path, monkeypatch): @@ -165,10 +161,13 @@ def test_healthy_store_launcher_is_a_noop(tmp_path, monkeypatch): store, _entry = _make_store(tmp_path, monkeypatch) bin_dir = home / "bin" bin_dir.mkdir() + local = root / ".hermes" / "bin" + local.mkdir(parents=True) for name in _WINDOWS_BIN_LAUNCHERS: # A launcher that does NOT embed the venv interpreter counts as # present, whatever wrote it. (bin_dir / f"{name}.exe").write_bytes(b"MZ already-staged launcher") + (local / f"{name}.exe").write_bytes(b"MZ already-staged launcher") assert ensure_windows_bin_launchers(root, windows=True, user_path_entries=[]) == [] for name in _WINDOWS_BIN_LAUNCHERS: @@ -206,8 +205,8 @@ def test_legacy_bin_restaged_only_while_on_user_path(managed_install): stems = {Path(p).stem for p in map(Path, restored)} assert set(_WINDOWS_BIN_LAUNCHERS) <= stems for name in _WINDOWS_BIN_LAUNCHERS: - assert (legacy / f"{name}.cmd").is_file() # legacy consent honored - assert (home / "bin" / f"{name}.cmd").is_file() # canonical healed too + assert _launcher_files(legacy, name) + assert _launcher_files(home / "bin", name) def test_legacy_bin_not_restaged_without_path_consent(managed_install): @@ -247,13 +246,13 @@ def test_profile_session_still_heals_the_shared_bin(tmp_path, monkeypatch): root = home / "hermes-agent" (root / "venv" / "Scripts").mkdir(parents=True) monkeypatch.setenv("HERMES_HOME", str(home / "profiles" / "work")) - monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "empty-store")) + _make_store(tmp_path, monkeypatch) restored = ensure_windows_bin_launchers(root, windows=True, user_path_entries=[]) assert len(restored) == len(_WINDOWS_BIN_LAUNCHERS) for name in _WINDOWS_BIN_LAUNCHERS: - assert (home / "bin" / f"{name}.cmd").is_file() + assert _launcher_files(home / "bin", name) assert not (home / "profiles" / "work" / "bin").exists() @@ -309,7 +308,7 @@ def test_migration_moves_path_to_home_bin_and_strips_legacy(managed_install): assert _normalize_windows_path(legacy_scripts) not in keys assert _normalize_windows_path(r"C:\Windows\system32") in keys # untouched for name in _WINDOWS_BIN_LAUNCHERS: - assert (home / "bin" / f"{name}.cmd").is_file() + assert _launcher_files(home / "bin", name) # Legacy FILES stay: editor/ACP configs holding absolute launcher paths # keep working. Only the PATH entry (the sweepable resolution route) goes. assert (root / "bin" / "hermes.cmd").exists() @@ -317,7 +316,7 @@ def test_migration_moves_path_to_home_bin_and_strips_legacy(managed_install): def test_migration_works_when_only_legacy_copy_exists(tmp_path, monkeypatch): home, root = _make_managed(tmp_path, monkeypatch) - monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "empty-store")) + _make_store(tmp_path, monkeypatch) state, read, write = _fake_registry([str(root / "bin")]) ok = migrate_windows_bin_path( @@ -326,7 +325,7 @@ def test_migration_works_when_only_legacy_copy_exists(tmp_path, monkeypatch): assert ok for name in _WINDOWS_BIN_LAUNCHERS: - assert (home / "bin" / f"{name}.cmd").is_file() + assert _launcher_files(home / "bin", name) keys = [_normalize_windows_path(e) for e in state["entries"]] assert _normalize_windows_path(home / "bin") in keys @@ -348,11 +347,8 @@ def test_migration_is_idempotent(managed_install): assert state["writes"] == first_writes # no redundant registry write -def test_migration_works_with_empty_store(tmp_path, monkeypatch): - """Staging no longer depends on venv console scripts: even with an EMPTY - pm store (no python staged yet) the runtime-resolving delegators stage, - so the PATH migration completes; the cmd boot path is what instructs - the user to run `hermes pm install`.""" +def test_migration_refuses_empty_store_without_changing_path(tmp_path, monkeypatch): + """Never point PATH at commands that cannot start.""" home = tmp_path / "hermes" root = home / "hermes-agent" (root / "venv" / "Scripts").mkdir(parents=True) @@ -365,11 +361,8 @@ def test_migration_works_with_empty_store(tmp_path, monkeypatch): root, windows=True, read_user_path=read, write_user_path=write ) - assert ok - for name in _WINDOWS_BIN_LAUNCHERS: - body = (home / "bin" / f"{name}.cmd").read_text(encoding="utf-8") - assert "pm install" in body - assert state["writes"] == 1 + assert not ok + assert state["writes"] == 0 def test_migration_skips_source_checkouts(tmp_path, monkeypatch): diff --git a/tests/hermes_cli/test_gateway.py b/tests/hermes_cli/test_gateway.py index b61be2c352..90f60d88ca 100644 --- a/tests/hermes_cli/test_gateway.py +++ b/tests/hermes_cli/test_gateway.py @@ -335,15 +335,10 @@ def test_spawn_detached_gateway_timestamps_stderr(monkeypatch, tmp_path): assert len(calls) == 1 cmd, kwargs = calls[0] - assert cmd == [ - "/usr/bin/python3", - "-m", - "hermes_cli.stderr_timestamp", - "--error-log", - str(tmp_path / "logs" / "gateway.error.log"), - "--", - *child_cmd, - ] + assert cmd[0] == "/usr/bin/python3" + separator = cmd.index("--") + assert cmd[separator - 2:separator] == ["--error-log", str(tmp_path / "logs" / "gateway.error.log")] + assert cmd[separator + 1:] == child_cmd assert kwargs["stdin"] is gateway.subprocess.DEVNULL assert kwargs["stderr"] is gateway.subprocess.DEVNULL assert kwargs["stdout"].name == str(tmp_path / "logs" / "gateway.log") @@ -677,18 +672,14 @@ class TestReapUnsupervisedGatewayOrphansWindows: fake_psutil = SimpleNamespace(Process=lambda pid: by_pid[pid]) monkeypatch.setitem(sys.modules, "psutil", fake_psutil) + @pytest.mark.platforms("windows") def test_windows_excludes_recorded_pid_and_bootstrap_from_kill(self, monkeypatch): """The recorded gateway PID and its bootstrap parent must not be killed.""" recorded_pid = 52615 # detached gateway recorded in gateway.pid bootstrap_pid = 52616 # Scheduled-Task bootstrap (argv matches scan) orphan_pid = 99998 # a real orphan that should still be reaped - # Pretend we're on Windows — supports_systemd_services() returns - # False so the function does NOT short-circuit and proceeds to the - # scan, and is_macos() is False so the launchd branch is skipped. - monkeypatch.setattr(gateway, "is_windows", lambda: True) - monkeypatch.setattr(gateway, "is_macos", lambda: False) - monkeypatch.setattr(gateway, "supports_systemd_services", lambda: False) + # Native Windows owns the service topology; records remain test data. # gateway.pid records the detached gateway; its parent is the # Scheduled-Task bootstrap whose argv matches the gateway scan. @@ -728,16 +719,13 @@ class TestReapUnsupervisedGatewayOrphansWindows: assert recorded_pid not in killed # the recorded gateway was NOT killed assert bootstrap_pid not in killed # its supervision chain was NOT killed + @pytest.mark.platforms("windows") def test_windows_no_orphans_when_only_recorded_gateway_running(self, monkeypatch): """If the only gateway processes are the recorded one and its bootstrap parent, the reaper returns False and kills nothing.""" recorded_pid = 52615 bootstrap_pid = 52616 - monkeypatch.setattr(gateway, "is_windows", lambda: True) - monkeypatch.setattr(gateway, "is_macos", lambda: False) - monkeypatch.setattr(gateway, "supports_systemd_services", lambda: False) - bootstrap = SimpleNamespace(pid=bootstrap_pid, parent=lambda: None) recorded = SimpleNamespace(pid=recorded_pid, parent=lambda: bootstrap) self._install_fake_psutil(monkeypatch, [recorded, bootstrap]) @@ -766,6 +754,7 @@ class TestReapUnsupervisedGatewayOrphansWindows: assert result is False # no orphans reaped assert killed_pids == [] # nothing was killed + @pytest.mark.platforms("windows") def test_windows_raw_record_supplies_exclusion_when_validation_fails( self, monkeypatch ): @@ -781,10 +770,6 @@ class TestReapUnsupervisedGatewayOrphansWindows: """ recorded_pid = 52615 - monkeypatch.setattr(gateway, "is_windows", lambda: True) - monkeypatch.setattr(gateway, "is_macos", lambda: False) - monkeypatch.setattr(gateway, "supports_systemd_services", lambda: False) - recorded = SimpleNamespace(pid=recorded_pid, parent=lambda: None) self._install_fake_psutil(monkeypatch, [recorded]) @@ -844,6 +829,7 @@ class TestReaperCandidateIsSupervisorOwned: fake_psutil = SimpleNamespace(Process=lambda pid: by_pid[pid]) monkeypatch.setitem(sys.modules, "psutil", fake_psutil) + @pytest.mark.platforms("windows") def test_windows_scheduled_task_gateway_spared_without_pidfile(self, monkeypatch): """A Windows gateway launched by the Scheduled Task is spared even when gateway.pid is missing — the supervisor-owned backstop catches it.""" @@ -851,9 +837,6 @@ class TestReaperCandidateIsSupervisorOwned: bootstrap_pid = 52616 # Task-launched `hermes gateway run` bootstrap orphan_pid = 99998 # a genuine orphan that SHOULD be reaped - monkeypatch.setattr(gateway, "is_windows", lambda: True) - monkeypatch.setattr(gateway, "is_macos", lambda: False) - monkeypatch.setattr(gateway, "supports_systemd_services", lambda: False) # No pidfile => get_running_pid() returns None. monkeypatch.setattr("gateway.status.get_running_pid", lambda: None) # _get_service_pids() is empty on Windows. @@ -1331,16 +1314,3 @@ def test_service_commands_refuse_on_sealed_apt_termux( assert exc.value.code == 1 out = capsys.readouterr().out assert "Termux" in out - - -def test_python_path_reloads_constants_when_update_added_the_helper(tmp_path, monkeypatch): - import hermes_constants - from hermes_cli import gateway - - venv = tmp_path / "environment" - python = hermes_constants.venv_python_path(venv) - python.parent.mkdir(parents=True) - python.touch() - monkeypatch.setattr(gateway, "_detect_venv_dir", lambda: venv) - monkeypatch.delattr(hermes_constants, "venv_python_path") - assert gateway.get_python_path() == str(python) diff --git a/tests/hermes_cli/test_gateway_service.py b/tests/hermes_cli/test_gateway_service.py index 62c1855846..77a6ead6cf 100644 --- a/tests/hermes_cli/test_gateway_service.py +++ b/tests/hermes_cli/test_gateway_service.py @@ -1233,82 +1233,10 @@ def _seed_pm_environment(tmp_path, monkeypatch, with_venv_fact=True): return project_root, environment -class TestDetectVenvDir: - """Tests for _detect_venv_dir() virtualenv detection. - - pm's committed-environment resolution (runtime_paths.selected_venv) is - the primary source; each legacy-probe test isolates it - (``_pm_runtime_venv_dir`` patched to None) so the fallbacks are exercised - deterministically regardless of whether the host checkout has - pm-provisioned a venv. - """ - - def test_resolves_pm_provisioned_venv_without_virtual_env(self, tmp_path, monkeypatch): - """No sys.prefix venv, no VIRTUAL_ENV anywhere — the pm-committed - environment (venv fact + selected_venv contract) is the answer.""" - monkeypatch.setattr("sys.prefix", "/usr") - monkeypatch.setattr("sys.base_prefix", "/usr") - monkeypatch.delenv("VIRTUAL_ENV", raising=False) - monkeypatch.delenv("HERMES_RUNTIME_DIR", raising=False) - project_root, environment = _seed_pm_environment(tmp_path, monkeypatch) - monkeypatch.setattr(gateway_cli, "PROJECT_ROOT", project_root) - - assert gateway_cli._detect_venv_dir() == environment - - def test_pm_resolution_none_without_venv_fact(self, tmp_path, monkeypatch): - """A committed state without a venv fact does not vouch — no pm answer.""" - monkeypatch.setattr("sys.prefix", "/usr") - monkeypatch.setattr("sys.base_prefix", "/usr") - monkeypatch.delenv("VIRTUAL_ENV", raising=False) - project_root, _ = _seed_pm_environment( - tmp_path, monkeypatch, with_venv_fact=False - ) - monkeypatch.setattr(gateway_cli, "PROJECT_ROOT", project_root) - - assert gateway_cli._detect_venv_dir() is None - - def test_detects_active_virtualenv_via_sys_prefix(self, tmp_path, monkeypatch): - # Legacy probe (pre-pm environments): isolated from pm resolution. - monkeypatch.setattr(gateway_cli, "_pm_runtime_venv_dir", lambda: None) - venv_path = tmp_path / "my-custom-venv" - venv_path.mkdir() - monkeypatch.setattr("sys.prefix", str(venv_path)) - monkeypatch.setattr("sys.base_prefix", "/usr") - - result = gateway_cli._detect_venv_dir() - assert result == venv_path - - def test_falls_back_to_dot_venv_directory(self, tmp_path, monkeypatch): - # Not inside a virtualenv - monkeypatch.setattr(gateway_cli, "_pm_runtime_venv_dir", lambda: None) - monkeypatch.setattr("sys.prefix", "/usr") - monkeypatch.setattr("sys.base_prefix", "/usr") - monkeypatch.delenv("VIRTUAL_ENV", raising=False) - monkeypatch.setattr(gateway_cli, "PROJECT_ROOT", tmp_path) - - dot_venv = tmp_path / ".venv" - dot_venv.mkdir() - - result = gateway_cli._detect_venv_dir() - assert result == dot_venv - - def test_returns_none_when_no_virtualenv(self, tmp_path, monkeypatch): - monkeypatch.setattr(gateway_cli, "_pm_runtime_venv_dir", lambda: None) - monkeypatch.setattr("sys.prefix", "/usr") - monkeypatch.setattr("sys.base_prefix", "/usr") - monkeypatch.delenv("VIRTUAL_ENV", raising=False) - monkeypatch.setattr(gateway_cli, "PROJECT_ROOT", tmp_path) - - result = gateway_cli._detect_venv_dir() - assert result is None - - class TestServicePathDirsPmVenv: - """_build_service_path_dirs() must derive the venv bin dir from pm's - facts/store resolution, not from sys.prefix sniffing (which degrades - under no-boot-through-venv, where sys.prefix == sys.base_prefix).""" + """Service PATH cannot retain a garbage-collectable dependency generation.""" - def test_includes_pm_venv_bin_without_sys_prefix_venv(self, tmp_path, monkeypatch): + def test_does_not_persist_disposable_generation_bin(self, tmp_path, monkeypatch): monkeypatch.setattr("sys.prefix", "/usr") monkeypatch.setattr("sys.base_prefix", "/usr") monkeypatch.delenv("VIRTUAL_ENV", raising=False) @@ -1321,7 +1249,7 @@ class TestServicePathDirsPmVenv: dirs = gateway_cli._build_service_path_dirs(project_root=project_root) - assert str(venv_bin) in dirs + assert str(venv_bin) not in dirs def _seed_pm_node_facts(hermes_root): @@ -1627,27 +1555,12 @@ class TestHermesHomeForTargetUser: assert result == "/home/alice/.hermes" -class TestGeneratedUnitUsesDetectedVenv: - def test_systemd_unit_uses_dot_venv_when_detected(self, tmp_path, monkeypatch): - dot_venv = tmp_path / ".venv" - dot_venv.mkdir() - (dot_venv / "bin").mkdir() - - monkeypatch.setattr(gateway_cli, "_detect_venv_dir", lambda: dot_venv) - monkeypatch.setattr(gateway_cli, "get_python_path", lambda: str(dot_venv / "bin" / "python")) - - unit = gateway_cli.generate_systemd_unit(system=False) - - assert f"VIRTUAL_ENV={dot_venv}" in unit - assert f"{dot_venv}/bin" in unit - # Must NOT contain a hardcoded /venv/ path - assert "/venv/" not in unit or "/.venv/" in unit - - class TestGeneratedUnitIncludesLocalBin: """~/.local/bin must be in PATH so uvx/pipx tools are discoverable.""" - def test_system_unit_includes_local_bin_in_path(self, monkeypatch): + def test_system_unit_includes_local_bin_in_path(self, monkeypatch, tmp_path): + monkeypatch.setattr(gateway_cli, "_system_service_identity", + lambda run_as_user=None: ("alice", "alice", str(tmp_path), 1001)) monkeypatch.setattr( gateway_cli, "_build_user_local_paths", @@ -1818,7 +1731,10 @@ class TestProfileArg: unit = gateway_cli.generate_systemd_unit(system=True, run_as_user="alice") assert "ExecStart=" in unit - assert "--profile mybot gateway run" in unit + import shlex + command = shlex.split(next(line.split("=", 1)[1] for line in unit.splitlines() + if line.startswith("ExecStart="))) + assert command[-4:] == ["--profile", "mybot", "gateway", "run"] assert f'HERMES_HOME={target_home / ".hermes" / "profiles" / "mybot"}' in unit def test_launchd_plist_wraps_gateway_stderr_with_timestamps(self, tmp_path, monkeypatch): @@ -1832,22 +1748,11 @@ class TestProfileArg: plist = gateway_cli.generate_launchd_plist() program_args = plistlib.loads(plist.encode("utf-8"))["ProgramArguments"] - assert program_args == [ - "/usr/bin/python3", - "-m", - "hermes_cli.stderr_timestamp", - "--error-log", - str(profile_dir / "logs" / "gateway.error.log"), - "--", - "/usr/bin/python3", - "-m", - "hermes_cli.main", - "--profile", - "mybot", - "gateway", - "run", - "--external-supervisor", - ] + assert program_args[0] == "/usr/bin/python3" + assert program_args[-5:] == ["--profile", "mybot", "gateway", "run", "--external-supervisor"] + separator = program_args.index("--") + assert program_args[separator - 2:separator] == ["--error-log", str(profile_dir / "logs" / "gateway.error.log")] + assert "--replace" not in program_args def test_launchd_plist_path_uses_real_user_home_not_profile_home(self, tmp_path, monkeypatch): profile_dir = tmp_path / ".hermes" / "profiles" / "orcha" @@ -1898,7 +1803,7 @@ class TestSystemUnitPathRemapping: monkeypatch.setenv("HERMES_HOME", str(root_home / ".hermes")) monkeypatch.setattr(gateway_cli, "get_hermes_home", lambda: root_home / ".hermes") monkeypatch.setattr(gateway_cli, "PROJECT_ROOT", project) - monkeypatch.setattr(gateway_cli, "_detect_venv_dir", lambda: project / "venv") + monkeypatch.setattr(gateway_cli, "get_python_path", lambda: str(venv_bin / "python")) monkeypatch.setattr( gateway_cli, "_system_service_identity", diff --git a/tests/hermes_cli/test_gui_command.py b/tests/hermes_cli/test_gui_command.py index fea68e45ae..3c9fd21a41 100644 --- a/tests/hermes_cli/test_gui_command.py +++ b/tests/hermes_cli/test_gui_command.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse +import os import subprocess import sys from pathlib import Path @@ -16,6 +17,12 @@ from hermes_cli import main_install_repair from hermes_cli import main_web_build +@pytest.fixture(autouse=True) +def _prepared_build_environment(monkeypatch): + from hermes_cli import source_build + monkeypatch.setattr(source_build, "source_build_env", lambda env=None, **kw: {**os.environ, **(env or {})}) + + @pytest.fixture(autouse=True) def _isolate_xdg_data_home(tmp_path, monkeypatch): """Keep desktop-entry writes out of the developer's real home directory. @@ -128,7 +135,7 @@ def _pack_into_staging(root: Path, content: str = "", returncode: int = 0): (never in release/), then returns *returncode*. Non-pack commands (the launch) return success.""" def _run(cmd, **kwargs): - if len(cmd) >= 3 and cmd[1:3] == ["run", "pack"]: + if len(cmd) >= 3 and cmd[1:3] == ["run", "builder"]: exe = _staging_dir_from(cmd) / _packaged_exe_rel() exe.parent.mkdir(parents=True, exist_ok=True) exe.write_text(content, encoding="utf-8") @@ -140,124 +147,7 @@ def _pack_into_staging(root: Path, content: str = "", returncode: int = 0): -def test_gui_installs_packages_and_launches_desktop_app(tmp_path, monkeypatch): - root = _make_desktop_tree(tmp_path) - monkeypatch.setattr(main_desktop, "_ensure_desktop_exe_launchable", lambda _root, exe: (exe, False)) - desktop_dir = root / "apps" / "desktop" - monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) - packaged_exe = _make_packaged_executable(root, monkeypatch) - - install_ok = subprocess.CompletedProcess(["npm", "ci"], 0) - pack_ok = subprocess.CompletedProcess(["npm", "run", "pack"], 0) - launch_ok = subprocess.CompletedProcess([str(packaged_exe)], 0) - - with patch("hermes_cli.main_install_repair._resolve_node_runtime_npm", return_value="/usr/bin/npm"), \ - patch("hermes_cli.main_web_build._run_npm_install_deterministic", return_value=install_ok) as mock_install, \ - patch("hermes_cli.main_desktop._desktop_build_needed", return_value=True), \ - patch("hermes_cli.main_desktop._write_desktop_build_stamp"), \ - patch("hermes_cli.main_desktop._desktop_macos_relaunchable_fixup"), \ - patch("hermes_cli.main_desktop._desktop_linux_sandbox_fixup", return_value=True), \ - patch("hermes_cli.main_desktop._register_linux_desktop_entry"), \ - patch("hermes_cli.main.subprocess.run", side_effect=_pack_into_staging(root)) as mock_run, \ - pytest.raises(SystemExit) as exc: - cli_main.cmd_gui(_ns()) - - assert exc.value.code == 0 - # The install now runs with a resolved env (managed-Node PATH), never a bare - # ``env=None`` that would leave npm's child scripts unable to find ``node``. - mock_install.assert_called_once() - assert mock_install.call_args.args == ("/usr/bin/npm", root) - assert mock_install.call_args.kwargs["capture_output"] is False - install_env = mock_install.call_args.kwargs["env"] - assert install_env is not None and "PATH" in install_env - pack_cmd = mock_run.call_args_list[0].args[0] - assert pack_cmd[:4] == ["/usr/bin/npm", "run", "pack", "--"] - # Stage-and-swap (#86443): the pack targets a staging dir beside release/, - # never release/ itself. - staging = _staging_dir_from(pack_cmd) - assert staging.parent == desktop_dir and staging.name.startswith(".staging-") - assert not staging.exists() # swapped into release/ and cleaned up - assert mock_run.call_args_list[0].kwargs["cwd"] == desktop_dir - launched = mock_run.call_args_list[1].args[0] - if sys.platform.startswith("linux"): - assert launched == [str(packaged_exe), "--disable-setuid-sandbox"] - else: - assert launched == [str(packaged_exe)] - assert mock_run.call_args_list[1].kwargs["cwd"] == desktop_dir - - -def test_gui_install_env_prepends_managed_node_on_bare_path(tmp_path, monkeypatch): - """Regression: npm's child scripts (electron-winstaller's select-7z-arch.js) - shell out to bare ``node``. When Desktop is launched from the updater chain - the parent PATH is stripped, so the install env MUST carry the Hermes-managed - Node ahead of that bare PATH or the install dies with ``node: not found``. - """ - import json - import os - - root = _make_desktop_tree(tmp_path) - monkeypatch.setattr(main_desktop, "_ensure_desktop_exe_launchable", lambda _root, exe: (exe, False)) - monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) - _make_packaged_executable(root, monkeypatch) - - # Record tool identities matching the actual pin table; path-only legacy - # facts are intentionally not eligible for runtime activation. - from pm.ensure import _lockfile - from pm.lock import Facts - from pm.registry import get_package - from pm.store import current_target - home = tmp_path / "hermes-home" - store_root = home / "tools" - lock = _lockfile() - target = current_target() - dirs = {} - for name in ("node", "npm"): - package = get_package(name) - entry_name = package.store_entry(lock.version(name), target) - entry = store_root / entry_name - tool_dir = entry / "bin" if name == "npm" else entry - tool_dir.mkdir(parents=True) - dirs[name] = tool_dir - Facts(store_root / "facts.json").record( - name, lock.version(name), entry_name, {"PATH": [str(tool_dir)]}, store_root, - target=target, artifacts=[a["sha256"] for a in lock.artifacts(name, target)], - ) - node_entry, npm_entry = dirs["node"], dirs["npm"] - monkeypatch.setenv("HERMES_HOME", str(home)) - monkeypatch.setenv("HERMES_RUNTIME_DIR", str(store_root)) - # Simulate the stripped PATH the desktop updater chain hands us. - monkeypatch.setenv("PATH", os.pathsep.join(["/usr/bin", "/bin"])) - - install_ok = subprocess.CompletedProcess(["npm", "ci"], 0) - launch_ok = subprocess.CompletedProcess(["hermes"], 0) - - # A plain return_value rather than a fixed side_effect list: this test only - # cares about the env handed to the npm install, and pinning an exact - # sequence of subprocess.run calls makes it fail (StopIteration) whenever - # cmd_gui legitimately shells out one extra time — e.g. the Linux sandbox - # fixup, which fires on hosts where chrome-sandbox isn't already - # root-owned+4755. Assert on the install env, not on a call count. - with patch("hermes_cli.main_install_repair._resolve_node_runtime_npm", return_value="/usr/bin/npm"), \ - patch("hermes_cli.main_web_build._run_npm_install_deterministic", return_value=install_ok) as mock_install, \ - patch("hermes_cli.main_desktop._desktop_build_needed", return_value=True), \ - patch("hermes_cli.main_desktop._write_desktop_build_stamp"), \ - patch("hermes_cli.main_desktop._desktop_macos_relaunchable_fixup"), \ - patch("hermes_cli.main_desktop._desktop_linux_sandbox_fixup", return_value=True), \ - patch("hermes_cli.main.subprocess.run", return_value=launch_ok), \ - pytest.raises(SystemExit): - cli_main.cmd_gui(_ns(skip_build=False)) - - managed_dirs = [str(npm_entry), str(node_entry)] - install_env = mock_install.call_args.kwargs["env"] - path_parts = [ - os.path.normpath(p) for p in install_env["PATH"].split(os.pathsep) - ] - assert path_parts[: len(managed_dirs)] == [ - os.path.normpath(d) for d in managed_dirs - ] - assert os.path.normpath("/usr/bin") in path_parts # the bare updater PATH is preserved, just after managed Node - - +# Dependency admission and staging are exercised by test_desktop_source_build.py. @@ -276,88 +166,12 @@ def test_gui_install_env_prepends_managed_node_on_bare_path(tmp_path, monkeypatc # ── Electron build-cache recovery tests ─────────────────────────────── -def _write_zip(path: Path) -> None: - import zipfile - - path.parent.mkdir(parents=True, exist_ok=True) - with zipfile.ZipFile(path, "w") as zf: - zf.writestr("electron", "fake binary payload") - - -def test_purge_electron_build_cache_clears_all_zips_and_unpacked_dir(tmp_path, monkeypatch): - """Purge is unconditional: it removes every electron-*.zip (regardless of - whether stdlib zipfile thinks it's corrupt) plus the half-written unpacked - dir, because @electron/get's own SHASUM check on re-download is the real - validator — not a self-rolled one.""" - cache = tmp_path / "electron-cache" - # A "clean" zip and a prepended-junk zip — the latter is the real-world - # corruption that zipfile.testzip() silently passes (it reads from the - # end-of-central-directory backward), which is why we don't gate on it. - clean = cache / "electron-v40.9.3-linux-x64.zip" - prepended = cache / "hashdir" / "electron-v40.9.3-linux-x64.zip" - _write_zip(clean) - _write_zip(prepended) - prepended.write_bytes(b"\x00" * 4096 + prepended.read_bytes()) - - desktop_dir = tmp_path / "apps" / "desktop" - unpacked = desktop_dir / "release" / "linux-unpacked" - unpacked.mkdir(parents=True) - (unpacked / "LICENSE.electron.txt").write_text("x", encoding="utf-8") - (unpacked / "resources.pak").write_text("x", encoding="utf-8") - - monkeypatch.setattr(main_desktop, "_electron_download_cache_dirs", lambda: [cache]) - - removed = main_desktop._purge_electron_build_cache(desktop_dir) - - assert clean in removed - assert prepended in removed - assert unpacked in removed - assert not clean.exists() - assert not prepended.exists() - assert not unpacked.exists() -def test_gui_does_not_retry_after_packaged_executable_exists(tmp_path, monkeypatch, capsys): - """A build that already produced a packaged executable did NOT fail from the - Electron-download problem the cache purge + mirror retries exist to repair. - Regression for #40187: a late failure such as macOS code signing leaves - Hermes.app/Contents/MacOS/Hermes in place. Re-downloading Electron can't - repair a signing failure, so the destructive purge + slow mirror retry must - be skipped — we fail directly instead of grinding through an identical retry. - """ - root = _make_desktop_tree(tmp_path) - monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) - live_exe = _make_packaged_executable(root, monkeypatch) - live_exe.write_text("good build", encoding="utf-8") - monkeypatch.delenv("ELECTRON_MIRROR", raising=False) - install_ok = subprocess.CompletedProcess(["npm", "ci"], 0) - # Executable EXISTS in the STAGING output at failure time → late failure - # (e.g. signing), not a corrupt download. With stage-and-swap (#86443) the - # discriminator reads the staging dir, so the fake pack lays it down there. - pack_fail = _pack_into_staging(root, content="half-signed", returncode=1) - - with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \ - patch("hermes_cli.main_web_build._run_npm_install_deterministic", return_value=install_ok), \ - patch("hermes_cli.main_desktop._desktop_macos_relaunchable_fixup"), \ - patch("hermes_cli.main_desktop._purge_electron_build_cache", return_value=[Path("/c/electron.zip")]) as mock_purge, \ - patch("hermes_cli.main_desktop._redownload_electron_dist", return_value=True) as mock_dl, \ - patch("hermes_cli.main.subprocess.run", side_effect=pack_fail) as mock_run, \ - pytest.raises(SystemExit) as exc: - cli_main.cmd_gui(_ns()) - - assert exc.value.code == 1 - # The live app was never touched by the failed pack (#86443). - assert live_exe.read_text(encoding="utf-8") == "good build" - assert not list((root / "apps" / "desktop").glob(".staging-*")) - # Neither destructive recovery runs, and there is exactly ONE pack attempt. - mock_purge.assert_not_called() - mock_dl.assert_not_called() - assert mock_run.call_count == 1 - assert "Desktop GUI build failed" in capsys.readouterr().out @@ -365,74 +179,12 @@ def test_gui_does_not_retry_after_packaged_executable_exists(tmp_path, monkeypat # ── electronDist (re)download helper tests (#47266) ─────────────────── -def test_electron_dist_ok_on_this_host(): - """A dist dir that exists but lacks the binary is NOT ok (partial extraction). - - The binary's basename is per-OS (``electron`` / ``electron.exe`` / - ``Electron.app/…/Electron``), and ``_electron_dist_binary()`` picks it from - the real ``sys.platform``. Asking the implementation for the path it - expects — instead of hardcoding one and faking the platform to match — - makes this a genuine round-trip on whichever lane runs it. - """ - import tempfile - - with tempfile.TemporaryDirectory() as td: - root = Path(td) - electron = root / "node_modules" / "electron" - (electron / "dist").mkdir(parents=True) - assert main_desktop._electron_dist_ok(root) is False - - binp = main_desktop._electron_dist_binary(root) - # The resolved binary must live under the dist dir we just created. - assert (electron / "dist") in binp.parents - binp.parent.mkdir(parents=True, exist_ok=True) - binp.write_text("", encoding="utf-8") - assert main_desktop._electron_dist_ok(root) is True -@pytest.mark.platforms("linux") -def test_electron_dist_binary_basename_linux(): - """``dist/electron`` on Linux — asserted against the live function. - - Split per-OS rather than parametrized over a platform table: the old - ``@parametrize(("linux", …), ("win32", …), ("darwin", …))`` skipped the two - non-host rows, so outside the Linux lane those two branches were asserted - nowhere at all. One marked test per OS puts each row on the lane that can - actually execute it. - """ - root = Path("/tmp/does-not-need-to-exist") - assert main_desktop._electron_dist_binary(root) == ( - root / "node_modules" / "electron" / "dist" / "electron" - ) -@pytest.mark.platforms("windows") -def test_electron_dist_binary_basename_windows(): - """``dist/electron.exe`` on Windows — the ``.exe`` suffix is the whole point.""" - root = Path("C:/does-not-need-to-exist") - assert main_desktop._electron_dist_binary(root) == ( - root / "node_modules" / "electron" / "dist" / "electron.exe" - ) -@pytest.mark.platforms("macos") -def test_electron_dist_binary_basename_macos(): - """``dist/Electron.app/Contents/MacOS/Electron`` on macOS. - - The nested ``.app`` bundle path is why #47266's "dist exists but the - binary doesn't" check can't just stat the dist directory. - """ - root = Path("/tmp/does-not-need-to-exist") - assert main_desktop._electron_dist_binary(root) == ( - root - / "node_modules" - / "electron" - / "dist" - / "Electron.app" - / "Contents" - / "MacOS" - / "Electron" - ) @@ -818,7 +570,7 @@ def test_cmd_gui_setup_tcc_identity_exits_before_build(tmp_path, monkeypatch): _make_packaged_executable(root, monkeypatch) with patch("hermes_cli.main_desktop._desktop_macos_setup_tcc_identity", return_value=True) as mock_setup, \ - patch("hermes_cli.main_web_build._run_npm_install_deterministic") as mock_install, \ + patch("hermes_cli.source_build.prepare_source_dependencies") as mock_install, \ pytest.raises(SystemExit) as exc: cli_main.cmd_gui(_ns(setup_tcc_identity=True, identity="Hermes Local Signing")) @@ -1133,9 +885,8 @@ def test_gui_bridges_ozone_hint_to_launch_env(tmp_path, monkeypatch): cfg = {"desktop": {"ozone_platform_hint": "x11"}} with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \ - patch("hermes_cli.main_web_build._run_npm_install_deterministic", return_value=ok), \ + patch("hermes_cli.source_build.prepare_source_dependencies", return_value=ok), \ patch("hermes_cli.main_desktop._desktop_build_needed", return_value=True), \ - patch("hermes_cli.main_desktop._write_desktop_build_stamp"), \ patch("hermes_cli.main_desktop._desktop_macos_relaunchable_fixup"), \ patch("hermes_cli.main_desktop._desktop_linux_sandbox_fixup", return_value=True), \ patch("hermes_cli.config.load_config", return_value=cfg), \ @@ -1145,14 +896,13 @@ def test_gui_bridges_ozone_hint_to_launch_env(tmp_path, monkeypatch): pytest.raises(SystemExit): cli_main.cmd_gui(_ns()) - launch_env = mock_run.call_args_list[1].kwargs["env"] + launch_env = mock_run.call_args_list[-1].kwargs["env"] assert launch_env.get("ELECTRON_OZONE_PLATFORM_HINT") == "x11" monkeypatch.setenv("ELECTRON_OZONE_PLATFORM_HINT", "wayland") with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \ - patch("hermes_cli.main_web_build._run_npm_install_deterministic", return_value=ok), \ + patch("hermes_cli.source_build.prepare_source_dependencies", return_value=ok), \ patch("hermes_cli.main_desktop._desktop_build_needed", return_value=True), \ - patch("hermes_cli.main_desktop._write_desktop_build_stamp"), \ patch("hermes_cli.main_desktop._desktop_macos_relaunchable_fixup"), \ patch("hermes_cli.main_desktop._desktop_linux_sandbox_fixup", return_value=True), \ patch("hermes_cli.config.load_config", return_value=cfg), \ @@ -1162,7 +912,7 @@ def test_gui_bridges_ozone_hint_to_launch_env(tmp_path, monkeypatch): pytest.raises(SystemExit): cli_main.cmd_gui(_ns()) - launch_env = mock_run2.call_args_list[1].kwargs["env"] + launch_env = mock_run2.call_args_list[-1].kwargs["env"] assert launch_env.get("ELECTRON_OZONE_PLATFORM_HINT") == "wayland" @@ -1232,9 +982,8 @@ def test_gui_linux_packaged_launch_bridges_detected_password_store(tmp_path, mon ok = subprocess.CompletedProcess([], 0) with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \ - patch("hermes_cli.main_web_build._run_npm_install_deterministic", return_value=ok), \ + patch("hermes_cli.source_build.prepare_source_dependencies", return_value=ok), \ patch("hermes_cli.main_desktop._desktop_build_needed", return_value=True), \ - patch("hermes_cli.main_desktop._write_desktop_build_stamp"), \ patch("hermes_cli.main_desktop._desktop_macos_relaunchable_fixup"), \ patch("hermes_cli.main_desktop._desktop_linux_sandbox_fixup", return_value=True), \ patch("hermes_cli.config.load_config", return_value={}), \ @@ -1244,7 +993,7 @@ def test_gui_linux_packaged_launch_bridges_detected_password_store(tmp_path, mon pytest.raises(SystemExit): cli_main.cmd_gui(_ns()) - launch_env = mock_run.call_args_list[1].kwargs["env"] + launch_env = mock_run.call_args_list[-1].kwargs["env"] assert launch_env["HERMES_DESKTOP_PASSWORD_STORE"] == "gnome-libsecret" @@ -1254,12 +1003,16 @@ def test_gui_linux_source_launch_bridges_detected_password_store(tmp_path, monke root = _make_desktop_tree(tmp_path) monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) + electron = root / "node_modules/electron" + (electron / "dist").mkdir(parents=True) + (electron / "path.txt").write_text("electron") + (electron / "dist/electron").touch() + ok = subprocess.CompletedProcess([], 0) with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \ - patch("hermes_cli.main_web_build._run_npm_install_deterministic", return_value=ok), \ + patch("hermes_cli.source_build.prepare_source_dependencies", return_value=ok), \ patch("hermes_cli.main_desktop._desktop_build_needed", return_value=True), \ - patch("hermes_cli.main_desktop._write_desktop_build_stamp"), \ patch("hermes_cli.config.load_config", return_value={}), \ patch("hermes_cli.linux_desktop_entry.install_desktop_entry", return_value=None), \ patch("hermes_cli.main_desktop._detect_linux_password_store", return_value="kwallet6"), \ @@ -1267,8 +1020,8 @@ def test_gui_linux_source_launch_bridges_detected_password_store(tmp_path, monke pytest.raises(SystemExit): cli_main.cmd_gui(_ns(source=True)) - assert mock_run.call_args_list[1].args[0] == ["/usr/bin/npm", "exec", "--", "electron", "."] - launch_env = mock_run.call_args_list[1].kwargs["env"] + assert mock_run.call_args_list[-1].args[0] == [str(electron / "dist/electron"), "."] + launch_env = mock_run.call_args_list[-1].kwargs["env"] assert launch_env["HERMES_DESKTOP_PASSWORD_STORE"] == "kwallet6" @@ -1283,9 +1036,8 @@ def test_gui_config_password_store_skips_detection(tmp_path, monkeypatch): cfg = {"desktop": {"password_store": "kwallet6"}} with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \ - patch("hermes_cli.main_web_build._run_npm_install_deterministic", return_value=ok), \ + patch("hermes_cli.source_build.prepare_source_dependencies", return_value=ok), \ patch("hermes_cli.main_desktop._desktop_build_needed", return_value=True), \ - patch("hermes_cli.main_desktop._write_desktop_build_stamp"), \ patch("hermes_cli.main_desktop._desktop_macos_relaunchable_fixup"), \ patch("hermes_cli.main_desktop._desktop_linux_sandbox_fixup", return_value=True), \ patch("hermes_cli.config.load_config", return_value=cfg), \ @@ -1296,7 +1048,7 @@ def test_gui_config_password_store_skips_detection(tmp_path, monkeypatch): cli_main.cmd_gui(_ns()) mock_detect.assert_not_called() - launch_env = mock_run.call_args_list[1].kwargs["env"] + launch_env = mock_run.call_args_list[-1].kwargs["env"] assert launch_env["HERMES_DESKTOP_PASSWORD_STORE"] == "kwallet6" @@ -1312,9 +1064,8 @@ def test_gui_explicit_password_store_env_wins_over_config_and_detection(tmp_path cfg = {"desktop": {"password_store": "kwallet6"}} with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \ - patch("hermes_cli.main_web_build._run_npm_install_deterministic", return_value=ok), \ + patch("hermes_cli.source_build.prepare_source_dependencies", return_value=ok), \ patch("hermes_cli.main_desktop._desktop_build_needed", return_value=True), \ - patch("hermes_cli.main_desktop._write_desktop_build_stamp"), \ patch("hermes_cli.main_desktop._desktop_macos_relaunchable_fixup"), \ patch("hermes_cli.main_desktop._desktop_linux_sandbox_fixup", return_value=True), \ patch("hermes_cli.config.load_config", return_value=cfg), \ @@ -1325,7 +1076,7 @@ def test_gui_explicit_password_store_env_wins_over_config_and_detection(tmp_path cli_main.cmd_gui(_ns()) mock_detect.assert_not_called() - launch_env = mock_run.call_args_list[1].kwargs["env"] + launch_env = mock_run.call_args_list[-1].kwargs["env"] assert launch_env["HERMES_DESKTOP_PASSWORD_STORE"] == "basic" @@ -1339,9 +1090,8 @@ def test_gui_password_store_bridge_is_linux_only(tmp_path, monkeypatch): ok = subprocess.CompletedProcess([], 0) with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \ - patch("hermes_cli.main_web_build._run_npm_install_deterministic", return_value=ok), \ + patch("hermes_cli.source_build.prepare_source_dependencies", return_value=ok), \ patch("hermes_cli.main_desktop._desktop_build_needed", return_value=True), \ - patch("hermes_cli.main_desktop._write_desktop_build_stamp"), \ patch("hermes_cli.main_desktop._desktop_macos_relaunchable_fixup"), \ patch("hermes_cli.config.load_config", return_value={}), \ patch("hermes_cli.linux_desktop_entry.install_desktop_entry", return_value=None), \ @@ -1351,7 +1101,7 @@ def test_gui_password_store_bridge_is_linux_only(tmp_path, monkeypatch): cli_main.cmd_gui(_ns()) mock_detect.assert_not_called() - launch_env = mock_run.call_args_list[1].kwargs["env"] + launch_env = mock_run.call_args_list[-1].kwargs["env"] assert "HERMES_DESKTOP_PASSWORD_STORE" not in launch_env @@ -1366,15 +1116,12 @@ def test_gui_password_store_bridge_is_linux_only(tmp_path, monkeypatch): def _gui_build_patches(root: Path, run_side_effect): return [ patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), - patch("hermes_cli.main_web_build._run_npm_install_deterministic", + patch("hermes_cli.source_build.prepare_source_dependencies", return_value=subprocess.CompletedProcess(["npm", "ci"], 0)), patch("hermes_cli.main_desktop._desktop_build_needed", return_value=True), - patch("hermes_cli.main_desktop._write_desktop_build_stamp"), patch("hermes_cli.main_desktop._desktop_macos_relaunchable_fixup"), patch("hermes_cli.main_desktop._register_linux_desktop_entry"), patch("hermes_cli.main_desktop._stop_desktop_processes_locking_build", return_value=[]), - patch("hermes_cli.main_desktop._purge_electron_build_cache", return_value=[]), - patch("hermes_cli.main_desktop._redownload_electron_dist", return_value=False), patch("hermes_cli.main.subprocess.run", side_effect=run_side_effect), ] @@ -1450,12 +1197,14 @@ def test_gui_failed_pack_leaves_previous_app_untouched(tmp_path, monkeypatch, ca monkeypatch.setenv("ELECTRON_MIRROR", "https://example.test/electron/") def failing_pack(cmd, **kwargs): + if cmd[1:3] != ["run", "builder"]: + return subprocess.CompletedProcess(cmd, 0) # Mimic before-pack.mjs wiping appOutDir inside the OUTPUT dir it was # given, then dying (corrupt Electron zip → ENOENT on rename). out = _staging_dir_from(cmd) / _packaged_exe_rel().parts[0] out.mkdir(parents=True, exist_ok=True) (out / "resources").mkdir(exist_ok=True) - return subprocess.CompletedProcess(cmd, 1) + raise subprocess.CalledProcessError(1, cmd) patches = _gui_build_patches(root, failing_pack) for p in patches: @@ -1472,7 +1221,7 @@ def test_gui_failed_pack_leaves_previous_app_untouched(tmp_path, monkeypatch, ca assert not list(desktop_dir.glob(".staging-*")) assert not list((desktop_dir / "release").glob("*.previous")) out = capsys.readouterr().out - assert "previous desktop app was left untouched" in out + assert "Desktop GUI build failed" in out def test_gui_successful_pack_swaps_new_app_into_release(tmp_path, monkeypatch): @@ -1505,7 +1254,8 @@ def test_gui_zero_exit_pack_without_artifact_keeps_previous_app(tmp_path, monkey live_exe.write_text("good build", encoding="utf-8") def empty_pack(cmd, **kwargs): - _staging_dir_from(cmd).mkdir(parents=True, exist_ok=True) + if cmd[1:3] == ["run", "builder"]: + _staging_dir_from(cmd).mkdir(parents=True, exist_ok=True) return subprocess.CompletedProcess(cmd, 0) patches = _gui_build_patches(root, empty_pack) diff --git a/tests/hermes_cli/test_install_cua_driver.py b/tests/hermes_cli/test_install_cua_driver.py index a05dd5f947..01230a0272 100644 --- a/tests/hermes_cli/test_install_cua_driver.py +++ b/tests/hermes_cli/test_install_cua_driver.py @@ -1,1765 +1,180 @@ -"""Tests for ``install_cua_driver`` upgrade semantics. - -The cua-driver upstream installer always pulls the latest release tag, so -re-running it is the canonical upgrade path. ``install_cua_driver(upgrade=True)`` -must: - -* Be supported-platform-only — no-op silently elsewhere so ``hermes update`` - can call it unconditionally without warning unsupported-platform users. -* Re-run the installer for explicit upgrades even when the binary is already - on PATH (this is the fix for the "we only pulled cua-driver once on enable" - complaint). Automatic Windows updates defer because the installer can prompt. -* For ``upgrade=False``, keep compatible installations, repair old or - incomplete installations, and install when missing. - -The pre-install arch probe that used to live alongside this function was -deleted (see the release-probe comment in tools_config_cua.py) — the upstream -installer has CUA_DRIVER_RS_BAKED_VERSION baked in by CD and errors -cleanly on missing-arch assets, and the upgrade path uses -``cua_driver_update_check()`` (which shells `cua-driver check-update ---json` against the already-installed binary). -""" - -from __future__ import annotations +"""CUA runtime validation, explicit PM setup, and native host integration.""" import json -import sys from types import SimpleNamespace from unittest.mock import patch +from xml.sax.saxutils import escape import pytest -def _runtime_manifest(version="0.20.0", *, omit=None): - omit = set(omit or ()) +def _runtime_manifest(version="0.20.0", *, omit=()): required = { "mcp": {"--socket", "--grant"}, - "serve": { - "--socket", - "--permission-mode", - "--capability-manifest", - "--approve-capability-manifest", - "--embedded", - }, + "serve": {"--socket", "--permission-mode", "--capability-manifest", + "--approve-capability-manifest", "--embedded"}, "stop": {"--socket"}, } return { "binary_version": version, - "mcp_invocation": {"command": "/opt/cua-driver", "args": ["mcp"]}, + "mcp_invocation": {"command": "cua-driver", "args": ["mcp"]}, "subcommands": [ - { - "name": command, - "args": [ - {"name": arg} - for arg in sorted(args - omit) - ], - } + {"name": command, "args": [{"name": arg} for arg in sorted(args - set(omit))]} for command, args in required.items() ], } -class TestCuaDriverRuntimeContract: - def test_current_manifest_is_ready(self): - from hermes_cli import tools_config_cua as tools_config +@pytest.mark.parametrize("version,omit,ready", [ + ("0.20.0", (), True), + ("0.19.4", (), False), + ("bad-version", (), False), + ("0.20.0", ("--approve-capability-manifest",), False), +]) +def test_runtime_contract(version, omit, ready, tmp_path): + from hermes_cli import tools_config_cua as cua - result = SimpleNamespace( - returncode=0, - stdout=json.dumps(_runtime_manifest()), - stderr="", - ) - with patch("subprocess.run", return_value=result): - state = tools_config._cua_driver_contract_status("/opt/cua-driver") - - assert state == { - "ready": True, - "binary": "/opt/cua-driver", - "version": "0.20.0", - "reason": "", - } - - @pytest.mark.parametrize("version", ["0.19.4", "bad-version"]) - def test_old_or_unversioned_driver_needs_repair(self, version): - from hermes_cli import tools_config_cua as tools_config - - result = SimpleNamespace( - returncode=0, - stdout=json.dumps(_runtime_manifest(version)), - stderr="", - ) - with patch("subprocess.run", return_value=result): - state = tools_config._cua_driver_contract_status("/opt/cua-driver") - - assert state["ready"] is False - assert state["reason"] - - def test_incomplete_manifest_needs_repair(self): - from hermes_cli import tools_config_cua as tools_config - - result = SimpleNamespace( - returncode=0, - stdout=json.dumps( - _runtime_manifest(omit={"--approve-capability-manifest"}) - ), - stderr="", - ) - with patch("subprocess.run", return_value=result): - state = tools_config._cua_driver_contract_status("/opt/cua-driver") - - assert state["ready"] is False + result = SimpleNamespace(returncode=0, stderr="", + stdout=json.dumps(_runtime_manifest(version, omit=omit))) + binary = str(tmp_path / "cua-driver") + with patch("subprocess.run", return_value=result): + state = cua._cua_driver_contract_status(binary) + assert state["ready"] is ready + assert state["binary"] == binary + assert bool(state["reason"]) is not ready + if omit: assert "serve --approve-capability-manifest" in state["reason"] -class TestInstallCuaDriverUpgrade: - # ``install_cua_driver`` supports macOS, Windows AND Linux. For everything - # below except the two unsupported-platform cases, the Linux host takes a - # byte-identical path to macOS — same ``fetch_tool`` ("curl"), same - # ``_cua_install_target_writable()`` verdict, same branch — so the old - # ``patch("platform.system", return_value="Darwin")`` bought nothing but a - # fake host. Dropped, and the names no longer claim macOS. +@pytest.mark.parametrize("upgrade", [False, True]) +def test_pm_failure_is_reported_without_vendor_fallback(monkeypatch, capsys, upgrade): + import pm + from hermes_cli import tools_config_cua as cua - def test_upgrade_on_unsupported_platform_is_silent_noop(self): - """The one branch no CI runner can reach for real. + monkeypatch.delenv("HERMES_CUA_DRIVER_CMD", raising=False) + monkeypatch.setattr(cua, "_resolved_cua_driver_cmd", lambda: None) + with patch.object(pm, "ensure", side_effect=pm.InstallError("cua-driver", "offline")) as ensure, \ + patch.object(cua.subprocess, "Popen", side_effect=AssertionError("vendor installer")): + assert not cua.install_cua_driver(upgrade=upgrade) + ensure.assert_called_once_with("cua-driver", explicit=True) + assert "offline" in capsys.readouterr().out - ``platform.system`` is still faked here, deliberately and narrowly: we - run Linux/macOS/Windows lanes, and every one of them is a *supported* - platform, so the refusal path is unreachable on all three. The fake is - sound because the function returns before touching any OS facility — - no subprocess, no path handling, no import — so there is nothing - underneath the branch for a real host to falsify. - """ - from hermes_cli import tools_config_cua as tools_config - with patch.object(tools_config, "_print_warning") as warn, \ - patch("platform.system", return_value="FreeBSD"): - assert tools_config.install_cua_driver(upgrade=True) is False - warn.assert_not_called() +@pytest.mark.parametrize("upgrade", [False, True]) +def test_broken_override_never_acquires_standard_driver(tmp_path, monkeypatch, upgrade): + import pm + from hermes_cli import tools_config_cua as cua - def test_non_upgrade_on_unsupported_platform_warns(self): - """Same narrow exception as above — see that test's docstring.""" - from hermes_cli import tools_config_cua as tools_config + monkeypatch.setenv("HERMES_CUA_DRIVER_CMD", str(tmp_path / "missing-driver")) + with patch.object(pm, "ensure", side_effect=AssertionError("override was replaced")): + assert not cua.install_cua_driver(upgrade=upgrade) - with patch.object(tools_config, "_print_warning") as warn, \ - patch("platform.system", return_value="FreeBSD"): - assert tools_config.install_cua_driver(upgrade=False) is False - warn.assert_called() - @pytest.mark.platforms("linux") - def test_upgrade_with_binary_present_runs_installer(self): - from hermes_cli import tools_config_cua as tools_config - - with patch.object(tools_config.shutil, "which", - side_effect=lambda n: "/usr/local/bin/" + n - if n in {"cua-driver", "curl"} else None), \ - patch.object( - tools_config, - "_cua_driver_contract_status", - return_value={"ready": True, "version": "0.20.0", "reason": ""}, - ), \ - patch.object(tools_config, "_run_cua_driver_installer", - return_value=True) as runner, \ - patch("subprocess.run"): - assert tools_config.install_cua_driver(upgrade=True) is True - runner.assert_called_once() - kwargs = runner.call_args.kwargs - assert kwargs.get("verbose") is False - - @pytest.mark.platforms("linux") - def test_upgrade_without_binary_runs_installer(self): - from hermes_cli import tools_config_cua as tools_config - - with patch.object(tools_config.shutil, "which", - side_effect=lambda n: "/usr/bin/curl" if n == "curl" else None), \ - patch.object(tools_config, "_run_cua_driver_installer", - return_value=True) as runner: - assert tools_config.install_cua_driver(upgrade=True) is True - runner.assert_called_once() - - @pytest.mark.platforms("linux") - def test_quiet_refresh_prints_single_contextual_progress_line(self): - """``platforms("linux")``: reaches Popen through the POSIX download-then-exec - branch, which this lane takes for real.""" - from unittest.mock import MagicMock - - from hermes_cli import tools_config_cua as tools_config - - fake_proc = MagicMock() - fake_proc.pid = 1 - fake_proc.returncode = 0 - fake_proc.communicate.return_value = ("", None) - - with patch( - "subprocess.run", - return_value=MagicMock(returncode=0, stderr=""), - ), \ - patch("subprocess.Popen", return_value=fake_proc), \ - patch.object( - tools_config.shutil, - "which", - return_value="/usr/local/bin/cua-driver", - ), \ - patch.object(tools_config, "_clear_stale_cua_install_lock"), \ - patch.object(tools_config, "_print_info") as info: - assert tools_config._run_cua_driver_installer( - label="Refreshing", - verbose=False, - ) is True - - info.assert_called_once_with( - "→ Refreshing cua-driver (Computer Use)..." - ) - - @pytest.mark.platforms("linux") - def test_quiet_refresh_can_suppress_progress_line(self): - """``platforms("linux")``: same POSIX Popen path as the test above.""" - from unittest.mock import MagicMock - - from hermes_cli import tools_config_cua as tools_config - - fake_proc = MagicMock() - fake_proc.pid = 1 - fake_proc.returncode = 0 - fake_proc.communicate.return_value = ("", None) - - with patch( - "subprocess.run", - return_value=MagicMock(returncode=0, stderr=""), - ), \ - patch("subprocess.Popen", return_value=fake_proc), \ - patch.object( - tools_config.shutil, - "which", - return_value="/usr/local/bin/cua-driver", - ), \ - patch.object(tools_config, "_clear_stale_cua_install_lock"), \ - patch.object(tools_config, "_print_info") as info: - assert tools_config._run_cua_driver_installer( - label="Refreshing", - verbose=False, - show_progress=False, - ) is True - - info.assert_not_called() - - def test_quiet_refresh_closes_stdin_and_honors_custom_timeout(self): - """A background refresh must neither wait on a hidden prompt nor - inherit the explicit install command's 11-minute ceiling.""" - import subprocess - from unittest.mock import MagicMock - - from hermes_cli import tools_config_cua as tools_config - - fake_proc = MagicMock() - fake_proc.pid = 1 - fake_proc.returncode = 0 - fake_proc.communicate.return_value = ("", None) - - with patch( - "subprocess.run", - return_value=MagicMock(returncode=0, stderr=""), - ), \ - patch("subprocess.Popen", return_value=fake_proc) as popen, \ - patch.object( - tools_config.shutil, - "which", - return_value="/usr/local/bin/cua-driver", - ), \ - patch.object(tools_config, "_clear_stale_cua_install_lock"), \ - patch.object( - tools_config, - "_repair_cua_driver_autostart_windows", - return_value=True, - ), \ - patch.object(tools_config, "_print_info"), \ - patch.object(tools_config, "_print_success"): - assert tools_config._run_cua_driver_installer( - label="Refreshing", - verbose=False, - installer_timeout=120, - ) is True - - assert popen.call_args.kwargs["stdin"] is subprocess.DEVNULL - fake_proc.communicate.assert_called_once_with(timeout=120) - - @pytest.mark.platforms("linux") - def test_upgrade_can_suppress_installer_progress(self): - from hermes_cli import tools_config_cua as tools_config - - with patch.object( - tools_config.shutil, - "which", - side_effect=lambda name: ( - f"/usr/local/bin/{name}" - if name in {"cua-driver", "curl"} - else None - ), - ), \ - patch.object( - tools_config, - "_cua_driver_contract_status", - return_value={"ready": True, "version": "0.20.0", "reason": ""}, - ), \ - patch.object( - tools_config, - "_run_cua_driver_installer", - return_value=True, - ) as runner, \ - patch("subprocess.run"): - assert tools_config.install_cua_driver( - upgrade=True, - show_installer_progress=False, - ) is True - - assert runner.call_args.kwargs["show_progress"] is False - - def test_upgrade_non_writable_install_target_skips_refresh(self): - from hermes_cli import tools_config_cua as tools_config - - with patch.object(tools_config.shutil, "which", - side_effect=lambda n: "/usr/local/bin/" + n - if n in {"cua-driver", "curl"} else None), \ - patch.object(tools_config, "_cua_install_target_writable", - return_value=False), \ - patch.object(tools_config, "_run_cua_driver_installer") as runner, \ - patch.object(tools_config, "_print_info") as info: - assert tools_config.install_cua_driver(upgrade=True) is True - runner.assert_not_called() - assert any( - "/Applications is not writable" in call.args[0] - for call in info.call_args_list - ) - - def test_fresh_install_non_writable_install_target_skips_install(self): - from hermes_cli import tools_config_cua as tools_config - - with patch.object(tools_config.shutil, "which", - side_effect=lambda n: "/usr/bin/curl" if n == "curl" else None), \ - patch.object(tools_config, "_cua_install_target_writable", - return_value=False), \ - patch.object(tools_config, "_run_cua_driver_installer") as runner, \ - patch.object(tools_config, "_print_info") as info: - assert tools_config.install_cua_driver(upgrade=False) is False - runner.assert_not_called() - assert any( - "/Applications is not writable" in call.args[0] - for call in info.call_args_list - ) - - @pytest.mark.platforms("macos") - def test_install_target_writability_is_probed_for_real_on_macos(self): - """The ``_cua_install_target_writable`` seam the two tests above patch. - - ``platforms("macos")``: ``/Applications`` is the only install target Hermes - checks, and the probe short-circuits to True on every other platform — - so this is the one host where the real filesystem answer means - anything. - """ - import os - - from hermes_cli import tools_config_cua as tools_config - - writable = tools_config._cua_install_target_writable() - if os.path.isdir("/Applications"): - assert writable is os.access("/Applications", os.W_OK) - else: - assert writable is True - - def test_non_upgrade_with_binary_skips_install(self): - from hermes_cli import tools_config_cua as tools_config - - with patch.object(tools_config.shutil, "which", - side_effect=lambda n: "/usr/local/bin/" + n - if n in {"cua-driver", "curl"} else None), \ - patch.object(tools_config, "_run_cua_driver_installer") as runner, \ - patch.object( - tools_config, - "_cua_driver_contract_status", - return_value={"ready": True, "version": "0.20.0", "reason": ""}, - ), \ - patch.object( - tools_config, - "_repair_cua_driver_autostart_windows", - return_value=True, - ), \ - patch("subprocess.run"): - assert tools_config.install_cua_driver(upgrade=False) is True - runner.assert_not_called() - - def test_non_upgrade_repairs_incompatible_existing_driver(self): - from hermes_cli import tools_config_cua as tools_config - - incompatible = { - "ready": False, - "version": "0.19.4", - "reason": "Hermes computer use requires cua-driver 0.20.0 or newer", - } - repaired = {"ready": True, "version": "0.20.0", "reason": ""} - with patch.object( - tools_config.shutil, - "which", - side_effect=lambda name: f"/usr/bin/{name}", - ), \ - patch.object( - tools_config, - "_resolved_cua_driver_cmd", - return_value="/usr/bin/cua-driver", - ), \ - patch.object( - tools_config, - "_cua_driver_contract_status", - side_effect=[incompatible, repaired], - ), \ - patch.object( - tools_config, - "_run_cua_driver_installer", - return_value=True, - ) as runner: - assert tools_config.install_cua_driver(upgrade=False) is True - - assert runner.call_args.kwargs["label"] == "Repairing" - - def test_incompatible_explicit_override_is_not_replaced(self, monkeypatch): - from hermes_cli import tools_config_cua as tools_config - - monkeypatch.setenv("HERMES_CUA_DRIVER_CMD", "/opt/custom/cua-driver") - incompatible = { - "ready": False, - "version": "0.19.4", - "reason": "Hermes computer use requires cua-driver 0.20.0 or newer", - } - with patch.object( - tools_config, - "_resolved_cua_driver_cmd", - return_value="/opt/custom/cua-driver", - ), \ - patch.object( - tools_config, - "_cua_driver_contract_status", - return_value=incompatible, - ), \ - patch.object(tools_config, "_run_cua_driver_installer") as runner: - assert tools_config.install_cua_driver(upgrade=False) is False - - runner.assert_not_called() - - @pytest.mark.parametrize("upgrade", [False, True]) - def test_missing_explicit_override_does_not_install_standard_driver( - self, monkeypatch, upgrade - ): - from hermes_cli import tools_config_cua as tools_config - - monkeypatch.setenv("HERMES_CUA_DRIVER_CMD", "/missing/custom/cua-driver") - with patch.object( - tools_config, - "_resolved_cua_driver_cmd", - return_value=None, - ), \ - patch.object(tools_config, "_run_cua_driver_installer") as runner: - assert tools_config.install_cua_driver(upgrade=upgrade) is False - - runner.assert_not_called() - - @pytest.mark.platforms("linux") - def test_non_upgrade_without_binary_runs_installer(self): - from hermes_cli import tools_config_cua as tools_config - - with patch.object(tools_config.shutil, "which", - side_effect=lambda n: "/usr/bin/curl" if n == "curl" else None), \ - patch.object(tools_config, "_run_cua_driver_installer", - return_value=True) as runner: - assert tools_config.install_cua_driver(upgrade=False) is True - runner.assert_called_once() - - -class TestRequireConfirmedUpdate: - """`hermes update` passes require_confirmed_update=True: the full - upstream installer (multi-minute, output captured, plus install.ps1's - 600s lock window on Windows) may only run when the driver's native - ``check-update`` verb positively confirms a newer release. An - indeterminate check (old driver, offline, GitHub rate-limited, probe - timeout) keeps the installed version and returns fast. - - Explicit `hermes computer-use install --upgrade` keeps the old - fall-through (require_confirmed_update=False): a force-refresh should - still reinstall when the check can't answer. - """ - - def _install(self, check_state, require_confirmed, contract_status=None, - binary_missing=False): - """Drive ``install_cua_driver`` on the host, whatever it is. - - The old signature took a ``system`` string and faked - ``platform.system`` with it, so callers picked "Windows"/"Darwin" - arbitrarily. Nothing in the confirmed-update gate is - platform-dependent — it's ``check-update`` state plus a flag — so the - fake only decided which lie the test told itself. ``which`` answers - for every fetch tool so the host's own branch resolves cleanly. - """ - from unittest.mock import MagicMock - - from hermes_cli import tools_config_cua as tools_config - - _which_names = {"curl", "powershell"} - if not binary_missing: - _which_names.add("cua-driver") - with patch.object(tools_config.shutil, "which", - side_effect=lambda n: "/x/" + n - if n in _which_names else None), \ - patch.object(tools_config, "_resolved_cua_driver_cmd", - return_value=None if binary_missing - else "/x/cua-driver"), \ - patch.object(tools_config, "_cua_install_target_writable", - return_value=True), \ - patch.object( - tools_config, - "_cua_driver_contract_status", - return_value=contract_status or { - "ready": True, - "version": "0.20.0", - "reason": "", - }, - ), \ - patch("tools.computer_use.cua_backend_driver.cua_driver_update_check", - return_value=check_state), \ - patch.object(tools_config, "_run_cua_driver_installer", - return_value=True) as runner, \ - patch("subprocess.run", - return_value=MagicMock(stdout="cua-driver 0.5.0", returncode=0)), \ - patch.object(tools_config, "_print_success"), \ - patch.object(tools_config, "_print_warning"), \ - patch.object(tools_config, "_print_info") as info: - ok = tools_config.install_cua_driver( - upgrade=True, require_confirmed_update=require_confirmed - ) - return ok, runner, info - - def test_indeterminate_check_keeps_installed_version(self): - ok, runner, info = self._install(None, require_confirmed=True) - assert ok is True - runner.assert_not_called() - assert any( - "keeping the installed version" in call.args[0] - for call in info.call_args_list - ) - - def test_indeterminate_check_points_at_force_path(self): - ok, runner, info = self._install(None, require_confirmed=True) - assert ok is True - runner.assert_not_called() - assert any( - "computer-use install --upgrade" in call.args[0] - for call in info.call_args_list - ) - - def test_confirmed_update_runs_installer_bounded(self): - """Every platform: a positively confirmed newer release runs the - installer in unattended-safe mode (background ceiling). On Windows - the actual command adds -NoAutoStart and the preflights guard the - launch (covered by their own test classes below).""" - state = {"current_version": "0.5.0", "latest_version": "0.6.0", - "update_available": True} - ok, runner, _ = self._install(state, require_confirmed=True) - assert ok is True - runner.assert_called_once() - assert runner.call_args.kwargs["installer_timeout"] == 120 - - @pytest.mark.platforms("windows") - def test_windows_incompatible_driver_defers_interactive_repair(self): - incompatible = { - "ready": False, - "version": "0.19.3", - "reason": "Hermes computer use requires cua-driver 0.20.0 or newer", - } - ok, runner, info = self._install( - None, - require_confirmed=True, - contract_status=incompatible, - ) - - assert ok is False - runner.assert_not_called() - assert any( - "computer-use install --upgrade" in call.args[0] - for call in info.call_args_list - ) - - @pytest.mark.platforms("windows") - def test_windows_missing_binary_defers_interactive_install(self): - """Driver enabled but never installed (or wiped by a failed install): - the automatic update must not launch install.ps1 either — this path - reached the installer before the top-level guard (#94296 review).""" - ok, runner, info = self._install( - None, - require_confirmed=True, - binary_missing=True, - ) - - assert ok is False - runner.assert_not_called() - assert any( - "computer-use install --upgrade" in call.args[0] - for call in info.call_args_list - ) - - @pytest.mark.skipif( - sys.platform == "win32", - reason="POSIX installers are non-interactive; missing binary installs", - ) - def test_posix_missing_binary_still_installs(self): - ok, runner, _ = self._install( - None, - require_confirmed=True, - binary_missing=True, - ) - - assert ok is True - runner.assert_called_once() - - def test_up_to_date_short_circuits(self): - state = {"current_version": "0.6.0", "latest_version": "0.6.0", - "update_available": False} - ok, runner, _ = self._install(state, require_confirmed=True) - assert ok is True - runner.assert_not_called() - - def test_explicit_upgrade_still_falls_through_on_indeterminate(self): - # `hermes computer-use install --upgrade` (default flag): the old - # behaviour — indeterminate check re-runs the installer. - ok, runner, _ = self._install(None, require_confirmed=False) - assert ok is True - runner.assert_called_once() - - @pytest.mark.skipif( - sys.platform == "win32", - reason="automatic Windows updates must not launch an interactive repair", - ) - def test_incompatible_driver_repairs_on_posix_despite_indeterminate_check(self): - """Hermes' own version floor is the confirmation. When the installed - driver fails the runtime contract, the `hermes update` refresh must - repair it even though ``check-update`` can't confirm a newer release - (its ~20h cache routinely lags a same-day floor bump — the 0.19.3 - wedge).""" - from unittest.mock import MagicMock - - from hermes_cli import tools_config_cua as tools_config - - incompatible = { - "ready": False, - "version": "0.19.3", - "reason": "Hermes computer use requires cua-driver 0.20.0 or newer", - } - with patch.object(tools_config.shutil, "which", - side_effect=lambda n: "/x/" + n - if n in {"cua-driver", "curl", "powershell"} else None), \ - patch.object(tools_config, "_resolved_cua_driver_cmd", - return_value="/x/cua-driver"), \ - patch.object(tools_config, "_cua_install_target_writable", - return_value=True), \ - patch.object( - tools_config, - "_cua_driver_contract_status", - side_effect=[incompatible, - {"ready": True, "version": "0.20.0", "reason": ""}], - ), \ - patch("tools.computer_use.cua_backend_driver.cua_driver_update_check", - return_value=None) as check, \ - patch.object(tools_config, "_run_cua_driver_installer", - return_value=True) as runner, \ - patch("subprocess.run", - return_value=MagicMock(stdout="cua-driver 0.19.3", - returncode=0)), \ - patch.object(tools_config, "_print_success"), \ - patch.object(tools_config, "_print_warning"), \ - patch.object(tools_config, "_print_info"): - ok = tools_config.install_cua_driver( - upgrade=True, require_confirmed_update=True - ) - - assert ok is True - runner.assert_called_once() - assert runner.call_args.kwargs["label"] == "Repairing" - # The confirmed-update gate must not even consult check-update: - # the contract failure already confirmed the need. - check.assert_not_called() - - -class TestUpdateCheckTimeoutDefaults: - """cua_driver_update_check: platform-sensitive default timeout. - - 8s is fine on POSIX but too tight for Windows first-spawn (Defender / - SmartScreen scanning), and a false timeout is what used to trigger the - full reinstall fall-through during `hermes update`. - """ - - def _captured_timeout(self): - from unittest.mock import MagicMock - from tools.computer_use import cua_backend_driver - - captured = {} - - def fake_run(cmd, **kw): - captured["timeout"] = kw.get("timeout") - m = MagicMock() - m.stdout = '{"update_available": false, "current_version": "1.0"}' - return m - - with patch("tools.computer_use.cua_backend_driver.resolve_cua_driver_cmd", - return_value="/x/cua-driver"), \ - patch("tools.computer_use.cua_backend.subprocess.run", - side_effect=fake_run): - cua_backend_driver.cua_driver_update_check() - return captured.get("timeout") - - @pytest.mark.platforms("windows") - def test_windows_default_is_generous(self): - """``platforms("windows")``: the 25s default exists because a real Windows - first-spawn is delayed by Defender/SmartScreen scanning — a faked - platform asserted the constant, never the host it is chosen for. - """ - assert self._captured_timeout() == 25.0 - - @pytest.mark.platforms("linux") - def test_posix_default_unchanged(self): - # Unmarked: the POSIX default is what this (Linux) host already picks, - # so no platform faking is involved. - assert self._captured_timeout() == 8.0 - - def test_explicit_timeout_wins(self): - from unittest.mock import MagicMock - from tools.computer_use import cua_backend_driver - - captured = {} - - def fake_run(cmd, **kw): - captured["timeout"] = kw.get("timeout") - m = MagicMock() - m.stdout = "{}" - return m - - with patch("tools.computer_use.cua_backend_driver.resolve_cua_driver_cmd", - return_value="/x/cua-driver"), \ - patch("tools.computer_use.cua_backend.subprocess.run", - side_effect=fake_run): - cua_backend_driver.cua_driver_update_check(timeout=3.0) - assert captured.get("timeout") == 3.0 - - -class TestArchProbeRemoval: - """Regression tests for the deletion of `_check_cua_driver_asset_for_arch`. - - The old probe queried ``/releases/latest`` on trycua/cua and inspected - asset names. That was wrong in two ways: - - 1. cua-driver-rs releases are marked **prerelease** on every cut, so - ``/releases/latest`` returns the Python ``cua-agent`` / ``cua-computer`` - package instead — a release with zero binary assets. The probe then - reported "no asset for $arch" on Linux x86_64, Windows, macOS Intel, - Linux arm64 — every non-Apple-Silicon host. - 2. Even with the right endpoint, it duplicated tag-resolution the upstream - installer already does correctly via ``CUA_DRIVER_RS_BAKED_VERSION`` - (auto-baked by CD on every release). - - The fix: stop probing. Trust the upstream installer for fresh installs - (it has the baked version + correct API fallback) and the - ``cua-driver check-update --json`` MCP-binary native command for the - upgrade path. - """ - - def test_probe_function_is_gone(self): - from hermes_cli import tools_config_cua as tools_config - assert not hasattr(tools_config, "_check_cua_driver_asset_for_arch") - assert not hasattr(tools_config, "_latest_cua_driver_rs_release") - - def test_fresh_install_does_not_call_github_api(self): - """Pre-install no longer probes the GitHub API — the upstream - ``install.sh`` resolves the tag from its baked CUA_DRIVER_RS_BAKED_VERSION - line. install.sh errors cleanly when the arch has no asset, so the - probe was duplicate gatekeeping. - """ - from hermes_cli import tools_config_cua as tools_config - - # No platform fake: "does Python hit the GitHub API?" is host-agnostic, - # and ``which`` is stubbed so the host's own fetch tool resolves. - with patch.object(tools_config.shutil, "which", - side_effect=lambda n: "/usr/bin/" + n - if n in ("curl", "powershell") else None), \ - patch("urllib.request.urlopen") as urlopen, \ - patch.object(tools_config, "_run_cua_driver_installer", - return_value=True) as runner: - assert tools_config.install_cua_driver(upgrade=False) is True - runner.assert_called_once() - urlopen.assert_not_called() - - def test_upgrade_with_binary_does_not_call_github_api_directly(self): - """The upgrade path no longer hits GitHub from Python — it delegates - to the upstream ``install.sh`` (which has the baked release tag and - the proper API fallback). When cua-driver is already installed, - ``cua_driver_update_check()`` (added in a separate change) further - short-circuits the network re-install via the binary's native - ``check-update --json`` verb. - """ - from hermes_cli import tools_config_cua as tools_config - - with patch.object(tools_config.shutil, "which", - side_effect=lambda n: "/usr/local/bin/" + n - if n in ("cua-driver", "curl", "powershell") else None), \ - patch.object( - tools_config, - "_cua_driver_contract_status", - return_value={"ready": True, "version": "0.20.0", "reason": ""}, - ), \ - patch("urllib.request.urlopen") as urlopen, \ - patch("subprocess.run"), \ - patch.object(tools_config, "_run_cua_driver_installer", - return_value=True) as runner: - assert tools_config.install_cua_driver(upgrade=True) is True - runner.assert_called_once() - # Probe deleted — no direct GitHub API call from Python. - urlopen.assert_not_called() - - -@pytest.mark.skipif( - sys.platform == "win32", - reason="POSIX installer uses the .install.lock.d directory protocol", -) -class TestPosixStaleInstallLockClear: - """_clear_stale_cua_install_lock: pre-clears the upstream installer's - concurrent-install lock only when the holder is provably dead (or the - lock is old and pid-less). Issue #58762.""" - - def _make_lock(self, tmp_path, pid=None): - import os - home = tmp_path / ".cua-driver" - lock = home / "packages" / ".install.lock.d" - lock.mkdir(parents=True) - if pid is not None: - (lock / "info").write_text(f"pid={pid}\n") - os.environ["CUA_DRIVER_RS_HOME"] = str(home) - return lock - - def teardown_method(self): - import os - os.environ.pop("CUA_DRIVER_RS_HOME", None) - - def test_dead_holder_lock_is_cleared(self, tmp_path): - from hermes_cli import tools_config_cua as tools_config - - dead_pid = 4194000 # above default pid_max on most systems - lock = self._make_lock(tmp_path, pid=dead_pid) - with patch.object(tools_config, "_print_info"): - tools_config._clear_stale_cua_install_lock() - assert not lock.exists() - - def test_live_holder_lock_is_kept(self, tmp_path): - import os - from hermes_cli import tools_config_cua as tools_config - - lock = self._make_lock(tmp_path, pid=os.getpid()) - tools_config._clear_stale_cua_install_lock() - assert lock.exists() - - def test_pidless_fresh_lock_is_kept(self, tmp_path): - from hermes_cli import tools_config_cua as tools_config - - lock = self._make_lock(tmp_path, pid=None) - tools_config._clear_stale_cua_install_lock() - assert lock.exists() - - def test_pidless_old_lock_is_cleared(self, tmp_path): - import os - import time - from hermes_cli import tools_config_cua as tools_config - - lock = self._make_lock(tmp_path, pid=None) - old = time.time() - (tools_config._CUA_LOCK_STALE_AFTER + 60) - os.utime(lock, (old, old)) - with patch.object(tools_config, "_print_info"): - tools_config._clear_stale_cua_install_lock() - assert not lock.exists() - - def test_no_lock_is_noop(self, tmp_path): - import os - os.environ["CUA_DRIVER_RS_HOME"] = str(tmp_path / ".cua-driver") - from hermes_cli import tools_config_cua as tools_config - tools_config._clear_stale_cua_install_lock() # must not raise - - -class TestWindowsStaleInstallLockClearDispatch: - @pytest.mark.platforms("windows") - def test_windows_branch_uses_file_lock_probe(self): - """``platforms("windows")``: which lock protocol applies IS the host fact under - test — on Linux the faked platform asserted the dispatch and skipped - the ``.install.lock.d`` directory that really exists here. - """ - from hermes_cli import tools_config_cua as tools_config - - with patch.object( - tools_config, "_clear_stale_windows_cua_install_lock" - ) as clear_windows: - tools_config._clear_stale_cua_install_lock() - - clear_windows.assert_called_once_with() - - -# ``platforms("windows")`` rather than ``skipif(sys.platform != "win32")``: the -# dedicated Windows CI job selects ``-m platforms("windows")``, so a bare skipif left -# these real-CreateFileW tests running on no host at all. @pytest.mark.platforms("windows") -class TestWindowsStaleInstallLockClear: - def _make_lock(self, tmp_path): - import os - - home = tmp_path / ".cua-driver" - home.mkdir() - lock = home / "install.lock" - lock.write_text("pid=stale\n", encoding="utf-8") - os.environ["CUA_DRIVER_RS_HOME"] = str(home) - return lock - - def teardown_method(self): - import os - - os.environ.pop("CUA_DRIVER_RS_HOME", None) - - def test_unlocked_lock_file_is_cleared(self, tmp_path): - from hermes_cli import tools_config_cua as tools_config - - lock = self._make_lock(tmp_path) - with patch.object(tools_config, "_print_info"): - tools_config._clear_stale_cua_install_lock() - - assert not lock.exists() - - def test_lock_held_with_file_share_none_is_kept(self, tmp_path): - import ctypes - from ctypes import wintypes - from hermes_cli import tools_config_cua as tools_config - - lock = self._make_lock(tmp_path) - kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) - create_file = kernel32.CreateFileW - create_file.argtypes = [ - wintypes.LPCWSTR, - wintypes.DWORD, - wintypes.DWORD, - wintypes.LPVOID, - wintypes.DWORD, - wintypes.DWORD, - wintypes.HANDLE, - ] - create_file.restype = wintypes.HANDLE - close_handle = kernel32.CloseHandle - close_handle.argtypes = [wintypes.HANDLE] - close_handle.restype = wintypes.BOOL - handle = create_file( - str(lock), - 0x80000000 | 0x40000000, # GENERIC_READ | GENERIC_WRITE - 0, # FileShare::None, matching install.ps1 - None, - 3, # OPEN_EXISTING - 0x00000080, # FILE_ATTRIBUTE_NORMAL - None, - ) - assert handle != wintypes.HANDLE(-1).value - - try: - tools_config._clear_stale_cua_install_lock() - assert lock.exists() - finally: - assert close_handle(handle) - - -class TestInstallerTimeoutKillsProcessGroup: - """On timeout the whole installer process group must be killed, so the - `curl | bash` grandchildren can't survive holding the install lock. - - The POSIX cases drop the old ``platform.system`` → "Linux" fake: this lane - IS Linux, so the branch is selected for real. The Windows cases are - ``platforms("windows")`` — the psutil tree-kill only runs when ``is_windows``, and - on Linux the fake picked that branch on a host with no such process model. - """ - - @pytest.mark.platforms("linux") - def test_timeout_kills_process_group_and_returns_false(self): - import signal - import subprocess - from unittest.mock import MagicMock - from hermes_cli import tools_config_cua as tools_config - - killed = {} - sigkill = getattr(signal, "SIGKILL", signal.SIGTERM) - - fake_proc = MagicMock() - fake_proc.pid = 12345 - # First communicate() raises TimeoutExpired, second (post-kill) returns. - fake_proc.communicate.side_effect = [ - subprocess.TimeoutExpired(cmd="x", timeout=1), - ("", None), - ] - - def fake_killpg(pgid, sig): - killed["pgid"] = pgid - killed["sig"] = sig - - with patch("subprocess.run", return_value=MagicMock(returncode=0, stderr="")), \ - patch("subprocess.Popen", return_value=fake_proc), \ - patch.object( - tools_config.os, "getpgid", return_value=99999, create=True - ), \ - patch.object( - tools_config.os, "killpg", side_effect=fake_killpg, create=True - ), \ - patch.object(tools_config, "_clear_stale_cua_install_lock"), \ - patch.object(tools_config, "_print_warning"), \ - patch.object(tools_config, "_print_info"): - ok = tools_config._run_cua_driver_installer(label="Refreshing", verbose=False) - - assert ok is False - assert killed.get("pgid") == 99999 - assert killed.get("sig") == sigkill - # Post-kill reap happened. - assert fake_proc.communicate.call_count == 2 - - def test_timeout_ceiling_exceeds_upstream_lock_window(self): - from hermes_cli import tools_config_cua as tools_config - # The upstream installer waits up to 600s before reclaiming a stale - # lock; our ceiling must give that window room to complete. - assert tools_config._CUA_INSTALLER_TIMEOUT > tools_config._CUA_LOCK_STALE_AFTER - - @pytest.mark.platforms("linux") - def test_installer_runs_in_new_session_on_posix(self): - from unittest.mock import MagicMock - from hermes_cli import tools_config_cua as tools_config - - captured = {} - fake_proc = MagicMock() - fake_proc.pid = 1 - fake_proc.returncode = 1 - fake_proc.communicate.return_value = ("", None) - - def fake_popen(*args, **kwargs): - captured.update(kwargs) - return fake_proc - - with patch("subprocess.run", return_value=MagicMock(returncode=0, stderr="")), \ - patch("subprocess.Popen", side_effect=fake_popen), \ - patch.object(tools_config, "_clear_stale_cua_install_lock"), \ - patch.object(tools_config, "_print_warning"), \ - patch.object(tools_config, "_print_info"): - tools_config._run_cua_driver_installer(label="Refreshing", verbose=False) - - assert captured.get("start_new_session") is True - - @pytest.mark.platforms("windows") - def test_windows_timeout_kills_descendants_and_parent(self): - import subprocess - from unittest.mock import MagicMock - from hermes_cli import tools_config_cua as tools_config - - child = MagicMock() - parent = MagicMock() - parent.children.return_value = [child] - - fake_proc = MagicMock() - fake_proc.pid = 12345 - fake_proc.communicate.side_effect = [ - subprocess.TimeoutExpired(cmd="powershell", timeout=1), - ("", None), - ] - - with patch("subprocess.Popen", return_value=fake_proc), \ - patch("psutil.Process", return_value=parent), \ - patch.object(tools_config, "_clear_stale_cua_install_lock"), \ - patch.object(tools_config, "_print_warning"), \ - patch.object(tools_config, "_print_info"): - ok = tools_config._run_cua_driver_installer( - label="Refreshing", verbose=False - ) - - assert ok is False - parent.children.assert_called_once_with(recursive=True) - child.kill.assert_called_once_with() - parent.kill.assert_called_once_with() - fake_proc.kill.assert_not_called() - assert fake_proc.communicate.call_count == 2 - assert ( - fake_proc.communicate.call_args_list[1].kwargs["timeout"] - == tools_config._CUA_INSTALLER_DRAIN_GRACE - ) - - @pytest.mark.platforms("windows") - def test_windows_tree_enumeration_failure_falls_back_to_direct_kill(self): - import psutil - import subprocess - from unittest.mock import MagicMock - from hermes_cli import tools_config_cua as tools_config - - parent = MagicMock() - parent.children.side_effect = psutil.AccessDenied(pid=12345) - - fake_proc = MagicMock() - fake_proc.pid = 12345 - fake_proc.communicate.side_effect = [ - subprocess.TimeoutExpired(cmd="powershell", timeout=1), - ("", None), - ] - - with patch("subprocess.Popen", return_value=fake_proc), \ - patch("psutil.Process", return_value=parent), \ - patch.object(tools_config, "_clear_stale_cua_install_lock"), \ - patch.object(tools_config, "_print_warning"), \ - patch.object(tools_config, "_print_info"): - ok = tools_config._run_cua_driver_installer( - label="Refreshing", verbose=False - ) - - assert ok is False - fake_proc.kill.assert_called_once_with() - assert fake_proc.communicate.call_count == 2 - - -class TestInstallerTimeoutDrainIsBounded: - """The drain that follows the timeout kill must carry its own deadline. - - ``_kill_installer_tree`` is best-effort: every ``psutil.Error`` it can hit - is logged at debug level and stepped over, so the tree it leaves behind - can still contain a live process — on Windows, most concretely, an - ``install.ps1`` that self-elevated through ``Start-Process -Verb RunAs`` - and cannot be killed from a medium-integrity parent. That survivor holds - the ``stdout=PIPE`` write handle it inherited, so reading to EOF is - reading for something that will not happen, and ``_CUA_INSTALLER_TIMEOUT`` - stops being a ceiling (#87703). - - Asserted through the ``timeout`` kwarg rather than by timing: a test that - proved the hang by hanging would be the same defect wearing a test's name. - """ - - def test_drain_grace_is_short_relative_to_the_run_ceiling(self): - from hermes_cli import tools_config_cua as tools_config - - # This is a grace period for a pipe that a live process is holding - # open, not a second budget for the install itself — the install is - # already over by the time it is used. - assert 0 < tools_config._CUA_INSTALLER_DRAIN_GRACE - assert ( - tools_config._CUA_INSTALLER_DRAIN_GRACE - < tools_config._CUA_INSTALLER_TIMEOUT / 10 - ) - - @pytest.mark.platforms("linux") - def test_post_kill_drain_passes_a_deadline(self): - """``platforms("linux")``: reaches the timeout handler through the real POSIX - ``killpg`` branch, so the drain under test is the one this lane runs. - """ - import subprocess - from unittest.mock import MagicMock - from hermes_cli import tools_config_cua as tools_config - - fake_proc = MagicMock() - fake_proc.pid = 12345 - fake_proc.communicate.side_effect = [ - subprocess.TimeoutExpired(cmd="x", timeout=1), - ("", None), - ] - - with patch("subprocess.run", return_value=MagicMock(returncode=0, stderr="")), \ - patch("subprocess.Popen", return_value=fake_proc), \ - patch.object(tools_config.os, "getpgid", return_value=99999, create=True), \ - patch.object(tools_config.os, "killpg", create=True), \ - patch.object(tools_config, "_clear_stale_cua_install_lock"), \ - patch.object(tools_config, "_print_warning"), \ - patch.object(tools_config, "_print_info"): - ok = tools_config._run_cua_driver_installer(label="Refreshing", verbose=False) - - assert ok is False - assert fake_proc.communicate.call_count == 2 - drain_timeout = fake_proc.communicate.call_args_list[1].kwargs.get("timeout") - assert drain_timeout is not None, ( - "the post-kill drain was issued without a deadline, so a survivor " - "of the kill can hold it open indefinitely" - ) - assert drain_timeout == tools_config._CUA_INSTALLER_DRAIN_GRACE - - @pytest.mark.platforms("linux") - def test_verbose_path_drains_under_the_same_deadline(self): - """The streaming install has the same handler and had the same hole. - - Its child inherits the console rather than a pipe, so the stall is - harder to hit there, but the two branches should not be allowed to - drift on a rule this small. - """ - import subprocess - from unittest.mock import MagicMock - from hermes_cli import tools_config_cua as tools_config - - fake_proc = MagicMock() - fake_proc.pid = 12345 - fake_proc.communicate.side_effect = [ - subprocess.TimeoutExpired(cmd="x", timeout=1), - ("", None), - ] - - with patch("subprocess.run", return_value=MagicMock(returncode=0, stderr="")), \ - patch("subprocess.Popen", return_value=fake_proc), \ - patch.object(tools_config.os, "getpgid", return_value=99999, create=True), \ - patch.object(tools_config.os, "killpg", create=True), \ - patch.object(tools_config, "_clear_stale_cua_install_lock"), \ - patch.object(tools_config, "_print_warning"), \ - patch.object(tools_config, "_print_info"), \ - patch.object(tools_config, "_print_success"): - ok = tools_config._run_cua_driver_installer(label="Installing", verbose=True) - - assert ok is False - assert fake_proc.communicate.call_count == 2 - drain_timeout = fake_proc.communicate.call_args_list[1].kwargs.get("timeout") - assert drain_timeout is not None, ( - "the streaming path's drain was issued without a deadline" - ) - assert drain_timeout == tools_config._CUA_INSTALLER_DRAIN_GRACE - - @pytest.mark.platforms("windows") - def test_unkillable_elevated_descendant_does_not_stall_the_drain(self): - """The reported scenario, with the kill refused exactly where it is. - - ``install.ps1`` self-elevates, so the descendant is High-IL and - ``child.kill()`` raises ``AccessDenied`` — which the tree-kill catches - and logs, by design. The survivor is then still holding the pipe, and - the drain is the only thing standing between that and an indefinite - hang. - """ - import psutil - import subprocess - from unittest.mock import MagicMock - from hermes_cli import tools_config_cua as tools_config - - child = MagicMock() - child.kill.side_effect = psutil.AccessDenied(pid=999) - parent = MagicMock() - parent.children.return_value = [child] - - fake_proc = MagicMock() - fake_proc.pid = 12345 - fake_proc.communicate.side_effect = [ - subprocess.TimeoutExpired(cmd="powershell", timeout=1), - ("", None), - ] - - with patch("subprocess.Popen", return_value=fake_proc), \ - patch("psutil.Process", return_value=parent), \ - patch.object(tools_config, "_clear_stale_cua_install_lock"), \ - patch.object(tools_config, "_print_warning"), \ - patch.object(tools_config, "_print_info"): - ok = tools_config._run_cua_driver_installer( - label="Refreshing", verbose=False - ) - - assert ok is False - # The refused child kill must not abort the rest of the sweep. - parent.kill.assert_called_once_with() - drain_timeout = fake_proc.communicate.call_args_list[1].kwargs.get("timeout") - assert drain_timeout is not None, ( - "a High-IL descendant survived the kill and the drain was issued " - "without a deadline, which is the reported hang" - ) - assert drain_timeout == tools_config._CUA_INSTALLER_DRAIN_GRACE - - @pytest.mark.platforms("windows") - def test_drain_that_times_out_still_surfaces_the_run_timeout(self): - """A guardrail, not a regression test — it passes without the fix too. - - What it pins is that the drain's own ``TimeoutExpired`` must not be - the one that escapes: the caller has to see the original run timeout - so the existing manual re-run hint prints and ``hermes update`` - unwinds. That is the behaviour a future refactor of the drain is most - likely to break silently. - """ - import subprocess - from unittest.mock import MagicMock - from hermes_cli import tools_config_cua as tools_config - - parent = MagicMock() - parent.children.return_value = [] - - fake_proc = MagicMock() - fake_proc.pid = 12345 - fake_proc.communicate.side_effect = [ - subprocess.TimeoutExpired(cmd="powershell", timeout=660), - subprocess.TimeoutExpired(cmd="powershell", timeout=15), - ] - - with patch("subprocess.Popen", return_value=fake_proc), \ - patch("psutil.Process", return_value=parent), \ - patch.object(tools_config, "_clear_stale_cua_install_lock"), \ - patch.object(tools_config, "_print_warning") as warn, \ - patch.object(tools_config, "_print_info"): - ok = tools_config._run_cua_driver_installer( - label="Refreshing", verbose=False - ) - - assert ok is False - warned = " ".join(str(c.args[0]) for c in warn.call_args_list if c.args) - assert "timed out after" in warned - - -@pytest.mark.platforms("linux") -class TestInstallerNoShell: - """The POSIX installer path must not use shell=True or command - substitution: the script is downloaded to a mkstemp file and exec'd - as a plain argv list (salvage of #34974's intent, without the fixed - /tmp path TOCTOU that PR introduced). - - ``platforms("linux")``: the download-then-exec argv IS the POSIX branch, and this - lane already takes it — the old ``platform.system`` → "Linux" fake was - asserting a branch the host had already selected. - """ - - def _run(self, download_rc=0): - from unittest.mock import MagicMock - from hermes_cli import tools_config_cua as tools_config - - calls = [] - fake_proc = MagicMock() - fake_proc.pid = 1 - fake_proc.returncode = 0 - fake_proc.communicate.return_value = ("", None) - - def fake_run(cmd, **kw): - calls.append(("run", cmd, kw)) - m = MagicMock() - m.returncode = download_rc - m.stderr = "curl: (6) could not resolve" if download_rc else "" - return m - - def fake_popen(cmd, **kw): - calls.append(("popen", cmd, kw)) - return fake_proc - - with patch("subprocess.run", side_effect=fake_run), \ - patch("subprocess.Popen", side_effect=fake_popen), \ - patch.object(tools_config.shutil, "which", return_value="/usr/local/bin/cua-driver"), \ - patch.object(tools_config, "_clear_stale_cua_install_lock"), \ - patch.object(tools_config, "_print_warning"), \ - patch.object(tools_config, "_print_info"), \ - patch.object(tools_config, "_print_success"): - ok = tools_config._run_cua_driver_installer(label="Refreshing", verbose=False) - return ok, calls - - def test_posix_path_downloads_then_execs_argv_list(self): - ok, calls = self._run() - assert ok is True - run_calls = [c for c in calls if c[0] == "run"] - popen_calls = [c for c in calls if c[0] == "popen"] - assert len(run_calls) == 1 and len(popen_calls) == 1 - # Download: plain argv curl, no shell. - dl_cmd = run_calls[0][1] - assert isinstance(dl_cmd, list) and dl_cmd[0] == "curl" - # Exec: argv list ["/bin/bash", ], shell=False. - exec_cmd, exec_kw = popen_calls[0][1], popen_calls[0][2] - assert isinstance(exec_cmd, list) and exec_cmd[0] == "/bin/bash" - assert "cua-driver-install-" in exec_cmd[1] - assert exec_kw.get("shell") is False - - def test_download_failure_returns_false_without_exec(self): - ok, calls = self._run(download_rc=6) - assert ok is False - assert not [c for c in calls if c[0] == "popen"] - - def test_temp_script_removed_after_run(self): - import os - captured = {} - from unittest.mock import MagicMock - from hermes_cli import tools_config_cua as tools_config - - fake_proc = MagicMock() - fake_proc.pid = 1 - fake_proc.returncode = 0 - fake_proc.communicate.return_value = ("", None) - - def fake_run(cmd, **kw): - m = MagicMock(); m.returncode = 0; m.stderr = "" - return m - - def fake_popen(cmd, **kw): - captured["script"] = cmd[1] - return fake_proc - - with patch("subprocess.run", side_effect=fake_run), \ - patch("subprocess.Popen", side_effect=fake_popen), \ - patch.object(tools_config.shutil, "which", return_value="/usr/local/bin/cua-driver"), \ - patch.object(tools_config, "_clear_stale_cua_install_lock"), \ - patch.object(tools_config, "_print_warning"), \ - patch.object(tools_config, "_print_info"), \ - patch.object(tools_config, "_print_success"): - tools_config._run_cua_driver_installer(label="Refreshing", verbose=False) - - assert "script" in captured - assert not os.path.exists(captured["script"]) - - -class TestConfirmedVersionPinning: - """When check-update confirms a newer release, the installer run must be - pinned to that exact version via CUA_DRIVER_RS_VERSION. - - The upstream installer scripts on `main` carry a baked version that - Release Please bumps in the release PR *before* the release assets are - published. An unpinned install inside that window 404s (observed - 2026-07-29: baked 0.14.0 vs latest published release 0.13.1). Pinning to - check-update's `latest_version` — which comes from the Releases API and - therefore has published assets — sidesteps the race. - """ - - def _install(self, check_state): - """Version pinning also applies to explicit installer runs.""" - from unittest.mock import MagicMock - - from hermes_cli import tools_config_cua as tools_config - - with patch.object(tools_config.shutil, "which", - side_effect=lambda n: "/x/" + n - if n in {"cua-driver", "curl", "powershell"} else None), \ - patch.object(tools_config, "_resolved_cua_driver_cmd", - return_value="/x/cua-driver"), \ - patch.object(tools_config, "_cua_install_target_writable", - return_value=True), \ - patch.object( - tools_config, - "_cua_driver_contract_status", - return_value={"ready": True, "version": "0.20.0", "reason": ""}, - ), \ - patch("tools.computer_use.cua_backend_driver.cua_driver_update_check", - return_value=check_state), \ - patch.object(tools_config, "_run_cua_driver_installer", - return_value=True) as runner, \ - patch("subprocess.run", - return_value=MagicMock(stdout="cua-driver 0.5.0", returncode=0)), \ - patch.object(tools_config, "_print_success"), \ - patch.object(tools_config, "_print_warning"), \ - patch.object(tools_config, "_print_info"): - ok = tools_config.install_cua_driver( - upgrade=True, require_confirmed_update=False - ) - return ok, runner - - def test_confirmed_update_pins_latest_version(self): - state = {"current_version": "0.12.6", "latest_version": "0.13.1", - "update_available": True} - ok, runner = self._install(state) - assert ok is True - assert runner.call_args.kwargs.get("pin_version") == "0.13.1" - - def test_v_prefixed_latest_version_is_normalized(self): - state = {"current_version": "0.12.6", "latest_version": "v0.13.1", - "update_available": True} - ok, runner = self._install(state) - assert ok is True - assert runner.call_args.kwargs.get("pin_version") == "0.13.1" - - def test_malformed_latest_version_falls_back_unpinned(self): - state = {"current_version": "0.12.6", "latest_version": "not a version", - "update_available": True} - ok, runner = self._install(state) - assert ok is True - assert runner.call_args.kwargs.get("pin_version") is None - - def test_missing_latest_version_falls_back_unpinned(self): - state = {"current_version": "0.12.6", "update_available": True} - ok, runner = self._install(state) - assert ok is True - assert runner.call_args.kwargs.get("pin_version") is None - - -@pytest.mark.platforms("linux") -class TestRunInstallerPinEnv: - """_run_cua_driver_installer(pin_version=...) exports CUA_DRIVER_RS_VERSION - into the installer child env; unpinned runs leave it untouched. - - ``platforms("linux")``: the helper reaches Popen through the POSIX - download-then-exec branch, which this lane takes for real — no - ``platform.system`` fake needed. The pin itself is host-agnostic - (``TestConfirmedVersionPinning`` covers the caller side unmarked). - """ - - def _run(self, pin_version): - from unittest.mock import MagicMock - - from hermes_cli import tools_config_cua as tools_config - - captured = {} - fake_proc = MagicMock() - fake_proc.pid = 1 - fake_proc.returncode = 1 - fake_proc.communicate.return_value = ("", None) - - def fake_popen(cmd, **kw): - captured["env"] = kw.get("env") - return fake_proc - - def fake_run(cmd, **kw): - m = MagicMock(); m.returncode = 0; m.stderr = "" - return m - - with patch("subprocess.run", side_effect=fake_run), \ - patch("subprocess.Popen", side_effect=fake_popen), \ - patch.object(tools_config, "_cua_driver_env", - return_value={"PATH": "/usr/bin"}), \ - patch.object(tools_config, "_clear_stale_cua_install_lock"), \ - patch.object(tools_config, "_print_warning"), \ - patch.object(tools_config, "_print_info"): - tools_config._run_cua_driver_installer( - label="Refreshing", verbose=False, pin_version=pin_version - ) - return captured.get("env") or {} - - def test_pin_version_exported_to_installer_env(self): - env = self._run("0.13.1") - assert env.get("CUA_DRIVER_RS_VERSION") == "0.13.1" - - def test_no_pin_leaves_env_untouched(self): - env = self._run(None) - assert "CUA_DRIVER_RS_VERSION" not in env - - -class TestWindowsAutostartRepair: - @pytest.mark.platforms("windows") - def test_existing_task_skips_elevated_powershell_repair(self): - """``platforms("windows")``: ``_repair_cua_driver_autostart_windows`` returns - True unconditionally off Windows, so only the fake made the schtasks - probe run at all. - """ - from hermes_cli import tools_config_cua as tools_config - - calls = [] - - def fake_run(cmd, **kwargs): - calls.append((cmd, kwargs)) - return SimpleNamespace(returncode=0) - - with patch("subprocess.run", side_effect=fake_run), \ - patch.object(tools_config.shutil, "which") as which: - ok = tools_config._repair_cua_driver_autostart_windows( - "cua-driver", verbose=False - ) - - assert ok is True - assert [cmd for cmd, _kwargs in calls] == [ - ["schtasks.exe", "/Query", "/TN", "cua-driver-serve"] - ] - which.assert_not_called() - - @pytest.mark.platforms("windows") - def test_windows_installer_runs_autostart_repair_after_success(self): - """``platforms("windows")``: the PowerShell install argv and the autostart - repair hook are both inside the ``is_windows`` branch, so on Linux the - fake selected a branch whose `powershell` doesn't exist on PATH.""" - from unittest.mock import MagicMock - from hermes_cli import tools_config_cua as tools_config - - captured = {} - fake_proc = MagicMock() - fake_proc.pid = 1 - fake_proc.returncode = 0 - fake_proc.communicate.return_value = ("", None) - - def fake_popen(cmd, **kwargs): - captured["cmd"] = cmd - captured["kwargs"] = kwargs - return fake_proc - - def fake_which(name: str): - if name == "cua-driver": - return r"C:\Users\Ha Trung\AppData\Local\Programs\Cua\cua-driver\bin\cua-driver.exe" - return None - - with patch.object(tools_config.shutil, "which", side_effect=fake_which), \ - patch("subprocess.Popen", side_effect=fake_popen), \ - patch.object(tools_config, "_clear_stale_cua_install_lock"), \ - patch.object(tools_config, "_repair_cua_driver_autostart_windows", return_value=True) as repair, \ - patch.object(tools_config, "_print_warning"), \ - patch.object(tools_config, "_print_info"), \ - patch.object(tools_config, "_print_success"): - ok = tools_config._run_cua_driver_installer(label="Refreshing", verbose=False) - - assert ok is True - assert captured["kwargs"].get("shell") is False - assert isinstance(captured["cmd"], list) - assert captured["cmd"][:4] == [ - "powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", - ] - repair.assert_called_once_with( - r"C:\Users\Ha Trung\AppData\Local\Programs\Cua\cua-driver\bin\cua-driver.exe", - verbose=False, - ) - - @pytest.mark.platforms("windows") - def test_autostart_repair_quotes_username_space_path_via_file_path(self): - """``platforms("windows")``: same early return off Windows — the elevated - PowerShell command string is only built on a real Windows host. - """ - from hermes_cli import tools_config_cua as tools_config - - calls = [] - driver = ( - r"C:\Users\Ha Trung\AppData\Local\Programs\Cua" - r"\cua-driver\bin\cua-driver.exe" - ) - - def fake_which(name: str): - if name == "cua-driver": - return driver - if name == "powershell": - return r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe" - return None - - def fake_run(cmd, **kwargs): - calls.append((cmd, kwargs)) - if cmd[0] == "schtasks.exe": - return SimpleNamespace(returncode=1) - return SimpleNamespace(returncode=0, stdout="", stderr="") - - with patch.object(tools_config.shutil, "which", side_effect=fake_which), \ - patch("subprocess.run", side_effect=fake_run), \ - patch.object(tools_config, "_print_warning"), \ - patch.object(tools_config, "_print_info"): - ok = tools_config._repair_cua_driver_autostart_windows( - "cua-driver", verbose=False - ) - - assert ok is True - ps_calls = [cmd for cmd, _kwargs in calls if cmd[0].endswith("powershell.exe")] - assert len(ps_calls) == 1 - ps_command = ps_calls[0][-1] - assert "-FilePath $exe" in ps_command - assert "-ArgumentList @('autostart','enable')" in ps_command - assert f"$exe = '{driver}'" in ps_command - assert f"& {driver}" not in ps_command - - -class TestCuaVersionSummary: - """`hermes computer-use status` prints one line, whatever the binary says. - - A binary chosen by HERMES_CUA_DRIVER_CMD is under no obligation to answer - `--version` the way cua-driver does, and its output used to be spliced - verbatim into the status line. - """ - - @staticmethod - def _summary(raw, **kw): - from hermes_cli import tools_config_cua as tools_config - - return tools_config._cua_version_summary(raw, **kw) - - def test_plain_version_passes_through(self): - assert self._summary("cua-driver 0.20.0") == "cua-driver 0.20.0" - - def test_multiline_banner_collapses_to_first_line(self): - banner = ( - "Microsoft Windows [Version 10.0.26200.9168]\n" - "(c) Microsoft Corporation. All rights reserved.\n" - "\n" - "C:\\Users\\demo>" - ) - summary = self._summary(banner) - assert summary == "Microsoft Windows [Version 10.0.26200.9168]" - assert "\n" not in summary - - def test_leading_blank_lines_skipped(self): - assert self._summary("\n\n cua-driver 0.20.0 ") == "cua-driver 0.20.0" - - def test_long_line_is_bounded(self): - assert len(self._summary("x" * 500)) == 120 - - def test_empty_output_stays_empty(self): - assert self._summary("") == "" - assert self._summary(" \n \n") == "" - - -class TestUnattendedRefreshPreflights: - """Unattended refreshes (installer_timeout set) fail FAST instead of - eating the ceiling: a held install lock or an unreachable release host - skips the run in seconds. Explicit installs (installer_timeout=None) - bypass both preflights — a human is watching upstream's own recovery. - """ - - def _run(self, installer_timeout, lock_held=False, reachable=True, - system="Linux"): - from unittest.mock import MagicMock - - from hermes_cli import tools_config_cua as tools_config - - proc = MagicMock() - proc.communicate.return_value = ("ok", None) - proc.returncode = 0 - - with patch("platform.system", return_value=system), \ - patch.object(tools_config, "_cua_install_lock_held", - return_value=lock_held) as lock_probe, \ - patch.object(tools_config, "_cua_release_endpoint_reachable", - return_value=reachable) as net_probe, \ - patch.object(tools_config, "_clear_stale_cua_install_lock"), \ - patch.object(tools_config.shutil, "which", - side_effect=lambda n: "/x/" + n), \ - patch.object(tools_config.subprocess, "run", - return_value=SimpleNamespace( - returncode=0, stdout="", stderr="")), \ - patch.object(tools_config.subprocess, "Popen", - return_value=proc) as popen, \ - patch.object(tools_config, "_print_success"), \ - patch.object(tools_config, "_print_warning"), \ - patch.object(tools_config, "_print_info") as info: - ok = tools_config._run_cua_driver_installer( - label="Refreshing", - verbose=False, - show_progress=False, - installer_timeout=installer_timeout, - ) - return ok, popen, info, lock_probe, net_probe - - def test_held_lock_skips_unattended_run(self): - ok, popen, info, _, _ = self._run(120, lock_held=True) - assert ok is False - popen.assert_not_called() - assert any("install lock is held" in c.args[0] - for c in info.call_args_list) - - def test_unreachable_host_skips_unattended_run(self): - ok, popen, info, _, _ = self._run(120, reachable=False) - assert ok is False - popen.assert_not_called() - assert any("unreachable" in c.args[0] for c in info.call_args_list) - - def test_explicit_install_bypasses_preflights(self): - """installer_timeout=None (explicit `computer-use install --upgrade`): - neither probe runs; upstream's own lock recovery stays in charge.""" - ok, popen, _, lock_probe, net_probe = self._run(None) - lock_probe.assert_not_called() - net_probe.assert_not_called() - popen.assert_called_once() - assert ok is True - - def test_clean_preflights_run_installer(self): - ok, popen, _, lock_probe, net_probe = self._run(120) - lock_probe.assert_called_once() - net_probe.assert_called_once() - popen.assert_called_once() - assert ok is True - - def test_windows_unattended_command_passes_noautostart(self): - """The unattended Windows command must invoke install.ps1 with - -NoAutoStart — Register-CuaDriverAutostart is the only branch that - self-elevates (UAC).""" - ok, popen, _, _, _ = self._run(120, system="Windows") - assert ok is True - cmd = popen.call_args.args[0] - joined = " ".join(cmd) - assert "-NoAutoStart" in joined - assert "scriptblock" in joined - - def test_windows_explicit_command_keeps_plain_oneliner(self): - """Explicit installs keep upstream's documented `irm | iex` shape - (autostart re-registration included — human present for UAC).""" - ok, popen, _, _, _ = self._run(None, system="Windows") - assert ok is True - cmd = popen.call_args.args[0] - joined = " ".join(cmd) - assert "-NoAutoStart" not in joined - assert "| iex" in joined +@pytest.mark.parametrize("old_target", [False, True]) +def test_autostart_uses_selected_binary_and_verifies_registration(tmp_path, monkeypatch, old_target): + from hermes_cli import tools_config_cua as cua + + binary = str(tmp_path / "User's driver directory" / "cua-driver.exe") + selected = str(tmp_path / "old-cua-driver.exe") if old_target else binary + calls = [] + + def run(cmd, **kwargs): + nonlocal selected + calls.append(cmd) + if cmd[0] == "schtasks.exe": + xml = ('' + '' + f'{escape(selected)}') + return SimpleNamespace(returncode=0, stdout=xml.encode("utf-16"), stderr=b"") + assert "-FilePath $exe" in cmd[-1] + assert "-ArgumentList @('autostart','enable')" in cmd[-1] + assert cua._ps_single_quote(binary) in cmd[-1] + selected = binary + return SimpleNamespace(returncode=0, stdout="", stderr="") + + monkeypatch.setattr(cua.subprocess, "run", run) + monkeypatch.setattr(cua.shutil, "which", lambda command: command) + assert cua._repair_cua_driver_autostart_windows(binary, verbose=False) + assert len([cmd for cmd in calls if cmd[0] != "schtasks.exe"]) == int(old_target) + assert all("/XML" in cmd for cmd in calls if cmd[0] == "schtasks.exe") + + +@pytest.mark.platforms("windows") +def test_autostart_does_not_claim_success_without_registered_task(monkeypatch): + from hermes_cli import tools_config_cua as cua + + monkeypatch.setattr(cua, "_cua_driver_autostart_registered_windows", lambda binary=None: False) + monkeypatch.setattr(cua.shutil, "which", lambda command: command) + monkeypatch.setattr(cua, "_run_text", lambda *a, **kw: SimpleNamespace(returncode=0)) + assert not cua._repair_cua_driver_autostart_windows("cua-driver.exe", verbose=False) + + +@pytest.mark.platforms("windows") +@pytest.mark.parametrize("registered", [False, True]) +def test_setup_preserves_host_registration_failure(monkeypatch, registered): + import pm + from hermes_cli import tools_config_cua as cua + + monkeypatch.delenv("HERMES_CUA_DRIVER_CMD", raising=False) + monkeypatch.setattr(pm, "ensure", lambda *a, **kw: None) + monkeypatch.setattr(cua, "_resolved_cua_driver_cmd", lambda: "cua-driver.exe") + monkeypatch.setattr(cua, "_cua_driver_contract_status", lambda *a: {"ready": True}) + with patch.object(cua, "_repair_cua_driver_autostart_windows", return_value=registered) as repair: + assert cua.install_cua_driver(show_installer_progress=False) is registered + repair.assert_called_once_with("cua-driver.exe", verbose=False) + + +@pytest.mark.platforms("macos") +def test_setup_refuses_bare_binary_without_required_signed_app(monkeypatch, tmp_path): + from hermes_cli import tools_config_cua as cua + + driver = tmp_path / "cua-driver" + driver.write_text("#!/bin/sh\nexit 0\n") + driver.chmod(0o755) + monkeypatch.setenv("HERMES_CUA_DRIVER_CMD", str(driver)) + monkeypatch.setattr(cua, "_cua_driver_contract_status", lambda *a: {"ready": True}) + assert not cua.install_cua_driver(show_installer_progress=False) + + +@pytest.mark.platforms("macos") +def test_setup_registers_only_the_validated_selected_app(monkeypatch, tmp_path): + from hermes_cli import tools_config_cua as cua + from tools.computer_use import cua_backend_daemon as daemon + + app = tmp_path / "CuaDriver.app" + driver = app / "Contents" / "MacOS" / "cua-driver" + driver.parent.mkdir(parents=True) + driver.write_text("#!/bin/sh\nexit 0\n") + driver.chmod(0o755) + monkeypatch.setenv("HERMES_CUA_DRIVER_CMD", str(driver)) + monkeypatch.setattr(cua, "_cua_driver_contract_status", lambda *a: {"ready": True}) + with patch.object(daemon, "_validate_cua_driver_app_signature") as validate, \ + patch.object(cua, "_run_text", return_value=SimpleNamespace(returncode=0)) as register: + assert cua.install_cua_driver(show_installer_progress=False) + validate.assert_called_once_with(str(app)) + assert register.call_args.args[0][-2:] == ["-f", str(app)] + + +@pytest.mark.platforms("macos") +def test_setup_keeps_permission_guidance(capsys): + from hermes_cli.tools_config_cua import _print_cua_platform_notes + + _print_cua_platform_notes(False, False, fresh_install=True) + output = capsys.readouterr().out + assert "Accessibility" in output + assert "Screen Recording" in output + + +@pytest.mark.parametrize("raw,expected", [ + ("cua-driver 0.20.0", "cua-driver 0.20.0"), + ("banner\nsecond line", "banner"), + ("\n\n cua-driver 0.20.0 ", "cua-driver 0.20.0"), + ("x" * 500, "x" * 120), + ("", ""), + (" \n \n", ""), +]) +def test_version_summary(raw, expected): + from hermes_cli.tools_config_cua import _cua_version_summary + + assert _cua_version_summary(raw) == expected \ No newline at end of file diff --git a/tests/hermes_cli/test_memory_setup.py b/tests/hermes_cli/test_memory_setup.py index d2428b55fe..27c0381013 100644 --- a/tests/hermes_cli/test_memory_setup.py +++ b/tests/hermes_cli/test_memory_setup.py @@ -60,9 +60,8 @@ def test_cmd_setup_generic_choice_cancel_writes_nothing(tmp_path, monkeypatch): -def test_install_dependencies_force_resyncs_declared_extra(tmp_path, monkeypatch): - """force=True re-syncs the provider's extra even when it imports fine, - so a downgraded/stripped bridge package is restored on hermes update.""" +def test_install_dependencies_prepares_declared_extra_even_if_importable(tmp_path, monkeypatch): + """PM, not ambient importability, decides whether constraints are current.""" import hermes_yaml as _yaml plugin_dir = tmp_path / "mem0" @@ -84,7 +83,7 @@ def test_install_dependencies_force_resyncs_declared_extra(tmp_path, monkeypatch lambda extras=None, explicit=False: synced.append((list(extras or []), explicit)), ) - memory_setup._install_dependencies("mem0", force=True) + memory_setup._install_dependencies("mem0") assert synced == [(["mem0"], True)] diff --git a/tests/hermes_cli/test_memory_setup_provider_arg.py b/tests/hermes_cli/test_memory_setup_provider_arg.py index ba35eeb65b..7dc163e1f4 100644 --- a/tests/hermes_cli/test_memory_setup_provider_arg.py +++ b/tests/hermes_cli/test_memory_setup_provider_arg.py @@ -55,7 +55,7 @@ class TestInstallDependenciesRunner: assert synced == [(["mem0"], True)] - def test_noop_when_extra_available(self, tmp_path): + def test_available_extra_still_goes_through_pm_currency_check(self, tmp_path): (tmp_path / "plugin.yaml").write_text("extra: mem0\n", encoding="utf-8") synced = [] @@ -69,4 +69,4 @@ class TestInstallDependenciesRunner: ): memory_setup._install_dependencies("x") - assert synced == [] + assert synced == [["mem0"]] diff --git a/tests/hermes_cli/test_nous_subscription.py b/tests/hermes_cli/test_nous_subscription.py index b9435d27dd..8af8153a78 100644 --- a/tests/hermes_cli/test_nous_subscription.py +++ b/tests/hermes_cli/test_nous_subscription.py @@ -3,8 +3,6 @@ import shutil import sys -import pytest - from hermes_cli.nous_account import NousPortalAccountInfo, NousToolAccessInfo from hermes_cli import nous_subscription as ns from tools import tool_backend_helpers @@ -470,16 +468,15 @@ def _block_legacy_agent_browser_checks(monkeypatch): monkeypatch.setattr("hermes_constants.agent_browser_runnable", lambda path: False) -def test_has_agent_browser_true_for_npx_only_resolution(monkeypatch): - """No PATH binary and no runnable node_modules copy, but the browser_tool - cascade resolves the npx fallback: browser capability is available.""" +def test_has_agent_browser_uses_passive_runtime_resolution(monkeypatch): + """Readiness shares the runtime resolver without acquiring a package.""" _block_legacy_agent_browser_checks(monkeypatch) calls = [] def fake_find_agent_browser(*, validate=True): calls.append({"validate": validate}) - return "npx agent-browser" + return "/prepared/agent-browser" monkeypatch.setattr(bt_install, "_find_agent_browser", fake_find_agent_browser) @@ -499,9 +496,8 @@ def test_has_agent_browser_false_when_nothing_resolvable(monkeypatch): assert ns._has_agent_browser() is False -def test_has_agent_browser_import_failure_falls_back_to_path_check(monkeypatch): - """If tools.browser_tool_install cannot be imported, the old PATH + node_modules - check must still answer (prior behaviour), not crash.""" +def test_has_agent_browser_import_failure_does_not_run_another_resolver(monkeypatch): + """A broken runtime resolver cannot advertise an unchecked fallback.""" monkeypatch.setitem(sys.modules, "tools.browser_tool_install", None) real_which = shutil.which monkeypatch.setattr( @@ -518,43 +514,7 @@ def test_has_agent_browser_import_failure_falls_back_to_path_check(monkeypatch): lambda path: path == "/fake/bin/agent-browser", ) - assert ns._has_agent_browser() is True - - -@pytest.mark.platforms("linux") -def test_has_agent_browser_import_failure_falls_back_to_hermes_managed_node_path( - monkeypatch, tmp_path -): - """If tools.browser_tool_install cannot be imported, the managed-Node rung must - still find a runnable agent-browser under the Hermes Node dir even when - it's absent from the probe process's PATH — the Windows installer shape - where install succeeded but the GUI still said needs setup.""" - monkeypatch.setitem(sys.modules, "tools.browser_tool_install", None) - managed_dir = tmp_path / "node" - managed_dir.mkdir() - managed_bin = managed_dir / "agent-browser" - managed_bin.write_text("#!/bin/sh\nexit 0\n") - managed_bin.chmod(0o755) - - real_which = shutil.which - monkeypatch.setattr( - shutil, - "which", - lambda cmd, *args, **kwargs: ( - None - if cmd == "agent-browser" and not kwargs.get("path") - else real_which(cmd, *args, **kwargs) - ), - ) - monkeypatch.setattr( - "hermes_constants.with_hermes_node_path", lambda: {"PATH": str(managed_dir)} - ) - monkeypatch.setattr( - "hermes_constants.agent_browser_runnable", - lambda p: bool(p) and str(p) == str(managed_bin), - ) - - assert ns._has_agent_browser() is True + assert ns._has_agent_browser() is False def test_has_agent_browser_import_failure_and_no_binary_is_false(monkeypatch): diff --git a/tests/hermes_cli/test_npm_engine.py b/tests/hermes_cli/test_npm_engine.py index 19b75ca37d..ccabb136d8 100644 --- a/tests/hermes_cli/test_npm_engine.py +++ b/tests/hermes_cli/test_npm_engine.py @@ -1,184 +1,28 @@ -"""Tests for npm ``EBADENGINE`` recovery (``hermes_cli/npm_engine.py``). - -The behaviour under test is a contract about *reacting* to npm's own engine -check: npm states the range it wants in the failure, Hermes provisions its -pm-pinned npm instead of touching a foreign one, and every other case leaves -the original failure alone. -""" - -import json -from pathlib import Path +"""The frozen npm retry import stops old callers without changing dependencies.""" import pytest -import hermes_cli.npm_engine as npm_engine -from hermes_cli.npm_engine import ( - actual_npm_version, - is_ebadengine, - maybe_repair_npm_engine, - required_npm_range, -) +from hermes_cli.npm_engine import maybe_repair_npm_engine -# Verbatim npm 10 output shape (`npm error`), and the npm 9 shape (`npm ERR!`). -EBADENGINE_OUTPUT = """ -npm error code EBADENGINE -npm error engine Unsupported engine -npm error engine Not compatible with your version of node/npm: hermes-agent@1.0.0 -npm error notsup Not compatible with your version of node/npm: hermes-agent@1.0.0 -npm error notsup Required: {"node":">=20.0.0","npm":"<11.10.0 || >=12.0.0"} -npm error notsup Actual: {"npm":"11.10.0","node":"v22.23.1"} -""" +@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 -LEGACY_EBADENGINE_OUTPUT = """ -npm ERR! code EBADENGINE -npm ERR! engine Unsupported engine -npm ERR! notsup Required: {"node":">=20.0.0","npm":">=12.0.0"} -npm ERR! notsup Actual: {"npm":"9.6.7","node":"v20.1.0"} -""" + home = tmp_path / "home" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setenv("HERMES_RUNTIME_DIR", str(home / "tools")) + calls = [] -# A lockfile mismatch — the other common `npm ci` failure. Must NOT be treated -# as an engine problem, or every out-of-sync lockfile would trigger a repair. -ELOCK_OUTPUT = """ -npm error code EUSAGE -npm error `npm ci` can only install packages when your package.json and -npm error package-lock.json are in sync. -""" + def forbidden_ensure(*args, **kwargs): + calls.append((args, kwargs)) + raise AssertionError("historical updater must not install") - -class TestDetection: - def test_recognises_modern_and_legacy_engine_failures(self): - assert is_ebadengine(EBADENGINE_OUTPUT) - assert is_ebadengine(LEGACY_EBADENGINE_OUTPUT) - - def test_unrelated_failures_are_not_engine_failures(self): - assert not is_ebadengine(ELOCK_OUTPUT) - assert not is_ebadengine("") - assert not is_ebadengine("npm error code E404") - - def test_range_comes_from_the_error_not_a_hardcoded_list(self): - assert required_npm_range(EBADENGINE_OUTPUT) == "<11.10.0 || >=12.0.0" - assert required_npm_range(LEGACY_EBADENGINE_OUTPUT) == ">=12.0.0" - - def test_actual_version_is_reported_back(self): - assert actual_npm_version(EBADENGINE_OUTPUT) == "11.10.0" - - def test_no_range_for_non_engine_output(self): - assert required_npm_range(ELOCK_OUTPUT) is None - assert required_npm_range("") is None - - def test_node_only_mismatch_yields_no_npm_range(self): - """Upgrading npm cannot fix a Node version mismatch, so don't try.""" - node_only = ( - 'npm error code EBADENGINE\n' - 'npm error notsup Required: {"node":">=20.0.0"}\n' - 'npm error notsup Actual: {"npm":"10.9.8","node":"v18.0.0"}\n' - ) - assert required_npm_range(node_only) is None - - def test_malformed_required_block_is_ignored(self): - broken = ( - "npm error code EBADENGINE\n" - "npm error notsup Required: {not json}\n" - ) - assert required_npm_range(broken) is None - - -class TestRepairDecision: - """`maybe_repair_npm_engine` returns the npm to retry with (truthy) only - when a repair actually happened, because its return value is what gates - the caller's single retry.""" - - def test_foreign_npm_provisions_pm_npm_instead(self, tmp_path, monkeypatch): - """A system/nvm/brew/Nix npm is never modified — Hermes ensures its - own pm-pinned npm and returns it.""" - system_npm = tmp_path / "usr-bin-npm" - system_npm.write_text("#!/bin/sh\n", encoding="utf-8") - managed = tmp_path / "store" / "npm-x" / "npm" - managed.parent.mkdir(parents=True) - managed.write_text("#!/bin/sh\n", encoding="utf-8") - - monkeypatch.setattr( - npm_engine, "_pm_npm", lambda quiet=False: str(managed) - ) - repaired = maybe_repair_npm_engine( - str(system_npm), EBADENGINE_OUTPUT, quiet=True - ) - assert repaired == str(managed) - - def test_failing_pm_npm_reports_no_retry(self, tmp_path, monkeypatch, capsys): - """When the failing npm already IS pm's npm, a re-ensure cannot change - anything — no retry, manual guidance instead.""" - managed = tmp_path / "store" / "npm-x" / "npm" - managed.parent.mkdir(parents=True) - managed.write_text("#!/bin/sh\n", encoding="utf-8") - - monkeypatch.setattr( - npm_engine, "_pm_npm", lambda quiet=False: str(managed) - ) - assert maybe_repair_npm_engine(str(managed), EBADENGINE_OUTPUT) is None - err = capsys.readouterr().err - assert 'npm install -g npm@"<11.10.0 || >=12.0.0"' in err - - def test_failed_provisioning_prints_manual_fix(self, tmp_path, monkeypatch, capsys): - system_npm = tmp_path / "usr-bin-npm" - system_npm.write_text("#!/bin/sh\n", encoding="utf-8") - - monkeypatch.setattr(npm_engine, "_pm_npm", lambda quiet=False: None) - assert not maybe_repair_npm_engine(str(system_npm), EBADENGINE_OUTPUT) - - # The user gets the exact command to run, since we refuse to run it. - err = capsys.readouterr().err - assert 'npm install -g npm@"<11.10.0 || >=12.0.0"' in err - - def test_non_engine_failure_never_repairs(self, tmp_path, monkeypatch): - def explode(quiet=False): # pragma: no cover - must not be reached - raise AssertionError("a lockfile mismatch must not trigger a repair") - - monkeypatch.setattr(npm_engine, "_pm_npm", explode) - npm = tmp_path / "npm" - npm.write_text("#!/bin/sh\n", encoding="utf-8") - assert not maybe_repair_npm_engine(str(npm), ELOCK_OUTPUT, quiet=True) - - def test_node_only_mismatch_on_foreign_npm_still_provisions( - self, tmp_path, monkeypatch - ): - """A too-old system NODE can't be fixed by any npm upgrade, but the - pm store ships a supported Node — provisioning covers it.""" - system_npm = tmp_path / "usr-bin-npm" - system_npm.write_text("#!/bin/sh\n", encoding="utf-8") - node_only = ( - "npm error code EBADENGINE\n" - 'npm error notsup Required: {"node":">=20.0.0"}\n' - 'npm error notsup Actual: {"npm":"10.9.8","node":"v18.0.0"}\n' - ) - managed = tmp_path / "store" / "npm-x" / "npm" - managed.parent.mkdir(parents=True) - managed.write_text("#!/bin/sh\n", encoding="utf-8") - - monkeypatch.setattr( - npm_engine, "_pm_npm", lambda quiet=False: str(managed) - ) - repaired = maybe_repair_npm_engine(str(system_npm), node_only, quiet=True) - assert repaired == str(managed) - - -class TestRepoRangeIsSatisfiable: - """Invariant: whatever the root package.json demands, the recovery can - parse and act on it — a malformed range would make the repair a no-op.""" - - def test_root_engines_npm_range_is_a_usable_constraint(self): - repo_root = Path(__file__).resolve().parents[2] - package_json = repo_root / "package.json" - engines = json.loads(package_json.read_text(encoding="utf-8")).get("engines", {}) - npm_range = engines.get("npm") - if not npm_range: - pytest.skip("root package.json does not pin engines.npm") - - synthetic = ( - "npm error code EBADENGINE\n" - 'npm error notsup Required: ' - + json.dumps({"node": ">=20.0.0", "npm": npm_range}) - + "\n" - ) - assert required_npm_range(synthetic) == npm_range + monkeypatch.setattr(pm, "ensure", forbidden_ensure) + with pytest.raises(SystemExit) as stopped: + 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_plugin_dependency_consent.py b/tests/hermes_cli/test_plugin_dependency_consent.py index 0a4493d0ee..ed5b36b4c1 100644 --- a/tests/hermes_cli/test_plugin_dependency_consent.py +++ b/tests/hermes_cli/test_plugin_dependency_consent.py @@ -55,12 +55,12 @@ def test_node_sidecar_question_stays_independent(tmp_path, monkeypatch): monkeypatch.setattr(plugins_cmd.sys.stdin, 'isatty', lambda: True) monkeypatch.setattr(plugins_cmd.sys.stdout, 'isatty', lambda: True) monkeypatch.setattr('builtins.input', lambda prompt: prompts.append(prompt) or 'yes') - monkeypatch.setattr(workspace, 'install_node_sidecar', lambda path: installs.append(path)) + monkeypatch.setattr(workspace, 'install_node_sidecar', lambda path, **kwargs: installs.append((path, kwargs))) from types import SimpleNamespace result = plugins_cmd._install_plugin_python_deps( {'name': 'sidecar'}, target, SimpleNamespace(print=lambda *args, **kwargs: lines.extend(args))) assert result == (True, None) - assert installs == [target] + assert installs == [(target, {'explicit': True})] assert len(prompts) == 1 and 'node_modules' in prompts[0] assert not any('Python dependencies' in str(line) for line in lines) diff --git a/tests/hermes_cli/test_post_setup_gating.py b/tests/hermes_cli/test_post_setup_gating.py index 3f5c0aebf7..4768e3ef80 100644 --- a/tests/hermes_cli/test_post_setup_gating.py +++ b/tests/hermes_cli/test_post_setup_gating.py @@ -56,40 +56,3 @@ class TestPostSetupGate: monkeypatch.setitem(tools_config._POST_SETUP_INSTALLED, "cua_driver", _boom) assert tools_config._post_setup_already_installed("cua_driver") is True - - -import pytest - - -@pytest.mark.parametrize("key,extra", [ - ("faster_whisper", "stt-whisper"), ("kittentts", "kittentts"), - ("piper", "piper"), ("ddgs", "ddgs"), ("langfuse", "langfuse"), -]) -@pytest.mark.parametrize("succeeds", [True, False]) -def test_python_provider_setup_records_extra_and_preserves_failure(key, extra, succeeds, monkeypatch, capsys): - import pm - from hermes_cli import tools_config_post_setup as post, plugins_cmd - - calls = [] - enabled = [] - monkeypatch.setattr(post, "_importable", lambda module: False) - monkeypatch.setattr(plugins_cmd, "_get_enabled_set", lambda: set()) - monkeypatch.setattr(plugins_cmd, "_save_enabled_set", lambda names: enabled.extend(names)) - - def sync(extras, *, explicit): - calls.append((extras, explicit)) - if not succeeds: - raise pm.InstallError("venv", "resolution refused") - - monkeypatch.setattr(pm, "sync_venv", sync) - post._run_post_setup(key) - assert calls == [([extra], True)] - output = capsys.readouterr().out - if succeeds: - assert "Restart Hermes" in output - if key == "langfuse": - assert enabled == ["observability/langfuse"] - else: - assert "resolution refused" in output - assert "Retry with: hermes tools" in output - assert not enabled diff --git a/tests/hermes_cli/test_source_build.py b/tests/hermes_cli/test_source_build.py index 538c3ee0aa..74656de50a 100644 --- a/tests/hermes_cli/test_source_build.py +++ b/tests/hermes_cli/test_source_build.py @@ -19,6 +19,50 @@ import pm from pm.package import Runner +def copy_freshness_scripts(root): + repository = Path(__file__).resolve().parents[2] + scripts = root / "scripts/build" + scripts.mkdir(parents=True, exist_ok=True) + for name in ("freshness.mjs", "frontend-common.mjs"): + shutil.copy2(repository / "scripts/build" / name, scripts / name) + + +def stamp_product(root, product, out): + script = (root / "scripts/build/freshness.mjs").as_uri() + subprocess.run([shutil.which("node"), "--input-type=module", "-e", + f"import {{recordProduct, buildInputs}} from {json.dumps(script)};" + "const [source, product, out] = process.argv.slice(1);" + "recordProduct({source, product, out, inputs: buildInputs(source, product)});", + str(root), product, str(out)], check=True) + + +def test_automatic_build_preserves_pm_admission_intent(monkeypatch): + from hermes_cli.source_build import source_build_env + + intent = [] + def acquire(name, *, base_env, explicit): + intent.append(explicit) + return Runner(name, base_env) + monkeypatch.setattr(pm, "ensure", acquire) + source_build_env() + assert intent == [False] + + +def test_installed_npm_does_not_authorize_missing_workspace_dependencies(source_checkout, monkeypatch): + from hermes_cli.source_build import prepare_source_dependencies, source_build_env + + root, _ = source_checkout + env = source_build_env() + monkeypatch.setenv("HERMES_DISABLE_LAZY_INSTALLS", "1") + with pytest.raises(subprocess.CalledProcessError): + prepare_source_dependencies(root, ("ui-tui", "web"), env=env) + assert _events(root) == [] + prepare_source_dependencies(root, ("ui-tui", "web"), env=env, explicit=True) + events = _events(root) + prepare_source_dependencies(root, ("ui-tui", "web"), env=env) + assert _events(root) == events + + @pytest.fixture def source_checkout(tmp_path, monkeypatch): node, npm = shutil.which("node"), shutil.which("npm") @@ -27,6 +71,7 @@ def source_checkout(tmp_path, monkeypatch): home.mkdir() monkeypatch.setenv("HERMES_HOME", str(home)) monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "tools")) + monkeypatch.setenv("HERMES_DISABLE_LAZY_INSTALLS", "0") monkeypatch.setenv("npm_config_cache", str(tmp_path / "npm-cache")) monkeypatch.setenv("ESBUILD_BINARY_PATH", "/wrong/esbuild") monkeypatch.setenv("HERMES_PYTHON", "/wrong/python") @@ -37,9 +82,9 @@ def source_checkout(tmp_path, monkeypatch): def acquire(name, *, base_env=None, explicit=False): acquired.append(name) assert name == "npm" - assert explicit + return Runner(name, {**(base_env or os.environ), "PATH": os.pathsep.join( - [str(Path(node).parent), str(Path(npm).parent), os.environ["PATH"]])}) + [str(Path(npm).parent), str(Path(node).parent), os.environ["PATH"]])}) monkeypatch.setattr(pm, "ensure", acquire) root = tmp_path / "source with spaces" @@ -53,7 +98,8 @@ def source_checkout(tmp_path, monkeypatch): directory.mkdir(parents=True) (directory / "package.json").write_text(json.dumps({ "name": workspace.replace("/", "-"), "version": "1.0.0", - "scripts": {"pack": "node ../../scripts/build/package-desktop.mjs"} + "scripts": {"build": "node ../../scripts/build/build-desktop.mjs", + "builder": "node ../../scripts/build/package-desktop.mjs"} if workspace == "apps/desktop" else {}, }), encoding="utf-8") (root / "log.mjs").write_text( @@ -69,7 +115,8 @@ def source_checkout(tmp_path, monkeypatch): scripts = root / "scripts" / "build" scripts.mkdir(parents=True) repository = Path(__file__).resolve().parents[2] - shutil.copy2(repository / "scripts/build/node-deps.mjs", scripts / "node-deps.mjs") + for name in ("node-deps.mjs", "freshness.mjs", "frontend-common.mjs"): + shutil.copy2(repository / "scripts/build" / name, scripts / name) (root / ".gitignore").write_text("node_modules/\n**/dist/\n", encoding="utf-8") return root, acquired @@ -81,6 +128,7 @@ def source_products(source_checkout): "import { appendFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';\n" "import { dirname, join } from 'node:path';\n" "import { fileURLToPath } from 'node:url';\n" + "import { recordProduct, buildInputs } from './scripts/build/freshness.mjs';\n" "const root = dirname(fileURLToPath(import.meta.url));\n" "export function build(step, output) {\n" " appendFileSync(join(root, 'events.jsonl'), JSON.stringify({step}) + '\\n');\n" @@ -88,6 +136,12 @@ def source_products(source_checkout): " if (step === 'web' && !existsSync(join(root, 'web/public/favicon.ico'))) throw new Error('icons missing');\n" " const path = join(root, output); mkdirSync(dirname(path), { recursive: true });\n" " writeFileSync(path, step);\n" + " if (step !== 'icons') {\n" + " let out = dirname(path);\n" + " if (step === 'desktop') { out = join(out, 'resources/app.asar.unpacked/dist');\n" + " mkdirSync(out, {recursive: true}); writeFileSync(join(out, 'index.html'), 'renderer'); }\n" + " recordProduct({source: root, product: step, out, inputs: buildInputs(root, step)});\n" + " }\n" "}\n", encoding="utf-8", ) @@ -109,6 +163,10 @@ def source_products(source_checkout): "build('desktop', relative('../..', staging) + '/linux-unpacked/hermes');\n", encoding="utf-8", ) + (root / "scripts/build/build-desktop.mjs").write_text( + "if (!process.argv.includes('--icons')) await import('../generate-icons.mjs');\n", + encoding="utf-8", + ) return root, acquired diff --git a/tests/hermes_cli/test_source_launcher_publication.py b/tests/hermes_cli/test_source_launcher_publication.py index e3ac42abe3..d021a4eaf0 100644 --- a/tests/hermes_cli/test_source_launcher_publication.py +++ b/tests/hermes_cli/test_source_launcher_publication.py @@ -17,6 +17,9 @@ BOOT_FILES = ( "hermes_bootstrap.py", "hermes_constants.py", "hermes_cli/__init__.py", "hermes_cli/_launchers.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", + "scripts/hermes-gateway", ) @@ -35,6 +38,7 @@ def fixture_tree(tmp_path, monkeypatch): " print(json.dumps({'value': selected_probe.VALUE, 'argv': sys.argv[1:], " "'home': os.environ.get('HERMES_HOME'), 'exe': sys.executable}))\n" " return 7\n" + "if __name__ == '__main__':\n sys.exit(main())\n" ) for path in (repo / "hermes_cli/main.py", repo / "acp_adapter/entry.py"): path.write_text(entry, encoding="utf-8") @@ -105,14 +109,205 @@ def test_posix_materializer_publishes_only_executable_shell_launchers(tmp_path, assert {p.name for p in launchers} == set(_launchers.ENTRY_POINTS) assert all(os.access(p, os.X_OK) for p in launchers) assert set(out.iterdir()) == set(launchers) + local = repo / ".hermes" / "bin" + assert {p.name for p in local.iterdir()} == set(_launchers.ENTRY_POINTS) def test_materializer_cli_refuses_missing_store_without_publishing(tmp_path, monkeypatch): repo, home, _interpreter = fixture_tree(tmp_path, monkeypatch) (home / "tools" / "facts.json").unlink() + orphan = home / "tools" / "python-unrecorded" / ("python.exe" if os.name == "nt" else "bin/python3") + orphan.parent.mkdir(parents=True) + orphan.touch() # uncommitted tool bytes are not an installed interpreter out = tmp_path / "bin" result = subprocess.run([sys.executable, "-I", str(repo / "hermes_cli/_launchers.py"), str(out)], cwd=tmp_path, capture_output=True, text=True, encoding="utf-8", timeout=30) assert result.returncode == 1, result.stdout + result.stderr assert "store interpreter" in result.stderr assert not out.exists() or not list(out.iterdir()) + + +def _command_survives_generation_collection(tmp_path, monkeypatch, surface): + from hermes_cli.runtime_state import collect_generations + + repo, home, interpreter = fixture_tree(tmp_path, monkeypatch) + out = tmp_path / "bin" + out.mkdir() + _launchers.ensure_install_launchers(repo, out) + launcher = next(path for path in out.iterdir() if path.stem == "hermes") + args = ["café ' quoted", "", "$HOME; not a shell"] + command = [] + selected = install_state_dir(repo) / "environments" / "old" / "venv" + for value in ("old", "new"): + selected = selected.parent.parent / value / "venv" + site = site_packages(selected) + site.mkdir(parents=True) + (selected / "pyvenv.cfg").write_text("home = fixture\n", encoding="utf-8") + (selected.parent / ".lease-managed").touch() + (site / "selected_probe.py").write_text(f"VALUE = {value!r}\n", encoding="utf-8") + (install_state_dir(repo) / "facts.json").write_text( + json.dumps({"packages": {"venv": {"environment": str(selected)}}}), encoding="utf-8") + if value == "old": + if surface == "legacy": + command = [sys.executable, "-I", str(repo / "scripts/hermes-gateway"), "--help"] + args = ["gateway", "--help"] + elif surface == "ssh": + from hermes_cli.windows_ssh_runtime import _resolve_direct_command + command = [*_resolve_direct_command(str(launcher)), *args] + elif surface == "published": + result = subprocess.run([str(launcher), "--print-runtime-command", "--", *args], + capture_output=True, text=True, timeout=30) + assert result.returncode == 0, result.stderr + command = json.loads(result.stdout) + else: + from hermes_cli import gateway + monkeypatch.setattr(gateway, "PROJECT_ROOT", repo) + if surface == "launchd": + import plistlib + unit = gateway.generate_launchd_plist() + command = plistlib.loads(unit.encode())["ProgramArguments"] + args = ["gateway", "run", "--external-supervisor"] + else: + unit = gateway.generate_systemd_unit() + command = shlex.split(next(line.removeprefix("ExecStart=") for line in unit.splitlines() + if line.startswith("ExecStart="))) + args = ["gateway", "run"] + assert str(selected.parent) not in unit + if surface not in ("legacy", "systemd", "launchd"): + assert Path(command[0]).samefile(interpreter) + assert collect_generations(repo, min_age_seconds=0) == [selected.parent.parent / "old"] + result = subprocess.run(command, cwd=tmp_path, capture_output=True, text=True, timeout=30) + assert result.returncode == 7, result.stderr + assert json.loads(result.stdout)["value"] == "new" + assert json.loads(result.stdout)["argv"] == args + + +@pytest.mark.parametrize("surface", ["published", "systemd", "launchd", "ssh", "legacy"]) +@pytest.mark.platforms("posix") +@pytest.mark.spawns_gateway_lookalike +def test_posix_commands_survive_generation_collection(tmp_path, monkeypatch, surface): + _command_survives_generation_collection(tmp_path, monkeypatch, surface) + + +@pytest.mark.parametrize("surface", ["published", "ssh"]) +@pytest.mark.platforms("windows") +def test_windows_commands_survive_generation_collection(tmp_path, monkeypatch, surface): + _command_survives_generation_collection(tmp_path, monkeypatch, surface) + + +@pytest.mark.platforms("windows") +def test_windows_repair_upgrades_healthy_old_pm_external_launchers(tmp_path, monkeypatch): + from hermes_cli._install_repair import ensure_windows_bin_launchers + + repo, home, interpreter = fixture_tree(tmp_path, monkeypatch) + managed = home / "hermes-agent" + shutil.move(repo, managed) + external = home / "bin" + external.mkdir() + for name in _launchers.ENTRY_POINTS: + (external / f"{name}.exe").write_bytes(b"old PM launcher without a venv binding") + assert ensure_windows_bin_launchers(managed, user_path_entries=[]) + local = managed / ".hermes" / "bin" + launcher = local / "hermes.exe" + if not launcher.exists(): + launcher = local / "hermes.cmd" + result = subprocess.run([str(launcher), "--print-runtime-command"], capture_output=True, + text=True, timeout=30) + assert result.returncode == 0, result.stderr + assert Path(json.loads(result.stdout)[0]).samefile(interpreter) + + +def test_dashboard_action_boots_selected_dependencies(tmp_path, monkeypatch): + from hermes_cli import web_server, web_server_gateway + + repo, home, _ = fixture_tree(tmp_path, monkeypatch) + selected = install_state_dir(repo) / "environments" / "current" / "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("VALUE = 'selected'\n", encoding="utf-8") + (install_state_dir(repo) / "facts.json").write_text( + json.dumps({"packages": {"venv": {"environment": str(selected)}}}), encoding="utf-8") + monkeypatch.setattr(web_server, "PROJECT_ROOT", repo) + monkeypatch.setattr(web_server_gateway, "_ACTION_LOG_DIR", home / "logs") + proc = web_server_gateway._spawn_hermes_action(["--version"], "gateway-restart") + try: + assert proc.wait(timeout=30) == 7 + log = (home / "logs" / web_server_gateway._ACTION_LOG_FILES["gateway-restart"]).read_text() + assert json.loads(log.splitlines()[-1])["value"] == "selected" + finally: + if proc.poll() is None: + proc.kill() + proc.wait(timeout=30) + + +@pytest.mark.parametrize("layout", ["legacy", "payload"]) +def test_pre_pm_base_dependencies_activate_only_at_boot(tmp_path, monkeypatch, layout): + repo, _, _ = fixture_tree(tmp_path, monkeypatch) + environment = repo / "venv" + if layout == "payload": + environment = repo.parent / "payload-deps" + (repo.parent / "manifest.json").write_text( + json.dumps({"repo": repo.name, "venv": environment.name}), encoding="utf-8") + site = site_packages(environment) + site.mkdir(parents=True) + editable = tmp_path / "editable" + editable.mkdir() + (editable / "selected_probe.py").write_text("VALUE = 'base-pth'\n", encoding="utf-8") + (site / "member.pth").write_text(str(editable) + "\n", encoding="utf-8") + command = _launchers.runtime_command(repo) + result = subprocess.run(command, cwd=tmp_path, capture_output=True, text=True, timeout=30) + assert result.returncode == 7, result.stderr + assert json.loads(result.stdout)["value"] == "base-pth" + + +def test_external_interpreter_keeps_its_owned_dependencies(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home")) + monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "empty-store")) + command = _launchers.runtime_command(ROOT, code="import ruamel.yaml; print('external-runtime-ready')") + result = subprocess.run(command, cwd=tmp_path, capture_output=True, text=True, timeout=30) + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == "external-runtime-ready" + + +@pytest.mark.platforms("posix") +@pytest.mark.spawns_gateway_lookalike +def test_service_survives_python_tool_replacement(tmp_path, monkeypatch): + from hermes_cli import gateway + + repo, home, interpreter = fixture_tree(tmp_path, monkeypatch) + monkeypatch.setattr(gateway, "PROJECT_ROOT", repo) + site = site_packages(repo / "venv") + site.mkdir(parents=True) + (site / "selected_probe.py").write_text("VALUE = 'ready'\n", encoding="utf-8") + store = home / "tools" + for version in ("python-A", "python-B"): + python = store / version / "bin" / "python3" + python.parent.mkdir(parents=True) + python.symlink_to(interpreter) + (store / "facts.json").write_text(json.dumps({"packages": {"python": {"entry": version}}}), encoding="utf-8") + gateway._prepare_service_launcher() + if version == "python-A": + unit = gateway.generate_systemd_unit() + assert str(store / version) not in unit + command = shlex.split(next(line.split("=", 1)[1] for line in unit.splitlines() if line.startswith("ExecStart="))) + shutil.rmtree(store / "python-A") + result = subprocess.run(command, cwd=tmp_path, capture_output=True, text=True, timeout=30) + assert result.returncode == 7, result.stderr + assert json.loads(result.stdout)["value"] == "ready" + + +def test_update_import_probe_uses_selected_dependencies(tmp_path, monkeypatch): + from hermes_cli import update_cmd, update_cmd_validation + + repo, _, _ = fixture_tree(tmp_path, monkeypatch) + selected = install_state_dir(repo) / "environments" / "current" / "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("VALUE = 'selected'\n", encoding="utf-8") + (install_state_dir(repo) / "facts.json").write_text( + json.dumps({"packages": {"venv": {"environment": str(selected)}}}), encoding="utf-8") + (repo / "hermes_integrity_probe.py").write_text("import selected_probe\n", encoding="utf-8") + monkeypatch.setattr(update_cmd, "_UPDATE_CRITICAL_MODULES", ("hermes_integrity_probe",)) + assert update_cmd_validation._critical_module_import_failures(repo, report_runtime_errors=True) == {} diff --git a/tests/hermes_cli/test_tools_config.py b/tests/hermes_cli/test_tools_config.py index eb915ead80..44b67e52f4 100644 --- a/tests/hermes_cli/test_tools_config.py +++ b/tests/hermes_cli/test_tools_config.py @@ -233,6 +233,7 @@ def test_first_install_nous_auto_configures_video_gen(monkeypatch, tmp_path): at runtime. Regression test for the bug where video_gen was marked as auto-configured but no config was actually written.""" monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "tools")) + monkeypatch.setenv("PATH", str(tmp_path / "empty-path")) monkeypatch.setattr("tools.tool_backend_helpers.managed_nous_tools_enabled", lambda: True) config = { "model": {"provider": "nous"}, @@ -373,43 +374,20 @@ def test_numeric_mcp_server_name_does_not_crash_sorted(): class TestAgentBrowserPostSetup: - """_run_post_setup('agent_browser'/'browserbase') — #43564. - - CLI readiness uses the runtime resolution cascade. Binary installation - belongs to pm; npx is only used for apt system dependencies. - """ + """Cloud isolation, image ownership, and failed setup remain observable.""" @pytest.fixture(autouse=True) def _stub_browser_use_install(self): - """Both browser branches now attempt a Browser Use CLI install first - (the CLI drives every non-Camofox backend). Stub it so these - Chromium-branch tests never bootstrap uv / hit the network, and so - their print/subprocess assertions stay scoped to the agent-browser - logic under test.""" with patch("hermes_cli.tools_config_post_setup._ensure_browser_use_cli") as stub: yield stub @pytest.fixture(autouse=True) - def _stub_chromium_install(self): + def _stub_package_install(self): with patch("pm.ensure") as ensure: yield ensure - def test_warns_when_neither_npx_nor_agent_browser_on_path(self): - with patch("shutil.which", return_value=None), patch( - "subprocess.run" - ) as run, patch("hermes_cli.tools_config_post_setup._print_warning") as warn: - _run_post_setup("agent_browser") - - run.assert_not_called() - warn.assert_called_once() - assert "npx not found" in warn.call_args.args[0] - def test_browserbase_returns_before_any_chromium_check(self): - """browserbase hosts its own Chromium; it must never reach the - agent-browser-only Chromium-install branch.""" - with patch("shutil.which", return_value="/usr/bin/npx"), patch( - "subprocess.run" - ) as run, patch( + with patch("subprocess.run") as run, patch( "tools.browser_tool_install._chromium_installed" ) as chromium_check: _run_post_setup("browserbase") @@ -417,242 +395,36 @@ class TestAgentBrowserPostSetup: run.assert_not_called() chromium_check.assert_not_called() - def test_chromium_already_installed_skips_subprocess(self): - with patch("shutil.which", return_value="/usr/bin/npx"), patch( - "tools.browser_tool_install.node_tool_runnable", return_value=True - ), patch( - "subprocess.run" - ) as run, patch( - "tools.browser_tool_install._chromium_installed", return_value=True - ), patch( - "hermes_cli.tools_config_post_setup._print_success" - ) as success: - _run_post_setup("agent_browser") - - run.assert_not_called() - success.assert_called_once() - assert "already installed" in success.call_args.args[0] - - def test_docker_with_missing_chromium_warns_instead_of_installing(self): - with patch("shutil.which", return_value="/usr/bin/npx"), patch( - "tools.browser_tool_install.node_tool_runnable", return_value=True - ), patch( - "subprocess.run" - ) as run, patch( - "tools.browser_tool_install._chromium_installed", return_value=False - ), patch( - "tools.browser_tool_install._running_in_docker", return_value=True - ), patch( - "hermes_cli.tools_config_post_setup._print_warning" - ) as warn: - _run_post_setup("agent_browser") - - run.assert_not_called() - assert any("Docker" in c.args[0] for c in warn.call_args_list) - - def test_find_agent_browser_not_found_warns_before_any_chromium_check(self): - """_find_agent_browser is resolved up front now (shared with the - browserbase early-return gate), so a FileNotFoundError here must - short-circuit before even checking Chromium/Docker status.""" - with patch("shutil.which", return_value="/usr/bin/npx"), patch( - "subprocess.run" - ) as run, patch( - "tools.browser_tool_install._chromium_installed" - ) as chromium_check, patch( - "tools.browser_tool_install._running_in_docker" - ) as docker_check, patch( - "tools.browser_tool_install._find_agent_browser", - side_effect=FileNotFoundError("agent-browser CLI not found"), - ), patch( - "hermes_cli.tools_config_post_setup._print_warning" - ) as warn: - _run_post_setup("agent_browser") - - run.assert_not_called() - chromium_check.assert_not_called() - docker_check.assert_not_called() - assert any("browser tools require Node.js" in c.args[0] for c in warn.call_args_list) - - @pytest.mark.platforms("linux") - @pytest.mark.parametrize("returncode", [0, 1]) - def test_installs_only_system_dependencies_via_npx(self, _stub_chromium_install, returncode): - """Apt failure must not cause a second browser download or change global env.""" - import os + def test_docker_with_missing_chromium_warns_instead_of_installing(self, _stub_package_install): with patch( - "shutil.which", - # accepts the `path=` kwarg _resolve_npx_bin's extended-path rung - # calls shutil.which with, not just the bare-PATH positional form. - side_effect=lambda name, path=None: f"/usr/bin/{name}" if name in {"npx", "apt-get"} else None, - ), patch( - "tools.browser_tool_install.node_tool_runnable", return_value=True + "tools.browser_tool_install._find_agent_browser", return_value="/image/agent-browser" ), patch("subprocess.run") as run, patch( "tools.browser_tool_install._chromium_installed", return_value=False ), patch( - "tools.browser_tool_install._running_in_docker", return_value=False - ), patch( - "tools.browser_tool_install._find_agent_browser", return_value="npx agent-browser" - ), patch( - "hermes_cli.tools_config_post_setup._print_success" + "tools.browser_tool_install._running_in_docker", return_value=True ), patch("hermes_cli.tools_config_post_setup._print_warning") as warn: - run.return_value = SimpleNamespace(returncode=returncode, stdout="", stderr="apt failed") - before = dict(os.environ) _run_post_setup("agent_browser") - assert dict(os.environ) == before - - run.assert_called_once() - assert run.call_args.args[0] == [ - "/usr/bin/npx", "--ignore-scripts", "-y", "playwright@1.62.1", "install-deps", "chromium", - ] - _stub_chromium_install.assert_called_once_with("chromium", explicit=True) - if returncode: - assert any("system dependency install failed" in c.args[0] for c in warn.call_args_list) - else: - warn.assert_not_called() - - @pytest.mark.platforms("linux") - def test_installs_system_dependencies_via_managed_npx(self): - """Dependency setup must reuse runtime npx resolution, including managed Node.""" - hermes_npx = "/home/user/.hermes/node/bin/npx" - with patch("shutil.which", side_effect=lambda name: "/usr/bin/apt-get" if name == "apt-get" else None), patch( - "subprocess.run" - ) as run, patch( - "tools.browser_tool_install._chromium_installed", return_value=False - ), patch( - "tools.browser_tool_install._running_in_docker", return_value=False - ), patch( - "tools.browser_tool_install._find_agent_browser", return_value="npx agent-browser" - ), patch( - "tools.browser_tool_install._resolve_npx_bin", return_value=hermes_npx - ), patch( - "hermes_cli.tools_config_post_setup._print_success" - ): - run.return_value = SimpleNamespace(returncode=0, stdout="", stderr="") - _run_post_setup("agent_browser") - - run.assert_called_once() - assert run.call_args.args[0] == [ - hermes_npx, "--ignore-scripts", "-y", "playwright@1.62.1", "install-deps", "chromium", - ] - assert run.call_args.kwargs["env"]["PATH"].startswith("/home/user/.hermes/node/bin:") - - @pytest.mark.platforms("linux") - def test_warns_when_system_dependency_installer_is_unavailable(self, _stub_chromium_install): - """A missing npx must not prevent the managed Chromium download.""" - with patch("shutil.which", return_value="/usr/bin/apt-get"), patch( - "subprocess.run" - ) as run, patch( - "tools.browser_tool_install._chromium_installed", return_value=False - ), patch( - "tools.browser_tool_install._running_in_docker", return_value=False - ), patch( - "tools.browser_tool_install._find_agent_browser", return_value="npx agent-browser" - ), patch( - "tools.browser_tool_install._resolve_npx_bin", return_value=None - ), patch( - "hermes_cli.tools_config_post_setup._print_warning" - ) as warn: - _run_post_setup("agent_browser") # must not raise run.assert_not_called() - _stub_chromium_install.assert_called_once_with("chromium", explicit=True) - assert any("npx not found" in c.args[0] for c in warn.call_args_list) + _stub_package_install.assert_not_called() + assert any("Docker" in c.args[0] for c in warn.call_args_list) - @pytest.mark.parametrize("browser_cmd", ["/usr/local/bin/agent-browser", "npx agent-browser"]) - def test_installs_chromium_via_pm_without_mutating_environment(self, browser_cmd): - """Both CLI resolution paths must use the full-Chromium package, not - agent-browser's installer (which also downloads headless shell).""" - import os - - with patch("shutil.which", return_value=None), patch( - "subprocess.run" - ) as run, patch( - "tools.browser_tool_install._chromium_installed", return_value=False - ), patch( - "tools.browser_tool_install._running_in_docker", return_value=False - ), patch( - "tools.browser_tool_install._find_agent_browser", return_value=browser_cmd - ), patch("pm.ensure") as ensure: - ensure.return_value.env = {"PLAYWRIGHT_BROWSERS_PATH": "/managed/chromium"} - run.return_value = SimpleNamespace(returncode=0, stdout="", stderr="") - before = dict(os.environ) - _run_post_setup("agent_browser") - assert dict(os.environ) == before - - ensure.assert_called_once_with("chromium", explicit=True) - run.assert_not_called() - - def test_install_success_invalidates_chromium_cache(self): - import tools.browser_tool as _bt - - with patch("shutil.which", return_value="/usr/bin/npx"), patch( - "tools.browser_tool_install.node_tool_runnable", return_value=True - ), patch( - "subprocess.run", - return_value=SimpleNamespace(returncode=0, stdout="", stderr=""), - ), patch( - "tools.browser_tool_install._chromium_installed", return_value=False - ), patch( - "tools.browser_tool_install._running_in_docker", return_value=False - ), patch( - "tools.browser_tool_install._find_agent_browser", return_value="npx agent-browser" - ), patch( - "hermes_cli.tools_config_post_setup._print_success" - ): - _bt._cached_chromium_installed = True - _run_post_setup("agent_browser") - - assert _bt._cached_chromium_installed is None, ( - "a successful install must invalidate the cached chromium-missing " - "result so the next check_browser_requirements() call re-probes" - ) - - def test_install_failure_reports_pm_error_and_does_not_invalidate_cache(self): + @pytest.mark.parametrize("failure", ["pm", "timeout"]) + def test_install_failure_reports_error(self, failure): import pm - import tools.browser_tool as _bt - with patch("shutil.which", return_value="/usr/bin/npx"), patch( - "tools.browser_tool_install.node_tool_runnable", return_value=True - ), patch( - "pm.ensure", side_effect=pm.InstallError("chromium", "fatal: network error") - ), patch( - "tools.browser_tool_install._chromium_installed", return_value=False - ), patch( + error = (pm.InstallError("agent-browser", "fatal: network error") if failure == "pm" + else subprocess.TimeoutExpired(cmd=["agent-browser"], timeout=600)) + with patch("pm.ensure", side_effect=error), patch( "tools.browser_tool_install._running_in_docker", return_value=False - ), patch( - "tools.browser_tool_install._find_agent_browser", return_value="npx agent-browser" - ), patch( - "hermes_cli.tools_config_post_setup._print_warning" - ) as warn, patch( + ), patch("hermes_cli.tools_config_post_setup._print_warning") as warn, patch( "hermes_cli.tools_config_post_setup._print_info" ) as info: - _bt._cached_chromium_installed = "sentinel" _run_post_setup("agent_browser") - assert any("Chromium install failed" in c.args[0] for c in warn.call_args_list) - assert any("fatal: network error" in c.args[0] for c in warn.call_args_list) - assert any("hermes pm install chromium" in c.args[0] for c in info.call_args_list) - assert _bt._cached_chromium_installed == "sentinel", ( - "a failed install must not invalidate the chromium cache" - ) + assert any(str(error) in c.args[0] for c in warn.call_args_list) + assert any("hermes tools post-setup agent_browser" in c.args[0] for c in info.call_args_list) - def test_install_timeout_warns_without_raising(self): - with patch("shutil.which", return_value="/usr/bin/npx"), patch( - "tools.browser_tool_install.node_tool_runnable", return_value=True - ), patch( - "pm.ensure", - side_effect=subprocess.TimeoutExpired(cmd=["chromium"], timeout=600), - ), patch( - "tools.browser_tool_install._chromium_installed", return_value=False - ), patch( - "tools.browser_tool_install._running_in_docker", return_value=False - ), patch( - "tools.browser_tool_install._find_agent_browser", return_value="npx agent-browser" - ), patch( - "hermes_cli.tools_config_post_setup._print_warning" - ) as warn: - _run_post_setup("agent_browser") # must not raise - - assert any("timed out" in c.args[0] for c in warn.call_args_list) class TestBrowserUseCliInstalledForAllNonCamofoxBackends: @@ -665,7 +437,7 @@ class TestBrowserUseCliInstalledForAllNonCamofoxBackends: def test_browser_post_setup_attempts_cli_install(self, key): with patch("hermes_cli.tools_config_post_setup._ensure_browser_use_cli") as ensure, patch( "shutil.which", return_value=None - ), patch("subprocess.run"): + ), patch("subprocess.run"), patch("pm.ensure"): _run_post_setup(key) ensure.assert_called_once() @@ -1021,7 +793,7 @@ def test_visible_providers_reuses_pool_video_feature_snapshot(monkeypatch): # The GUI's "Run setup" idempotence rides on provider_readiness_status # reporting ready/needs_setup honestly. agent_browser (local browser) must # track the FULL local install (CLI + Chromium), the cloud-provider hook -# ("browserbase") only the CLI, and camofox its npm package. +# ("browserbase") only the CLI, and Camofox its external server. # ── Toolsets that shipped after a platform's last `hermes tools` save ──────── diff --git a/tests/hermes_cli/test_tools_config_post_setup.py b/tests/hermes_cli/test_tools_config_post_setup.py new file mode 100644 index 0000000000..ef19c8105e --- /dev/null +++ b/tests/hermes_cli/test_tools_config_post_setup.py @@ -0,0 +1,168 @@ +"""Provider setup delegates package policy to PM and external server owners.""" + +import os +import sys +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from threading import Thread +from types import ModuleType +from unittest.mock import patch + +import pm +import pytest + +from hermes_cli.tools_config_post_setup import _run_post_setup + + +def test_cloud_browser_explicit_setup_ensures_managed_driver(): + with ( + patch("tools.browser_use_cli.install_cli", return_value=(True, "ready")), + patch("tools.browser_tool_install._find_agent_browser", return_value="/external/agent-browser"), + patch("pm.ensure") as ensure, + patch("subprocess.run") as run, + ): + _run_post_setup("browserbase") + + ensure.assert_called_once_with("agent-browser", explicit=True) + run.assert_not_called() + + +@pytest.mark.platforms("linux") +@pytest.mark.parametrize("playwright_present", [True, False]) +def test_local_setup_uses_pm_dependency_union_and_gives_system_library_guidance( + capsys, playwright_present, +): + with ( + patch("tools.browser_use_cli.install_cli", return_value=(True, "ready")), + patch("tools.browser_tool_install._running_in_docker", return_value=False), + patch("tools.browser_tool_install._chromium_installed", return_value=False), + patch("importlib.util.find_spec", return_value=object() if playwright_present else None), + patch("shutil.which", return_value="/usr/bin/apt-get"), + patch("pm.ensure") as ensure, + patch("subprocess.run") as run, + ): + before = dict(os.environ) + _run_post_setup("agent_browser") + assert dict(os.environ) == before + + ensure.assert_called_once_with("agent-browser", explicit=True) + run.assert_not_called() + output = capsys.readouterr().out + assert "system" in output + if playwright_present: + assert sys.executable in output + assert "-m playwright install-deps chromium" in output + else: + assert "https://playwright.dev/python/docs/browsers#install-system-dependencies" in output + assert "npx" not in output + + +@pytest.mark.parametrize("status", [200, 503]) +def test_camofox_setup_leaves_external_server_and_config_owned_by_user(monkeypatch, capsys, status): + from hermes_cli.config import get_hermes_home + from hermes_cli.tools_config_post_setup import _POST_SETUP_READY + + class Health(BaseHTTPRequestHandler): + def do_GET(self): + assert self.path == "/health" + self.send_response(status) + self.end_headers() + self.wfile.write(b"{}") + + def log_message(self, format, *args): + pass + + config_path = get_hermes_home() / "config.yaml" + config_path.write_text("browser:\n cloud_provider: camofox\n", encoding="utf-8") + before = config_path.read_bytes() + with ThreadingHTTPServer(("127.0.0.1", 0), Health) as server: + thread = Thread(target=server.serve_forever, daemon=True) + thread.start() + monkeypatch.setenv("CAMOFOX_URL", f"http://127.0.0.1:{server.server_port}") + try: + with ( + patch("hermes_constants.find_node_executable", return_value="/external/npm"), + patch("subprocess.run") as run, + patch("pm.ensure") as ensure, + ): + _run_post_setup("camofox") + run.assert_not_called() + ensure.assert_not_called() + assert _POST_SETUP_READY["camofox"]() is (status == 200) + finally: + server.shutdown() + thread.join(timeout=5) + + assert config_path.read_bytes() == before + output = capsys.readouterr().out + assert "externally managed" in output + assert ("reachable" if status == 200 else "not reachable") in output + + +@pytest.mark.parametrize("key,extra", [ + ("ddgs", "ddgs"), ("faster_whisper", "stt-whisper"), + ("kittentts", "kittentts"), ("piper", "piper"), +]) +@pytest.mark.parametrize("refused", [False, True]) +def test_importable_sdk_does_not_bypass_pm_constraints(monkeypatch, capsys, key, extra, refused): + sdk = ModuleType(key) + sdk.__version__ = "0.0.0" + monkeypatch.setitem(sys.modules, key, sdk) + error = pm.InstallError("venv", "outside frozen feature set") if refused else None + with patch("pm.sync_venv", side_effect=error) as sync: + _run_post_setup(key) + + sync.assert_called_once_with([extra], explicit=True) + output = capsys.readouterr().out + if refused: + assert "outside frozen feature set" in output + assert "Retry with: hermes tools" in output + assert "Restart Hermes" not in output + else: + assert "Restart Hermes" in output + + +@pytest.mark.parametrize("failure", [None, "sdk", "admission"]) +def test_langfuse_setup_uses_plugin_admission_and_preserves_config_on_refusal( + monkeypatch, tmp_path, capsys, failure, +): + from hermes_cli.config import get_hermes_home, read_raw_config + + monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "runtime")) + monkeypatch.setitem(sys.modules, "langfuse", ModuleType("langfuse")) + config_path = get_hermes_home() / "config.yaml" + config_path.write_text( + "plugins:\n enabled: [other]\n disabled: [langfuse, observability/langfuse]\n", + encoding="utf-8", + ) + before = config_path.read_bytes() + + def resolve_candidate(**kwargs): + assert kwargs["explicit"] is True + kwargs["plugin_dirs"]() + if failure == "admission": + raise pm.InstallError("venv", "candidate refused") + # The real admission publisher must commit both lists, not a second UI writer. + publication = kwargs["before_publish"]() + publication.finish() + + with ( + patch("pm.sync_venv", side_effect=pm.InstallError("venv", "SDK refused") if failure == "sdk" else None) as sdk, + patch("pm.client.sync_venv", side_effect=resolve_candidate) as admission, + ): + _run_post_setup("langfuse") + + sdk.assert_called_once_with(["langfuse"], explicit=True) + if failure == "sdk": + admission.assert_not_called() + else: + admission.assert_called_once() + if failure: + assert config_path.read_bytes() == before + output = capsys.readouterr().out + assert "refused" in output + if failure == "sdk": + assert "Retry with: hermes tools" in output + else: + plugin_config = read_raw_config()["plugins"] + assert set(plugin_config["enabled"]) == {"other", "observability/langfuse"} + assert plugin_config["disabled"] == [] diff --git a/tests/hermes_cli/test_tui_npm_install.py b/tests/hermes_cli/test_tui_npm_install.py index e366bcfcb2..71f313ff8e 100644 --- a/tests/hermes_cli/test_tui_npm_install.py +++ b/tests/hermes_cli/test_tui_npm_install.py @@ -23,7 +23,7 @@ def test_need_rebuild_when_tui_bundle_missing(tmp_path: Path) -> None: assert main_tui_launch._tui_need_rebuild(tmp_path) is True -def test_no_rebuild_when_tui_bundle_newer_than_inputs(tmp_path: Path) -> None: +def test_unreceipted_bundle_is_stale_even_when_newer(tmp_path: Path) -> None: _touch_tui_entry(tmp_path) src = tmp_path / "src" src.mkdir() @@ -31,7 +31,7 @@ def test_no_rebuild_when_tui_bundle_newer_than_inputs(tmp_path: Path) -> None: os.utime(src / "entry.tsx", (100, 100)) os.utime(tmp_path / "dist" / "entry.js", (200, 200)) - assert main_tui_launch._tui_need_rebuild(tmp_path) is False + assert main_tui_launch._tui_need_rebuild(tmp_path) is True def test_rebuild_when_tui_source_newer_than_bundle(tmp_path: Path) -> None: @@ -85,6 +85,8 @@ def test_source_compile_failure_stops_launch_without_reinstall(tui_source): def test_fresh_bundle_does_not_prepare_or_compile(tui_source, monkeypatch, termux): root, acquired = tui_source _touch_tui_entry(root / "ui-tui") + from tests.hermes_cli.test_source_build import stamp_product + stamp_product(root, "tui", root / "ui-tui/dist") if termux: monkeypatch.setenv("TERMUX_VERSION", "test") argv, cwd = main_tui_launch._make_tui_argv(root / "ui-tui", tui_dev=False) diff --git a/tests/hermes_cli/test_update_cua_pm.py b/tests/hermes_cli/test_update_cua_pm.py new file mode 100644 index 0000000000..7389112a38 --- /dev/null +++ b/tests/hermes_cli/test_update_cua_pm.py @@ -0,0 +1,105 @@ +"""Updates reconcile owned CUA pins without bypassing native host setup.""" + +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest + +import pm +from hermes_cli import tools_config_cua as setup +from hermes_cli import update_cmd_maint as update + + +@pytest.fixture +def refresh(monkeypatch): + monkeypatch.setattr(update, "_load_updates_cfg", lambda: {"refresh_cua_driver": True}) + monkeypatch.delenv("HERMES_CUA_DRIVER_CMD", raising=False) + monkeypatch.setenv("PATH", "") + installed = Mock(return_value=SimpleNamespace()) + ensure = Mock() + monkeypatch.setattr(pm, "installed_package", installed) + monkeypatch.setattr(pm, "ensure", ensure) + return installed, ensure + + +@pytest.mark.parametrize("disabled,installed,override", [ + (True, True, ""), + (False, False, ""), + (False, True, "custom-cua"), +]) +def test_cua_refresh_skips_disabled_missing_or_external_driver( + refresh, monkeypatch, disabled, installed, override, +): + lookup, ensure = refresh + monkeypatch.setattr(update, "_load_updates_cfg", lambda: {"refresh_cua_driver": not disabled}) + monkeypatch.setenv("HERMES_CUA_DRIVER_CMD", override) + lookup.return_value = SimpleNamespace() if installed else None + host_setup = Mock(side_effect=AssertionError("unexpected host setup")) + monkeypatch.setattr(setup, "install_cua_driver", host_setup) + + update._refresh_cua_driver_after_update() + + ensure.assert_not_called() + host_setup.assert_not_called() + + +@pytest.mark.platforms("linux") +def test_linux_refresh_reconciles_installed_pin(refresh): + lookup, ensure = refresh + update._refresh_cua_driver_after_update() + lookup.assert_called_once_with("cua-driver", allow_outdated=True) + ensure.assert_called_once_with("cua-driver", explicit=True) + + +@pytest.mark.platforms("windows") +def test_windows_refresh_defers_pin_and_uac_to_explicit_setup(refresh, monkeypatch, capsys): + """Keep the task's versioned executable selected until interactive re-registration.""" + _, ensure = refresh + host_setup = Mock(side_effect=AssertionError("unattended UAC")) + monkeypatch.setattr(setup, "install_cua_driver", host_setup) + + update._refresh_cua_driver_after_update() + + ensure.assert_not_called() + host_setup.assert_not_called() + output = capsys.readouterr().out + assert "deferred" in output.lower() + assert "UAC" in output + assert "hermes computer-use install --upgrade" in output + + +@pytest.mark.platforms("macos") +@pytest.mark.parametrize("registration_exit", [0, 1]) +def test_macos_refresh_registers_post_ensure_signed_app( + refresh, monkeypatch, tmp_path, capsys, registration_exit, +): + """Exercise native setup dispatch; no real LaunchServices or codesign writes.""" + from tools.computer_use import cua_backend_daemon as daemon + + lookup, ensure = refresh + old = tmp_path / "old-cua-driver" + app = tmp_path / "new-pin" / "CuaDriver.app" + binary = app / "Contents" / "MacOS" / "cua-driver" + binary.parent.mkdir(parents=True) + binary.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + binary.chmod(0o755) + lookup.return_value = SimpleNamespace(binary=old) + + def select_new(*args, **kwargs): + lookup.return_value = SimpleNamespace(binary=binary) + + ensure.side_effect = select_new + contract = Mock(return_value={"ready": True}) + monkeypatch.setattr(setup, "_cua_driver_contract_status", contract) + validate = Mock() + monkeypatch.setattr(daemon, "_validate_cua_driver_app_signature", validate) + register = Mock(return_value=SimpleNamespace(returncode=registration_exit, stdout="", stderr="")) + monkeypatch.setattr(setup, "_run_text", register) + + update._refresh_cua_driver_after_update() + + ensure.assert_called_once_with("cua-driver", explicit=True) + contract.assert_called_once_with(str(binary)) + validate.assert_called_once_with(str(app)) + assert register.call_args.args[0][-2:] == ["-f", str(app)] + assert ("app registration failed" in capsys.readouterr().out) == bool(registration_exit) diff --git a/tests/hermes_cli/test_update_handoff_exit.py b/tests/hermes_cli/test_update_handoff_exit.py index 9d79986c7e..255745a52e 100644 --- a/tests/hermes_cli/test_update_handoff_exit.py +++ b/tests/hermes_cli/test_update_handoff_exit.py @@ -1,18 +1,4 @@ -"""Windows hand-off child must hard-exit once the update is durably done (#93581). - -The re-exec'd venv child (spawned by -``_reexec_dependency_sync_off_windows_shim`` with ``HERMES_UPDATE_REEXEC=1``) -completes all update work — the receipt records ``success`` / ``completed at -command boundary`` — but then hangs in interpreter shutdown on a leftover -non-daemon thread, freezing the PowerShell window for minutes. The fix: on -the hand-off path only, after the receipt is finalized, the lock released, -and stdio restored, flush and ``os._exit(code)`` instead of unwinding. - -These tests pin: the hard exit fires (with the right code) only when the -re-exec marker env is set, it happens after lock release + stdio restore, -early ``SystemExit`` codes propagate to it, and real exceptions keep the -normal raise path (traceback intact, no hard exit). -""" +"""Historical handoff markers cannot bypass normal update cleanup or exits.""" from __future__ import annotations @@ -87,12 +73,12 @@ def _noop_impl(args, gateway_mode=False): return None -def test_handoff_child_hard_exits_zero_after_success(monkeypatch): +def test_historical_handoff_marker_does_not_bypass_normal_shutdown(monkeypatch): events = _run_cmd_update(monkeypatch, _noop_impl, reexec=True) - assert events["exit_codes"] == [0] + assert events["exit_codes"] == [] assert events["receipts"] == [(0, COMMAND_BOUNDARY_STOP_REASON)] - # The hard exit is the last thing, after lock release and stdio restore. - assert events["order"] == ["acquire", "impl", "release", "restore-stdio", "hard-exit"] + # Cleanup remains ordered even if an old parent supplied its handoff marker. + assert events["order"] == ["acquire", "impl", "release", "restore-stdio"] def test_non_handoff_run_never_hard_exits(monkeypatch): @@ -109,9 +95,8 @@ def test_handoff_child_propagates_early_systemexit_code(monkeypatch): with pytest.raises(SystemExit) as excinfo: _run_cmd_update(monkeypatch, early_refusal, reexec=True) assert excinfo.value.code == 3 - # The finally-block hard exit ran (before the re-raise propagated) - # and carried the early exit's code, not a blanket 0. - assert _LAST["exit_codes"] == [3] + # The original exception propagates after normal cleanup. + assert _LAST["exit_codes"] == [] def test_handoff_child_systemexit_none_means_zero(monkeypatch): @@ -120,7 +105,7 @@ def test_handoff_child_systemexit_none_means_zero(monkeypatch): with pytest.raises(SystemExit): _run_cmd_update(monkeypatch, bare_exit, reexec=True) - assert _LAST["exit_codes"] == [0] + assert _LAST["exit_codes"] == [] def test_unhandled_exception_keeps_raise_path_no_hard_exit(monkeypatch): diff --git a/tests/hermes_cli/test_update_import_guard.py b/tests/hermes_cli/test_update_import_guard.py index 2d4e8f6784..e2acea9ee5 100644 --- a/tests/hermes_cli/test_update_import_guard.py +++ b/tests/hermes_cli/test_update_import_guard.py @@ -191,10 +191,10 @@ def test_import_guard_rejects_malformed_health_payload(monkeypatch, tmp_path): stdout = "" def malformed(cmd, **_kwargs): - marker = cmd[-1].split("sys.stdout.write('\\n", 1)[1].split("'", 1)[0] - Result.stdout = f"{marker}{{}}" + Result.stdout = "__HERMES_IMPORT_HEALTH_fixture__{}" return Result() + monkeypatch.setattr("secrets.token_hex", lambda size: "fixture") monkeypatch.setattr(update_cmd.subprocess, "run", malformed) ok, module, error = update_cmd._validate_critical_modules_import(tmp_path) @@ -270,38 +270,6 @@ def test_hint_stays_silent_for_unrelated_failures(exc): assert partial_update_hint(exc) == [] -def test_import_guard_prefers_the_project_venv_interpreter(monkeypatch, tmp_path): - """``hermes update`` can run under a different Python than the install's. - - Probing ``sys.executable`` would then validate a tree the user never - actually runs. - On Windows (the platform this guard exists for) the driving interpreter - and the venv interpreter routinely differ. - """ - bin_dir = "Scripts" if update_cmd._m()._is_windows() else "bin" - name = "python.exe" if update_cmd._m()._is_windows() else "python" - venv_python = tmp_path / "venv" / bin_dir / name - venv_python.parent.mkdir(parents=True) - venv_python.write_text("") - - seen: dict = {} - - def fake_run(cmd, **kwargs): - seen["interpreter"] = cmd[0] - - class R: - returncode = 0 - stdout = "" - stderr = "" - - return R() - - monkeypatch.setattr(update_cmd.subprocess, "run", fake_run) - update_cmd._validate_critical_modules_import(tmp_path) - - assert seen["interpreter"] == str(venv_python) - - def test_import_guard_ignores_missing_third_party_dependency(monkeypatch, tmp_path): """A new third-party requirement is not a partially-updated tree. diff --git a/tests/hermes_cli/test_update_receipt_pm_embed.py b/tests/hermes_cli/test_update_receipt_pm_embed.py index 83ba188b50..bcf1175e3e 100644 --- a/tests/hermes_cli/test_update_receipt_pm_embed.py +++ b/tests/hermes_cli/test_update_receipt_pm_embed.py @@ -45,14 +45,14 @@ def _isolated_receipt_state(): def _sync_under_update(**sections): """A pm sync that runs WHILE the update receipt is open — the real - in-process update flow (sync_venv / bisect rebuilds).""" + in-process update flow (sync_venv).""" import pm.receipt as pm_receipt_mod pm_receipt_mod.begin("sync") if "venv_rebuild" in sections: pm_receipt_mod.record_venv_rebuild(**sections.pop("venv_rebuild")) - if "bisect" in sections: - pm_receipt_mod.record_bisect(sections.pop("bisect")) + if "features" in sections: + pm_receipt_mod.record_feature_list(sections.pop("features")) if "warnings" in sections: for message in sections.pop("warnings"): pm_receipt_mod.record_warning(message) @@ -65,7 +65,7 @@ def test_update_receipt_embeds_pm_sections(homed): ur.begin_update_receipt() _sync_under_update( venv_rebuild={"ok": True, "reason": ""}, - bisect=[{"plugin": "bad", "action": "disabled", "reason": "conflict"}], + features=["web", "acp"], ) ur.record_step("git-pull", True) path = ur.finalize_update_receipt("success") @@ -73,11 +73,24 @@ def test_update_receipt_embeds_pm_sections(homed): data = json.loads((homed / "logs" / "update_receipts" / "latest.json").read_text(encoding="utf-8")) assert data["outcome"] == "success" assert data["pm_venv_rebuild"] == {"ok": True, "reason": ""} - assert data["pm_plugin_bisect"][0]["plugin"] == "bad" + assert data["pm_feature_list"] == ["web", "acp"] assert data["pm_sync_outcome"] == "ok" assert data["update_id"] +def test_legacy_worker_receipt_fields_still_embed(homed): + from pm.receipt import accept_worker_receipt + + ur.begin_update_receipt() + update_id = ur.current_correlation_id() + legacy = {"update_id": update_id, "outcome": "ok", + "plugin_bisect": [{"plugin": "old-plugin", "action": "disabled"}]} + accept_worker_receipt(legacy, update_id) + path = ur.finalize_update_receipt("success") + data = json.loads(path.read_text(encoding="utf-8")) + assert data["pm_plugin_bisect"] == legacy["plugin_bisect"] + + def test_update_receipt_without_sync_embeds_nothing(homed): ur.begin_update_receipt() ur.finalize_update_receipt("success") diff --git a/tests/hermes_cli/test_update_shim_self_lock.py b/tests/hermes_cli/test_update_shim_self_lock.py index 857120557d..ef2308c60b 100644 --- a/tests/hermes_cli/test_update_shim_self_lock.py +++ b/tests/hermes_cli/test_update_shim_self_lock.py @@ -1,224 +1,23 @@ -"""The Windows console-shim update self-lock (#88838, #89599, #86093). - -``venv\\Scripts\\hermes.exe`` is a launcher that runs the interpreter with the -shim itself as its script, keeping the file open without FILE_SHARE_DELETE for -the whole command. An update started that way must therefore replace a file it -is holding, which Windows refuses — so the DEPENDENCY SYNC re-runs itself under -``venv\\Scripts\\python.exe``. - -The hand-off sits at the sync boundary, not at the top of ``hermes update``: -everything before it (the fetch, the stash question, the branch switch) runs -in the user's own console, and an up-to-date run that never syncs never hands -off at all. - -``_is_windows`` is patched so these paths are exercised on any host. -""" - -from __future__ import annotations - -import sys -import types +"""Retired dependency handoff stays inert; queued artifact recovery stays live.""" from pathlib import Path import pytest -from hermes_cli import main as cli_main -from hermes_cli import update_cmd -from hermes_cli import main_install_repair - -SHIM_NAMES = ["hermes.exe", "hermes-agent.exe", "hermes-acp.exe", "hermes-gateway.exe"] +from hermes_cli import main as cli_main, main_install_repair -@pytest.fixture -def venv(tmp_path, monkeypatch): - """A Windows-shaped project venv with a python.exe, wired into main.""" - scripts = tmp_path / "venv" / "Scripts" - scripts.mkdir(parents=True) - (scripts / "python.exe").write_bytes(b"") - # update_cmd reads these off hermes_cli.main (frozen ``_m()`` surface); the - # install-repair helpers read their own module globals — patch both. - for target in (cli_main, main_install_repair): - monkeypatch.setattr(target, "_is_windows", lambda: True) - monkeypatch.setattr(target, "_venv_scripts_dir", lambda: scripts) - monkeypatch.setattr(sys, "argv", ["hermes", "update"]) - monkeypatch.delenv(cli_main._UPDATE_REEXEC_ENV, raising=False) - _fake_psutil(monkeypatch, []) - return scripts +def test_historical_dependency_handoff_stops_for_relaunch(monkeypatch, capsys): + import subprocess + def forbidden(*args, **kwargs): + pytest.fail("historical sync must not spawn another updater") -def _fake_psutil(monkeypatch, ancestor_exes: list[str]): - """Stand in for psutil with a fixed self+ancestor executable chain.""" - - class _Proc: - def __init__(self, exe=None): - self._exe = exe - - def exe(self): - if self._exe is None: - raise OSError("exe unavailable") - return self._exe - - def parents(self): - return [_Proc(exe) for exe in ancestor_exes] - - monkeypatch.setitem(sys.modules, "psutil", types.SimpleNamespace(Process=_Proc)) - - -def _capture_popen(monkeypatch, raises: Exception | None = None): - calls = [] - - def fake_popen(cmd, env=None, **kwargs): - if raises is not None: - raise raises - calls.append((list(cmd), dict(env or {}), kwargs)) - return object() - - monkeypatch.setattr(cli_main.subprocess, "Popen", fake_popen) - return calls - - -# --------------------------------------------------------------------------- -# Shim detection -# --------------------------------------------------------------------------- - - -@pytest.mark.parametrize("shim_name", SHIM_NAMES) -def test_detects_shim_as_argv0(venv, monkeypatch, shim_name): - monkeypatch.setattr(sys, "argv", [str(venv / shim_name), "update"]) - assert main_install_repair._windows_shim_in_process_chain() == venv / shim_name - - -def test_detects_shim_from_zipapp_main_py(venv, monkeypatch): - """runpy/zipapp launches put ``\\__main__.py`` in argv[0].""" - monkeypatch.setattr(sys, "argv", [str(venv / "hermes.exe" / "__main__.py")]) - assert main_install_repair._windows_shim_in_process_chain() == venv / "hermes.exe" - - -def test_detects_shim_from_main_module_spec_origin(venv, monkeypatch): - fake_main = types.SimpleNamespace( - __file__=None, - __spec__=types.SimpleNamespace(origin=str(venv / "hermes.exe")), - ) - monkeypatch.setitem(sys.modules, "__main__", fake_main) - assert main_install_repair._windows_shim_in_process_chain() == venv / "hermes.exe" - - -def test_detects_shim_in_ancestor_chain(venv, monkeypatch): - """The launcher is usually a separate parent process, not argv[0].""" - _fake_psutil(monkeypatch, [str(venv / "hermes.exe")]) - assert main_install_repair._windows_shim_in_process_chain() == venv / "hermes.exe" - - -def test_ignores_hermes_exe_outside_the_project_venv(venv, monkeypatch, tmp_path): - """A shim from some other install must never trigger a re-exec.""" - other = tmp_path / "other" / "Scripts" - other.mkdir(parents=True) - monkeypatch.setattr(sys, "argv", [str(other / "hermes.exe"), "update"]) - _fake_psutil(monkeypatch, [str(other / "hermes.exe")]) - assert main_install_repair._windows_shim_in_process_chain() is None - - -def test_no_shim_off_windows(venv, monkeypatch): - monkeypatch.setattr(main_install_repair, "_is_windows", lambda: False) - monkeypatch.setattr(sys, "argv", [str(venv / "hermes.exe"), "update"]) - assert main_install_repair._windows_shim_in_process_chain() is None - - -def test_no_shim_without_a_venv(venv, monkeypatch): - monkeypatch.setattr(main_install_repair, "_venv_scripts_dir", lambda: None) - monkeypatch.setattr(sys, "argv", [str(venv / "hermes.exe"), "update"]) - assert main_install_repair._windows_shim_in_process_chain() is None - - -# --------------------------------------------------------------------------- -# Re-exec hand-off -# --------------------------------------------------------------------------- - - -def test_reexec_runs_same_args_under_venv_python(venv, monkeypatch, capsys): - monkeypatch.setattr(sys, "argv", [str(venv / "hermes.exe"), "update", "--yes"]) - calls = _capture_popen(monkeypatch) - - assert cli_main._reexec_dependency_sync_off_windows_shim() is True - cmd, env, kwargs = calls[0] - assert cmd == [ - str(venv / "python.exe"), "-m", "hermes_cli.main", "update", "--yes", - ] - assert env[cli_main._UPDATE_REEXEC_ENV] == "1" - assert "under the venv Python" in capsys.readouterr().out - - -def test_reexec_child_runs_unattended(venv, monkeypatch): - """The parent exits, so a prompt in the child could never be answered.""" - monkeypatch.setattr(sys, "argv", [str(venv / "hermes.exe"), "update"]) - calls = _capture_popen(monkeypatch) - - assert cli_main._reexec_dependency_sync_off_windows_shim() is True - assert calls[0][2]["stdin"] is cli_main.subprocess.DEVNULL - - -def test_reexec_does_not_recurse(venv, monkeypatch): - monkeypatch.setattr(sys, "argv", [str(venv / "hermes.exe"), "update"]) - monkeypatch.setenv(cli_main._UPDATE_REEXEC_ENV, "1") - calls = _capture_popen(monkeypatch) - - assert cli_main._reexec_dependency_sync_off_windows_shim() is False - assert calls == [] - - -def test_reexec_skipped_when_not_launched_from_a_shim(venv, monkeypatch): - calls = _capture_popen(monkeypatch) - assert cli_main._reexec_dependency_sync_off_windows_shim() is False - assert calls == [] - - -def test_reexec_falls_through_when_venv_python_is_missing(venv, monkeypatch, capsys): - (venv / "python.exe").unlink() - monkeypatch.setattr(sys, "argv", [str(venv / "hermes.exe"), "update"]) - - assert cli_main._reexec_dependency_sync_off_windows_shim() is False - assert "-m hermes_cli.main update" not in capsys.readouterr().out - - -def test_reexec_falls_through_when_spawn_fails(venv, monkeypatch, capsys): - monkeypatch.setattr(sys, "argv", [str(venv / "hermes.exe"), "update"]) - _capture_popen(monkeypatch, raises=OSError("no exec")) - - assert cli_main._reexec_dependency_sync_off_windows_shim() is False - assert "-m hermes_cli.main update" in capsys.readouterr().out - - -# --------------------------------------------------------------------------- -# Hand-off placement: the sync boundary, not the top of the command -# --------------------------------------------------------------------------- - - -def test_up_to_date_run_never_hands_off(venv, monkeypatch, capsys): - """The regression that started this: a no-op update must not detach. - - The hand-off used to run before the fetch, so every ``hermes update`` — - including the ``Already up to date!`` case that never touches the venv — - spawned a child and returned to the shell, leaving the child printing - into a console it no longer owned. ``--check`` is the cheapest real run - that reaches ``cmd_update`` and exits without syncing; nothing may be - spawned along the way. - """ - monkeypatch.setattr(sys, "argv", [str(venv / "hermes.exe"), "update", "--check"]) - calls = _capture_popen(monkeypatch) - monkeypatch.setattr(update_cmd, "_cmd_update_check", lambda **kwargs: None) - - cli_main.cmd_update(types.SimpleNamespace(check=True, branch=None)) - - assert calls == [], "an up-to-date run must not spawn a detached child" - - -# Reboot-deferred renames -# --------------------------------------------------------------------------- - - -def test_reboot_deferred_rename_fallback_is_gone(): - """MOVEFILE_DELAY_UNTIL_REBOOT needed elevation and freed nothing.""" - assert not hasattr(cli_main, "_schedule_replace_on_reboot") + monkeypatch.setattr(subprocess, "Popen", forbidden) + monkeypatch.setenv("HERMES_UPDATE_REEXEC", "1") + with pytest.raises(SystemExit) as stopped: + cli_main._reexec_dependency_sync_off_windows_shim() + assert stopped.value.code == 0 + assert "run `hermes` again" in capsys.readouterr().err def test_pending_rename_filter_drops_only_our_shim_pairs(): @@ -235,32 +34,19 @@ def test_pending_rename_filter_drops_only_our_shim_pairs(): def test_pending_rename_filter_keeps_a_shim_pair_with_a_foreign_target(): shims = [Path(r"C:\hermes\venv\Scripts\hermes.exe")] - entries = [ - r"\??\C:\hermes\venv\Scripts\hermes.exe", r"!\??\C:\somewhere\else.exe", - ] - kept, removed = main_install_repair._filter_pending_shim_renames(entries, shims) - assert removed == 0 - assert kept == entries + entries = [r"\??\C:\hermes\venv\Scripts\hermes.exe", r"!\??\C:\somewhere\else.exe"] + assert main_install_repair._filter_pending_shim_renames(entries, shims) == (entries, 0) def test_pending_rename_filter_preserves_a_trailing_delete_entry(): - """A bare source with an empty target is a scheduled delete, not a pair.""" entries = [r"\??\C:\other\thing.dll", "", r"\??\C:\other\orphan.dll"] - kept, removed = main_install_repair._filter_pending_shim_renames(entries, []) - assert removed == 0 - assert kept == entries - - -# --------------------------------------------------------------------------- -# venv layout -# --------------------------------------------------------------------------- + assert main_install_repair._filter_pending_shim_renames(entries, []) == (entries, 0) +@pytest.mark.platforms("windows") @pytest.mark.parametrize("venv_name", ["venv", ".venv"]) -def test_venv_scripts_dir_finds_both_layouts(tmp_path, monkeypatch, venv_name): - """uv writes .venv; our installers write venv. Both must resolve (#79542).""" +def test_legacy_shim_recovery_finds_both_layouts(tmp_path, monkeypatch, venv_name): scripts = tmp_path / venv_name / "Scripts" scripts.mkdir(parents=True) monkeypatch.setattr(cli_main, "PROJECT_ROOT", tmp_path) - monkeypatch.setattr(main_install_repair, "_is_windows", lambda: True) - assert main_install_repair._venv_scripts_dir() == scripts + assert main_install_repair._venv_scripts_dir() == scripts \ No newline at end of file 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 b26b6acb85..09e504d2cb 100644 --- a/tests/hermes_cli/test_web_memory_provider_setup_install.py +++ b/tests/hermes_cli/test_web_memory_provider_setup_install.py @@ -1,13 +1,7 @@ -"""C16: web dashboard memory-provider setup must install declared provider -extras through pm's sync authority (plugin.yaml ``extra:`` / materialized -legacy ``python_dependencies``), never ``tools.lazy_deps`` or raw pip, and -must report restart-required truthfully when the boot-selected environment -can't see a fresh install.""" +"""CLI/dashboard setup resolves declarations, not ambient importability.""" -from types import SimpleNamespace -from pathlib import Path -from unittest.mock import MagicMock import json +from pathlib import Path import shutil import subprocess import sys @@ -17,159 +11,15 @@ import pytest from hermes_cli.web_routers import memory_providers as mp -@pytest.fixture() -def routed(monkeypatch): - """Mutable harness over the module-level collaborators.""" - state = SimpleNamespace( - importable=set(), # dep names that import cleanly - manifest={}, - plugin_dir=None, - sync_calls=[], # (extras, explicit) - materialized=[], # plugin_dir args - sync_error=None, - ) - - monkeypatch.setattr(mp, "_memory_provider_manifest", lambda name: state.manifest) - - import plugins.memory as plugins_memory - monkeypatch.setattr( - plugins_memory, "find_provider_dir", lambda name: state.plugin_dir, - raising=False, - ) - - import hermes_cli.web_server_memory as wsm - _IMPORT_OF = {"mem0ai": "mem0", "honcho-ai": "honcho"} - - def fake_importable(dep): - package = dep.split("[")[0] - for ch in "<>=!~;": - package = package.split(ch)[0] - return _IMPORT_OF.get(package.strip(), package.strip().replace("-", "_")) in state.importable - - monkeypatch.setattr(mp, "_dependency_importable", fake_importable) - - import pm - monkeypatch.setattr(pm, "available", lambda extra: extra in state.importable) - def fake_sync_venv(extras=None, *, explicit=False, plugin_dirs=None): - state.sync_calls.append((extras, explicit)) - state.materialized.extend(plugin_dirs() if callable(plugin_dirs) else plugin_dirs or []) - if state.sync_error: - raise state.sync_error - # A successful sync makes freshly installed dists importable in-process. - if getattr(state, "sync_effect", True): - state.importable |= {"mem0", "honcho", "freshpkg"} - - monkeypatch.setattr(pm, "sync_venv", fake_sync_venv) - - return state - - -class TestDeclaredExtraRouting: - @pytest.mark.parametrize("sync_effect, status", [(True, "installed"), (False, "restart_required")]) - def test_extra_without_legacy_pip_declarations_is_synced(self, routed, sync_effect, status): - routed.manifest = {"extra": "mem0"} - routed.sync_effect = sync_effect - - rows = mp._install_memory_provider_pip_dependencies("mem0", []) - - assert routed.sync_calls == [(["mem0"], True)] - assert rows[0]["name"] == "mem0" - assert rows[0]["status"] == status - - def test_extra_synced_explicitly_when_import_missing(self, routed): - routed.manifest = {"extra": "mem0"} - routed.importable = set() - - rows = mp._install_memory_provider_pip_dependencies("mem0", ["mem0ai"]) - - assert routed.sync_calls == [(["mem0"], True)] - assert rows[0]["status"] == "installed" - - def test_no_sync_when_everything_imports(self, routed): - routed.manifest = {"extra": "mem0"} - routed.importable = {"mem0"} - - rows = mp._install_memory_provider_pip_dependencies("mem0", ["mem0ai"]) - - assert routed.sync_calls == [] - assert rows[0]["status"] == "already_installed" - - def test_sync_failure_reports_failed_row(self, routed): - routed.manifest = {"extra": "mem0"} - routed.sync_error = RuntimeError("sealed venv refuses extras") - - rows = mp._install_memory_provider_pip_dependencies("mem0", ["mem0ai"]) - - assert rows[0]["status"] == "failed" - assert "sealed venv" in rows[0]["stderr"] - - -class TestLegacyPythonDependencies: - @pytest.mark.parametrize("extra", [None, "mem0"]) - def test_third_party_python_dependencies_preserve_active_union(self, routed, tmp_path, monkeypatch, extra): - routed.manifest = {"python_dependencies": ["freshpkg"]} - if extra: - routed.manifest["extra"] = extra - routed.plugin_dir = tmp_path / "external-provider" - routed.plugin_dir.mkdir() - (routed.plugin_dir / "plugin.yaml").write_text("name: ext\npython_dependencies: [freshpkg]\n") - routed.importable = set() - active = tmp_path / "active-provider" - active.mkdir() - monkeypatch.setattr("pm.workspace.enabled_member_dirs", lambda **kwargs: [active]) - - rows = mp._install_memory_provider_pip_dependencies("ext", ["freshpkg"]) - - assert routed.materialized == [active, routed.plugin_dir] - assert routed.sync_calls == [([extra] if extra else None, True)] - assert rows[0]["status"] == "installed" - - def test_legacy_specs_without_plugin_dir_fail_honestly(self, routed): - routed.manifest = {"python_dependencies": ["freshpkg"]} - routed.plugin_dir = None - - rows = mp._install_memory_provider_pip_dependencies("ext", ["freshpkg"]) - - assert routed.sync_calls == [] - assert rows[0]["status"] == "failed" - - -class TestRestartTruthfulness: - def test_install_that_boot_env_cannot_see_is_restart_required(self, routed): - routed.manifest = {"extra": "mem0"} - routed.importable = set() - # Sync succeeds but the freshly installed dist stays invisible - # to this boot-selected interpreter. - routed.sync_effect = False - - rows = mp._install_memory_provider_pip_dependencies("mem0", ["mem0ai"]) - - assert rows[0]["status"] == "restart_required" - assert rows[0]["status"] != "failed" - - def test_setup_result_counts_restart_required_as_not_failed(self, routed, monkeypatch): - routed.manifest = {"extra": "mem0"} - monkeypatch.setattr( - mp, "_install_memory_provider_pip_dependencies", - lambda name, deps: [{"kind": "pip", "name": "mem0ai", "status": "restart_required", - "command": "hermes pm install mem0", "returncode": None, - "stdout": "", "stderr": ""}], - ) - monkeypatch.setattr(mp, "_memory_provider_setup_manifest", - lambda name: {"pip_dependencies": ["mem0ai"], "external_dependencies": [], - "required_env": []}) - monkeypatch.setattr(mp, "_discover_memory_provider_statuses", lambda: {}) - - result = mp._install_memory_provider_setup("mem0") - - assert result["ok"] is True - - -def test_dashboard_admits_real_provider_union_and_keeps_selection_on_failure(tmp_path, monkeypatch): +@pytest.mark.parametrize("surface", ["dashboard", "cli"]) +@pytest.mark.parametrize("declaration", ["pyproject", "python_dependencies", "pip_dependencies"]) +def test_setup_admits_real_provider_union_and_keeps_selection_on_failure(tmp_path, monkeypatch, surface, declaration): 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 hermes_cli import memory_setup + from hermes_cli.web_server_memory import _memory_provider_setup_info uv = shutil.which("uv") assert uv, "real PM admission test requires uv" @@ -187,32 +37,102 @@ def test_dashboard_admits_real_provider_union_and_keeps_selection_on_failure(tmp directory.mkdir(parents=True) (incumbent / "plugin.yaml").write_text('name: incumbent\npython_dependencies: ["existing_dep==1.0"]\n') manifest = candidate / "plugin.yaml" - manifest.write_text('name: candidate\npython_dependencies: ["provider_dep==1.0", "provider_dep==2.0"]\n') + def declare(requirements): + if declaration == "pyproject": + manifest.write_text('name: candidate\n') + (candidate / "pyproject.toml").write_text( + '[project]\nname="candidate"\nversion="1"\nrequires-python=">=3.14"\n' + f'dependencies={json.dumps(requirements)}\n[tool.uv]\npackage=false\n') + else: + manifest.write_text(f'name: candidate\n{declaration}: {json.dumps(requirements)}\n') + declare(["provider_dep==1.0", "provider_dep==2.0"]) config = home / "config.yaml" config.write_text('plugins:\n enabled: [incumbent]\n') monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setattr(Path, "home", lambda: tmp_path) monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "tools")) monkeypatch.setattr("pm.paths.repo_root", lambda: core) monkeypatch.setattr("pm._uv._toolchain", lambda **kwargs: (Path(uv), Path(sys.executable))) monkeypatch.setattr("pm.client.is_runtime", lambda: True) monkeypatch.setattr("plugins.memory.find_provider_dir", lambda name: candidate) - monkeypatch.setattr(mp, "_memory_provider_manifest", lambda name: {"python_dependencies": ["provider_dep"]}) - monkeypatch.setattr(mp, "_dependency_importable", lambda dep: False) + # An importable ambient module must not bypass admission or pin checks. + (tmp_path / "provider_dep.py").write_text('VALUE="ambient, not admitted"\n') + monkeypatch.syspath_prepend(str(tmp_path)) + monkeypatch.delitem(sys.modules, "provider_dep", raising=False) + import provider_dep + assert provider_dep.VALUE == "ambient, not admitted" + def prepare(): + if surface == "dashboard": + return mp._install_memory_provider_python_dependencies("candidate") + memory_setup._install_dependencies("candidate") pm.lock_project(core, explicit=True, offline=True) pm.sync_venv(explicit=True) original = selected_venv(core) original_config = config.read_bytes() + before_listing = set(home.rglob("*")) + info = _memory_provider_setup_info("candidate") + assert info["python_dependencies_declared"] is True + assert info["dependencies_installed"] is False + assert set(home.rglob("*")) == before_listing, "listing must not prepare dependencies" - failed = mp._install_memory_provider_pip_dependencies("candidate", ["provider_dep"]) - assert failed[0]["status"] == "failed" + if surface == "dashboard": + failed = prepare() + assert failed[0]["status"] == "failed", failed + else: + with pytest.raises(pm.InstallError): + prepare() assert selected_venv(core) == original assert config.read_bytes() == original_config - manifest.write_text('name: candidate\npython_dependencies: ["provider_dep==1.0"]\n') - success = mp._install_memory_provider_pip_dependencies("candidate", ["provider_dep"]) - assert success[0]["status"] == "restart_required", success + declare(["provider_dep==1.0"]) + success = prepare() + if surface == "dashboard": + assert success[0]["status"] == "restart_required", success python = venv_python_path(selected_venv(core)) result = subprocess.run([str(python), "-I", "-c", "import existing_dep, provider_dep; print('both')"], check=True, capture_output=True, text=True, timeout=30) assert result.stdout.strip() == "both" assert config.read_bytes() == original_config + # Preparing an already-current union must not produce another generation. + selected = selected_venv(core) + prepare() + assert selected_venv(core) == selected + assert _memory_provider_setup_info("candidate")["dependencies_installed"] is True + declare(["provider_dep==2.0"]) + assert _memory_provider_setup_info("candidate")["dependencies_installed"] is False + assert selected_venv(core) == selected + + +@pytest.mark.parametrize("python_failure", [False, True]) +def test_setup_reports_restart_and_preserves_external_steps(tmp_path, monkeypatch, python_failure): + import shlex + + provider = tmp_path / "provider" + provider.mkdir() + (provider / "plugin.yaml").write_text(json.dumps({ + "name": "provider", "extra": "mem0", "external_dependencies": [ + {"name": "external-sidecar", "check": f'{shlex.quote(sys.executable)} -c "pass"'}, + ], + })) + monkeypatch.setattr("plugins.memory.find_provider_dir", lambda name: provider) + monkeypatch.setattr(mp, "_load_memory_provider", lambda name: None) + monkeypatch.setattr(mp, "_discover_memory_provider_statuses", lambda: []) + # The resolver seam is isolated; real union behavior is exercised above. + def sync(*args, **kwargs): + if python_failure: + raise RuntimeError("Python preparation refused") + monkeypatch.setattr("pm.sync_venv", sync) + result = mp._install_memory_provider_setup("provider") + assert result["ok"] is not python_failure + assert result["results"][0]["status"] == ("failed" if python_failure else "restart_required") + assert result["results"][1]["name"] == "external-sidecar" + assert result["results"][1]["status"] == "already_installed" + assert all(row["status"] != "no_declared_steps" for row in result["results"]) + + +def test_malformed_candidate_manifest_is_not_a_successful_noop(tmp_path, monkeypatch): + (tmp_path / "plugin.yaml").write_text('name: [unterminated\n') + monkeypatch.setattr("plugins.memory.find_provider_dir", lambda name: tmp_path) + [row] = mp._install_memory_provider_python_dependencies("broken") + assert row["status"] == "failed" \ No newline at end of file diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index ad0acfc7e1..c89cc5687a 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -921,7 +921,7 @@ class TestWebServerEndpoints: - def test_post_memory_provider_setup_routes_pip_through_pm(self, monkeypatch): + def test_post_memory_provider_setup_routes_pip_through_pm(self, monkeypatch, tmp_path): """NS-605 lineage: dashboard pip installs must route through pm (venv sync of the owning extra), never a direct `pip install --python sys.executable`.""" @@ -930,11 +930,12 @@ class TestWebServerEndpoints: import hermes_cli.web_server as web_server import pm - # Force a provider with declared legacy dependencies and an owned extra. - manifest = {"pip_dependencies": ["honcho-ai"], "extra": "honcho"} - monkeypatch.setattr("hermes_cli.web_server_memory._memory_provider_manifest", lambda name: manifest) - monkeypatch.setattr("hermes_cli.web_routers.memory_providers._memory_provider_manifest", lambda name: manifest) - monkeypatch.setattr("hermes_cli.web_routers.memory_providers._dependency_importable", lambda dep: False) + # Read the real declaration through the same candidate path as the CLI. + provider = tmp_path / "honcho-provider" + provider.mkdir() + (provider / "plugin.yaml").write_text("name: honcho\nextra: honcho\n") + monkeypatch.setattr("plugins.memory.find_provider_dir", lambda name: provider) + monkeypatch.setattr("hermes_cli.web_routers.memory_providers._discover_memory_provider_statuses", lambda: []) installed = [] @@ -968,10 +969,12 @@ class TestWebServerEndpoints: - def test_put_memory_provider_config_writes_config_and_secret(self): + def test_put_memory_provider_config_writes_config_and_secret(self, monkeypatch): from hermes_constants import get_hermes_home from hermes_cli.config import load_config, load_env + monkeypatch.setattr("pm.venv_is_current", lambda **kwargs: True) + resp = self.client.put( "/api/memory/providers/hindsight/config", json={ diff --git a/tests/hermes_cli/test_web_ui_build.py b/tests/hermes_cli/test_web_ui_build.py index 682f03b809..165cc5e257 100644 --- a/tests/hermes_cli/test_web_ui_build.py +++ b/tests/hermes_cli/test_web_ui_build.py @@ -6,10 +6,8 @@ from unittest.mock import patch import pytest -from hermes_cli.main_web_build import ( - _build_web_ui, _web_ui_build_needed, _compute_web_ui_content_hash, - _web_ui_stamp_path, _write_web_ui_build_stamp, -) +from hermes_cli.main_web_build import _build_web_ui, _web_ui_build_needed +from tests.hermes_cli.test_source_build import stamp_product, copy_freshness_scripts from tests.hermes_cli.test_source_build import source_checkout, source_products, _events # noqa: F401 @@ -29,6 +27,7 @@ def _touch(path: Path, offset: float = 0.0) -> None: def _make_web_dir(tmp_path: Path) -> tuple[Path, Path]: """Return (web_dir, dist_dir) matching real repo layout.""" + copy_freshness_scripts(tmp_path) web_dir = tmp_path / "web" web_dir.mkdir(parents=True) (web_dir / "package.json").touch() @@ -36,61 +35,6 @@ def _make_web_dir(tmp_path: Path) -> tuple[Path, Path]: return web_dir, dist_dir -class TestWebUIBuildNeeded: - """Content-hash staleness — replaces the old mtime comparison. - - The dashboard build hashes the web source tree (like the desktop build) - instead of comparing mtimes, so git operations that rewrite mtimes - without changing content no longer fool the freshness check. - """ - - @staticmethod - def _root(web_dir: Path) -> Path: - return web_dir.parent.parent if web_dir.parent.name == "apps" else web_dir.parent - - def _stamp_current(self, web_dir: Path) -> None: - """Record a stamp matching web_dir's current source content.""" - _write_web_ui_build_stamp(self._root(web_dir), web_dir) - - - def test_mtime_only_change_is_not_stale(self, tmp_path): - """The whole point: bumping mtimes without changing bytes (what - ``git pull`` / ``hermes update`` do) must NOT report stale.""" - web_dir, dist_dir = _make_web_dir(tmp_path) - src = web_dir / "src" / "App.tsx" - src.parent.mkdir(parents=True, exist_ok=True) - src.write_text("export const A = 1\n") - (dist_dir / ".vite").mkdir(parents=True, exist_ok=True) - (dist_dir / ".vite" / "manifest.json").write_text("{}") - self._stamp_current(web_dir) - assert _web_ui_build_needed(web_dir) is False - future = time.time() + 10_000 - os.utime(src, (future, future)) - os.utime(web_dir / "package.json", (future, future)) - assert _web_ui_build_needed(web_dir) is False - - - def test_content_hash_is_deterministic(self, tmp_path): - web_dir, _ = _make_web_dir(tmp_path) - (web_dir / "src").mkdir(parents=True, exist_ok=True) - (web_dir / "src" / "App.tsx").write_text("export const A = 1\n") - root = self._root(web_dir) - h1 = _compute_web_ui_content_hash(root, web_dir) - h2 = _compute_web_ui_content_hash(root, web_dir) - assert h1 == h2 - assert len(h1) == 64 - - def test_write_stamp_creates_file_with_hash(self, tmp_path): - import json as _json - web_dir, _ = _make_web_dir(tmp_path) - (web_dir / "src").mkdir(parents=True, exist_ok=True) - (web_dir / "src" / "App.tsx").write_text("export const A = 1\n") - self._stamp_current(web_dir) - stamp = _web_ui_stamp_path() - assert stamp.is_file() - data = _json.loads(stamp.read_text()) - assert data["contentHash"] == _compute_web_ui_content_hash(self._root(web_dir), web_dir) - @pytest.mark.platforms("linux") class TestBuildWebUIFlock: @@ -110,8 +54,8 @@ class TestBuildWebUIFlock: def release_after_building(): # Simulate the winning process finishing its build. - _touch(dist_dir / ".vite" / "manifest.json") - _write_web_ui_build_stamp(tmp_path, web_dir) + _touch(dist_dir / "index.html") + stamp_product(tmp_path, "web", dist_dir) holder.close() # releases the flock t = threading.Timer(0.2, release_after_building) @@ -150,7 +94,7 @@ def test_web_failure_is_not_success_even_with_an_old_dist(source_products, fatal assert acquired == ["npm"] assert [event["step"] for event in _events(root)] == ["deps", "icons", "web"] assert dist.read_text() == "old product" - assert not _web_ui_stamp_path().exists() + assert not (root / "hermes_cli/web_dist/hermes-build.json").exists() @pytest.mark.platforms("posix") @@ -160,7 +104,7 @@ def test_failed_preparation_never_runs_web_compilation(source_products): assert not _build_web_ui(root / "web", fatal=True) assert acquired == ["npm"] assert _events(root) == [] - assert not _web_ui_stamp_path().exists() + assert not (root / "hermes_cli/web_dist/hermes-build.json").exists() @pytest.mark.platforms("linux") @@ -190,7 +134,7 @@ def test_contended_stale_dist_waits_for_the_lock_holder(tmp_path): def finish_build(): (dist / "index.html").write_text("winner") - _write_web_ui_build_stamp(tmp_path, web) + stamp_product(tmp_path, "web", dist) holder.close() worker = threading.Timer(2, finish_build) diff --git a/tests/install/installer-script-e2e.sh b/tests/install/installer-script-e2e.sh index 2283e7df45..b3400286f2 100755 --- a/tests/install/installer-script-e2e.sh +++ b/tests/install/installer-script-e2e.sh @@ -295,7 +295,8 @@ 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/venv/bin/hermes" + 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" \ || fail "hermes --version failed after $2; log in $LOG_DIR/version-$2.log" @@ -310,9 +311,19 @@ smoke_desktop() { # 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/venv/bin/hermes" - if ! "$hermes" desktop --help 2>/dev/null | grep -qF -- --build-only; then - ok "hermes desktop --build-only not supported at $1; skipping desktop smoke" + 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 diff --git a/tests/install_ps1_fake_uv.py b/tests/install_ps1_fake_uv.py deleted file mode 100644 index 75bddc8a76..0000000000 --- a/tests/install_ps1_fake_uv.py +++ /dev/null @@ -1,102 +0,0 @@ -"""Fake uv executable used by behavioral Windows installer tests.""" - -from __future__ import annotations - -from pathlib import Path -import subprocess - - -_FAKE_UV = r''' -using System; -using System.IO; -using System.Linq; - -public static class FakeUv { - public static int Main(string[] args) { - if (Path.GetFileName(Environment.GetCommandLineArgs()[0]).Equals( - "python.exe", StringComparison.OrdinalIgnoreCase)) { - Console.WriteLine(Environment.GetEnvironmentVariable("FAKE_PYTHON_VERSION") - ?? "Python 3.11.0"); - return 0; - } - - File.AppendAllText(Environment.GetEnvironmentVariable("FAKE_UV_LOG"), - string.Join(" ", args) + Environment.NewLine); - - if (args.Length >= 2 && args[0] == "python" && args[1] == "find") { - string findDelayMs = Environment.GetEnvironmentVariable( - "FAKE_UV_FIND_DELAY_MS"); - if (!string.IsNullOrEmpty(findDelayMs)) { - System.Threading.Thread.Sleep(int.Parse(findDelayMs)); - } - string stderrBytes = Environment.GetEnvironmentVariable( - "FAKE_UV_FIND_STDERR_BYTES"); - if (!string.IsNullOrEmpty(stderrBytes)) { - Console.Error.Write(new string('x', int.Parse(stderrBytes))); - } - bool managed = args.Contains("--managed-python"); - string availableVersion = Environment.GetEnvironmentVariable( - "FAKE_MANAGED_PYTHON_VERSION"); - if (managed && !string.IsNullOrEmpty(availableVersion) - && (args.Length < 3 || args[2] != availableVersion)) { - return 1; - } - Console.WriteLine(Environment.GetEnvironmentVariable( - managed ? "FAKE_MANAGED_PYTHON" : "FAKE_THIRD_PARTY_PYTHON")); - return 0; - } - if (args.Length >= 2 && args[0] == "python" && args[1] == "install") { - return 0; - } - if (args.Length >= 2 && args[0] == "venv" && args[1] == "venv") { - string forcedExit = Environment.GetEnvironmentVariable("FAKE_UV_VENV_EXIT"); - if (!string.IsNullOrEmpty(forcedExit)) { - return int.Parse(forcedExit); - } - string managedPython = Environment.GetEnvironmentVariable("FAKE_MANAGED_PYTHON"); - int pythonAt = Array.IndexOf(args, "--python"); - bool correctPython = pythonAt >= 0 && pythonAt + 1 < args.Length - && string.Equals(args[pythonAt + 1], managedPython, - StringComparison.OrdinalIgnoreCase); - if (!correctPython || !args.Contains("--managed-python") - || !args.Contains("--no-python-downloads")) { - return 42; - } - string scripts = Path.Combine(Environment.CurrentDirectory, "venv", "Scripts"); - Directory.CreateDirectory(scripts); - File.Copy(managedPython, Path.Combine(scripts, "python.exe"), true); - return 0; - } - return 2; - } -} -''' - - -def compile_fake_uv(powershell: str, output: Path) -> None: - source = output.with_suffix(".cs") - source.write_text(_FAKE_UV, encoding="utf-8") - compile_script = output.with_name("compile-fake-uv.ps1") - compile_script.write_text( - "param([string]$Source, [string]$Output)\n" - "Add-Type -Path $Source -OutputAssembly $Output " - "-OutputType ConsoleApplication\n", - encoding="utf-8", - ) - subprocess.run( - [ - powershell, - "-NoProfile", - "-ExecutionPolicy", - "Bypass", - "-File", - str(compile_script), - "-Source", - str(source), - "-Output", - str(output), - ], - check=True, - capture_output=True, - text=True, - ) diff --git a/tests/installation_launcher_fixture.py b/tests/installation_launcher_fixture.py new file mode 100644 index 0000000000..6ce9102615 --- /dev/null +++ b/tests/installation_launcher_fixture.py @@ -0,0 +1,27 @@ +"""A real published launcher driving a disposable CLI, without installing deps.""" + +from pathlib import Path +import shutil +import sys + +from hermes_cli._launchers import mint_launcher + + +def publish_fixture_launcher(root: Path, main_source: str) -> Path: + repository = Path(__file__).resolve().parents[1] + package = root / "hermes_cli" + package.mkdir(parents=True, exist_ok=True) + (package / "__init__.py").touch() + (package / "main.py").write_text(main_source, encoding="utf-8") + # The application is a stand-in; launcher production and command queries + # are real. The interpreter is external to the checkout, like PM's store. + (root / "hermes_bootstrap.py").write_text("", encoding="utf-8") + (root / "pm").mkdir(exist_ok=True) + for relative in ("hermes_constants.py", "hermes_cli/_launchers.py", "hermes_cli/runtime_paths.py"): + shutil.copyfile(repository / relative, root / relative) + out = root / ".hermes" / "bin" + out.mkdir(parents=True) + launcher = mint_launcher("hermes", root, out, Path(sys.executable), None) + assert launcher is not None + assert not (root / "venv").exists() + return launcher \ No newline at end of file diff --git a/tests/plugins/memory/test_hindsight_provider.py b/tests/plugins/memory/test_hindsight_provider.py index e8cf29c325..b0b16a6742 100644 --- a/tests/plugins/memory/test_hindsight_provider.py +++ b/tests/plugins/memory/test_hindsight_provider.py @@ -119,6 +119,15 @@ def _provider_for_mode(tmp_path, monkeypatch, mode: str): return provider +def test_initialize_does_not_upgrade_an_old_client(tmp_path, monkeypatch): + calls = [] + monkeypatch.setattr("importlib.metadata.version", lambda name: "0.0.1") + monkeypatch.setattr("pm.sync_venv", lambda *args, **kwargs: calls.append((args, kwargs))) + provider = _provider_for_mode(tmp_path, monkeypatch, "cloud") + assert provider._session_id == "test-session" + assert calls == [], "provider initialization must not replace the process dependency generation" + + def _assert_cloud_client_lazy_installed_before_import(tmp_path, monkeypatch, mode: str): """Cloud/local-external clients must ensure lazy deps before importing.""" import builtins @@ -1593,69 +1602,6 @@ class TestPostSetupEnvEncoding: assert "" not in content -class TestClientAutoUpgradeRoutesThroughPm: - """The initialize()-time hindsight-client auto-upgrade must go through - pm.sync_venv (uv.lock owns the pin) — never a direct - `uv pip install --python sys.executable` subprocess, which fails with - EROFS/EACCES on immutable images (NS-605).""" - - def _init_with_outdated_client(self, tmp_path, monkeypatch, error=None): - import importlib.metadata as md - import subprocess as subprocess_mod - import pm - - config_path = tmp_path / "hindsight" / "config.json" - config_path.parent.mkdir(parents=True, exist_ok=True) - config_path.write_text(json.dumps({"mode": "cloud"})) - monkeypatch.setattr( - "plugins.memory.hindsight.get_hermes_home", lambda: tmp_path - ) - - # Simulate an installed-but-outdated client. - monkeypatch.setattr(md, "version", lambda name: "0.0.1") - - calls = [] - - def fake_sync(extras=None, **kw): - calls.append(tuple(extras or ())) - if error is not None: - raise error - - monkeypatch.setattr(pm, "sync_venv", fake_sync) - - # Regression guard: no direct pip subprocess may run. - def _no_subprocess(*a, **kw): # pragma: no cover - fails loudly - raise AssertionError(f"unexpected subprocess.run during auto-upgrade: {a}") - monkeypatch.setattr(subprocess_mod, "run", _no_subprocess) - - provider = HindsightMemoryProvider() - provider.initialize(session_id="s", hermes_home=str(tmp_path), platform="cli") - return calls - - def test_upgrade_syncs_extra_not_subprocess(self, tmp_path, monkeypatch): - calls = self._init_with_outdated_client(tmp_path, monkeypatch) - assert calls == [("hindsight",)] - - def test_blocked_upgrade_is_nonfatal_and_surfaces_reason( - self, tmp_path, monkeypatch, caplog - ): - import logging - - import pm as pm_pkg - - with caplog.at_level(logging.WARNING): - calls = self._init_with_outdated_client( - tmp_path, monkeypatch, - error=pm_pkg.InstallError( - "venv", "runtime installs are disabled on this deployment" - ), - ) - assert len(calls) == 1 # attempted exactly once, init still completed - assert any("runtime installs are disabled" in r.getMessage() - for r in caplog.records) - - - class TestMultiplexBackgroundScope: """Under multiplex_profiles get_secret fails closed on an unscoped thread; the writer / daemon-start threads are spawned from a scoped context and diff --git a/tests/plugins/platforms/photon/test_inbound.py b/tests/plugins/platforms/photon/test_inbound.py index 6678387528..a71c5f4a0d 100644 --- a/tests/plugins/platforms/photon/test_inbound.py +++ b/tests/plugins/platforms/photon/test_inbound.py @@ -158,15 +158,11 @@ def test_is_duplicate_window(monkeypatch: pytest.MonkeyPatch) -> None: assert adapter._is_duplicate("id-1") is True # still dup -def test_check_requirements_without_node(monkeypatch: pytest.MonkeyPatch) -> None: - # If no node binary is resolvable — neither in the pm store nor on PATH — - # the adapter should refuse to start. Resolution is pm-store-first - # (_node_command → find_node_executable) with shutil.which as fallback, - # so both must return None to model a machine with no node at all. +def test_check_requirements_without_node(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: from plugins.platforms.photon import adapter as adapter_mod - monkeypatch.setattr(adapter_mod, "_node_command", lambda _name: None) - monkeypatch.setattr(adapter_mod.shutil, "which", lambda _name: None) + monkeypatch.setenv("PATH", "") + monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "missing-store")) assert adapter_mod.check_requirements() is False diff --git a/tests/plugins/platforms/photon/test_npm_error_log_regression.py b/tests/plugins/platforms/photon/test_npm_error_log_regression.py index fa45aecddf..789ac399bc 100644 --- a/tests/plugins/platforms/photon/test_npm_error_log_regression.py +++ b/tests/plugins/platforms/photon/test_npm_error_log_regression.py @@ -37,7 +37,7 @@ def test_regression_return_code_zero_on_success( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: """_install_sidecar() must still return 0 on npm success.""" - monkeypatch.setattr(cli_mod, "_node_command", lambda _: "/usr/bin/npm") + monkeypatch.setattr(cli_mod, "find_node_executable", lambda _: "/usr/bin/npm") monkeypatch.setattr( cli_mod.subprocess, "run", lambda cmd, **kw: types.SimpleNamespace(returncode=0, stderr=""), @@ -69,7 +69,7 @@ def test_regression_oserror_on_log_write_does_not_propagate( def exists(self): return False - monkeypatch.setattr(cli_mod, "_node_command", lambda _: "/usr/bin/npm") + monkeypatch.setattr(cli_mod, "find_node_executable", lambda _: "/usr/bin/npm") monkeypatch.setattr( cli_mod.subprocess, "run", lambda cmd, **kw: types.SimpleNamespace(returncode=1, stderr="npm ERR!"), @@ -95,7 +95,7 @@ def test_regression_empty_stderr_does_not_write_log( """If npm fails but stderr is empty (some npm versions), _NPM_ERROR_LOG must NOT be written — an empty file would mislead check_requirements().""" error_log = tmp_path / ".photon-npm-error.log" - monkeypatch.setattr(cli_mod, "_node_command", lambda _: "/usr/bin/npm") + monkeypatch.setattr(cli_mod, "find_node_executable", lambda _: "/usr/bin/npm") monkeypatch.setattr( cli_mod.subprocess, "run", lambda cmd, **kw: types.SimpleNamespace(returncode=1, stderr=""), @@ -125,7 +125,7 @@ def test_regression_permissionerror_on_success_unlink_does_not_propagate( def unlink(self, *a, **kw): raise PermissionError("access denied") - monkeypatch.setattr(cli_mod, "_node_command", lambda _: "/usr/bin/npm") + monkeypatch.setattr(cli_mod, "find_node_executable", lambda _: "/usr/bin/npm") monkeypatch.setattr( cli_mod.subprocess, "run", lambda cmd, **kw: types.SimpleNamespace(returncode=0, stderr=""), @@ -145,7 +145,7 @@ def test_regression_long_stderr_truncated_before_write( error_log = tmp_path / ".photon-npm-error.log" huge_stderr = "npm ERR! " + ("x" * 10_000) - monkeypatch.setattr(cli_mod, "_node_command", lambda _: "/usr/bin/npm") + monkeypatch.setattr(cli_mod, "find_node_executable", lambda _: "/usr/bin/npm") monkeypatch.setattr( cli_mod.subprocess, "run", lambda cmd, **kw: types.SimpleNamespace(returncode=1, stderr=huge_stderr), @@ -163,7 +163,7 @@ def test_regression_none_stderr_does_not_crash( ) -> None: """On some platforms/configurations proc.stderr can be None even with stderr=PIPE (e.g. encoding errors). _install_sidecar() must handle this.""" - monkeypatch.setattr(cli_mod, "_node_command", lambda _: "/usr/bin/npm") + monkeypatch.setattr(cli_mod, "find_node_executable", lambda _: "/usr/bin/npm") monkeypatch.setattr( cli_mod.subprocess, "run", lambda cmd, **kw: types.SimpleNamespace(returncode=1, stderr=None), @@ -188,7 +188,7 @@ def test_regression_stale_log_not_surfaced_after_successful_reinstall( error_log.write_text("stale: npm ERR! old failure", encoding="utf-8") # Successful reinstall clears the log - monkeypatch.setattr(cli_mod, "_node_command", lambda _: "/usr/bin/npm") + monkeypatch.setattr(cli_mod, "find_node_executable", lambda _: "/usr/bin/npm") monkeypatch.setattr( cli_mod.subprocess, "run", lambda cmd, **kw: types.SimpleNamespace(returncode=0, stderr=""), diff --git a/tests/pm/test_cua_driver_package.py b/tests/pm/test_cua_driver_package.py new file mode 100644 index 0000000000..d9821ba31e --- /dev/null +++ b/tests/pm/test_cua_driver_package.py @@ -0,0 +1,58 @@ +"""CUA archive staging preserves the native host payload, without running it.""" + +import tarfile +import zipfile + +import pytest + +from pm import Lockfile, Store, get_package, paths + + +@pytest.mark.parametrize("target", ["darwin-arm64", "darwin-x64"]) +def test_macos_cua_selects_bundle_binary_and_preserves_signature(tmp_path, target): + package = get_package("cua-driver") + lock = Lockfile(paths.lockfile_path()) + version = lock.version(package.name) + assert version is not None + payload = tmp_path / "payload" + app = payload / "CuaDriver.app" + members = { + "cua-driver": b"standalone executable fixture", + "CuaDriver.app/Contents/MacOS/cua-driver": b"bundle executable fixture", + "CuaDriver.app/Contents/MacOS/cua-cursor-theme": b"cursor helper fixture", + "CuaDriver.app/Contents/Info.plist": b"bundle metadata fixture", + "CuaDriver.app/Contents/_CodeSignature/CodeResources": b"signature fixture", + } + for name, content in members.items(): + file = payload / name + file.parent.mkdir(parents=True, exist_ok=True) + file.write_bytes(content) + archive = tmp_path / "cua.tar.gz" + with tarfile.open(archive, "w:gz") as tar: + tar.add(payload, arcname="cua-release") + staged = tmp_path / "staged" + package.unpack(archive, staged, target) + package.stage(Store(tmp_path / "store"), staged, version, target) + + assert package.binary(staged, target) == staged / app.relative_to(payload) / "Contents/MacOS/cua-driver" + for name, content in members.items(): + assert (staged / name).read_bytes() == content + assert lock.artifacts(package.name, target)[0]["url"] == package.fetch_url(version, target) + assert "-binary.tar.gz" not in package.fetch_url(version, target) + + +def test_windows_cua_keeps_uiaccess_and_cursor_helpers(tmp_path): + package = get_package("cua-driver") + archive = tmp_path / "cua.zip" + members = {name: name.encode() for name in ( + "cua-driver.exe", "cua-driver-uia.exe", "cua-cursor-theme.exe", "cua_driver_sdk.dll", + )} + with zipfile.ZipFile(archive, "w") as zipped: + for name, content in members.items(): + zipped.writestr(name, content) + staged = tmp_path / "staged" + package.unpack(archive, staged, "win32-x64") + package.stage(Store(tmp_path / "store"), staged, "fixture", "win32-x64") + assert package.binary(staged, "win32-x64") == staged / "cua-driver.exe" + for name, content in members.items(): + assert (staged / name).read_bytes() == content \ No newline at end of file diff --git a/tests/pm/test_custom_root_union.py b/tests/pm/test_custom_root_union.py index 7df2235968..de05778ae6 100644 --- a/tests/pm/test_custom_root_union.py +++ b/tests/pm/test_custom_root_union.py @@ -4,10 +4,8 @@ plugin-deps union the same as standard ~/.hermes ones. get_default_hermes_root() is the ONE authority: HERMES_HOME outside the default (e.g. /opt/data in Docker) → that root directly; profile-mode HERMES_HOME (/profiles/) → . The union's profile scan -and the bisect disable write-back must both flow through it — the bug -this guards: hardcoded Path.home()/.hermes/profiles silently omitted -custom-root profiles (enabled dep plugins never joined the union; disable -decisions never wrote back). +must flow through it: hardcoded Path.home()/.hermes/profiles silently +omitted custom-root profiles and their enabled dependency plugins. """ from __future__ import annotations @@ -68,31 +66,6 @@ def test_custom_hermes_home_profile_joins_union(tmp_path, monkeypatch): assert ordered.get(profile_home / "plugins") == ["dep-plug"] -def test_custom_hermes_home_disable_writes_back(tmp_path, monkeypatch): - """Bisect disable decisions must write back to the CUSTOM-root - profile's config — the resolver disabling a plugin there updates - that profile's enabled list.""" - custom_root = tmp_path / "opt-data" - profile_home = custom_root / "profiles" / "worker" - _write_enabled(profile_home, ["bad-plug", "keep-plug"]) - _make_dep_plugin(profile_home / "plugins", "bad-plug") - _make_dep_plugin(profile_home / "plugins", "keep-plug") - - monkeypatch.setenv("HERMES_HOME", str(custom_root)) - - removed = pstate.disable_plugins(["bad-plug"]) - assert removed[str(profile_home)] == ["bad-plug"] - - # the profile's config reflects the removal - with (profile_home / "config.yaml").open(encoding="utf-8-sig") as f: - cfg = yaml.safe_load(f) - assert cfg["plugins"]["enabled"] == ["keep-plug"] - - # and the union no longer sees the disabled member - members = ws.enabled_member_dirs() - assert [p.name for p in members] == ["keep-plug"] - - def test_standard_layout_still_works(tmp_path, monkeypatch): """The default-home path: profile under the (monkeypatched) default root, standard layout. Guards the derivation change didn't break the diff --git a/tests/pm/test_features.py b/tests/pm/test_features.py index be73001d69..e5f258702f 100644 --- a/tests/pm/test_features.py +++ b/tests/pm/test_features.py @@ -95,16 +95,29 @@ def test_sync_venv_allows_frozen_extras_when_lazy_off(rooted, monkeypatch): feats.write_features(["web"]) import sys + from pm import paths + from pm.lock import Facts + from hermes_cli.runtime_paths import install_state_dir, runtime_facts_path ensure_mod = sys.modules["pm.ensure"] - # lazy off, request within the frozen set: passes the gate (may still - # no-op on the stamp — we only assert no refusal here, by making the - # stamp match so sync_venv returns early) + # Matching stamp alone cannot certify a vanished environment. Reuse only + # the recorded selection while retaining the disabled acquisition policy. + repo = rooted / "repo" + repo.mkdir() + monkeypatch.setattr(paths, "repo_root", lambda: repo) + environment = install_state_dir(repo) / "environments" / "frozen" / "venv" + environment.mkdir(parents=True) + (environment / "pyvenv.cfg").write_text("home = fixture\n") + Facts(runtime_facts_path(repo)).record_state("venv", "stamp", ["web"], environment=environment) monkeypatch.setattr(ensure_mod, "lazy_installs_allowed", lambda: False) venv_pkg = ensure_mod.get_package("venv") monkeypatch.setattr( venv_pkg, "expected_stamp", lambda extras: "stamp" ) - monkeypatch.setattr(ensure_mod, "_facts", lambda: {"venv": {"stamp": "stamp", "extras": ["web"]}}) - ensure_mod.sync_venv(["web"]) # no raise + monkeypatch.setattr(venv_pkg, "apply", lambda *args, **kwargs: pytest.fail("current frozen environment rebuilt")) + ensure_mod.sync_venv(["web"]) + (environment / "pyvenv.cfg").unlink() + from pm.package import InstallError + with pytest.raises(InstallError, match="lazy installs are disabled"): + ensure_mod.sync_venv(["web"]) diff --git a/tests/pm/test_lazy_install_policy.py b/tests/pm/test_lazy_install_policy.py new file mode 100644 index 0000000000..c632e545d1 --- /dev/null +++ b/tests/pm/test_lazy_install_policy.py @@ -0,0 +1,30 @@ +"""PM reads boolean install policy from real config, without CLI output.""" + +import pytest + +import pm +from hermes_cli.config import get_config_path + + +@pytest.mark.parametrize("content,expected", [ + ("{}\n", True), + ("security:\n allow_lazy_installs: true\n", True), + ("security:\n allow_lazy_installs: false\n", False), + ("security:\n allow_lazy_installs: 'false'\n", False), + ("security: [\n", False), +]) +def test_real_policy_is_boolean_and_fails_closed(monkeypatch, capsys, content, expected): + monkeypatch.delenv("HERMES_DISABLE_LAZY_INSTALLS", raising=False) + config = get_config_path() + config.write_text(content, encoding="utf-8") + + assert pm.lazy_installs_allowed() is expected + assert capsys.readouterr().out == "" + assert config.read_text(encoding="utf-8") == content + + +def test_internal_disable_overrides_enabled_config(monkeypatch): + get_config_path().write_text("security:\n allow_lazy_installs: true\n", encoding="utf-8") + monkeypatch.setenv("HERMES_DISABLE_LAZY_INSTALLS", "1") + + assert not pm.lazy_installs_allowed() diff --git a/tests/pm/test_node_sidecar.py b/tests/pm/test_node_sidecar.py index 0d6b110e5c..2e3d915784 100644 --- a/tests/pm/test_node_sidecar.py +++ b/tests/pm/test_node_sidecar.py @@ -1,126 +1,119 @@ -"""Tests: install_node_sidecar — the npm ci executor for plugin package.json -sidecars (plugin-deps plan §B item 2, wired). Hermetic: runner + binary -injected, lazy-gate patched.""" - -from __future__ import annotations +"""Plugin-local npm installs retain PM's paired Node and consent policy.""" +import json +import os from pathlib import Path -from types import SimpleNamespace +import shutil +import shlex +import subprocess import pytest -import pm.workspace as ws +import pm +from pm.package import InstallError, Runner, compose_env +from pm.workspace import install_node_sidecar -@pytest.fixture -def lazy_on(monkeypatch): - import sys - - if "pm.ensure" not in sys.modules: - import importlib - - importlib.import_module("pm.ensure") - ensure_mod = sys.modules["pm.ensure"] - monkeypatch.setattr(ensure_mod, "lazy_installs_allowed", lambda: True) +def test_no_package_json_never_acquires_npm(tmp_path, monkeypatch): + def unexpected(*args, **kwargs): + pytest.fail("a dependency-free plugin must not acquire npm") + monkeypatch.setattr(pm, "ensure", unexpected) + assert install_node_sidecar(tmp_path) is None -def _plug(tmp_path: Path, with_lock: bool = False) -> Path: - plug = tmp_path / "node-plug" - plug.mkdir() - (plug / "package.json").write_text('{"name": "node-plug"}\n', encoding="utf-8") - if with_lock: - (plug / "package-lock.json").write_text("{}\n", encoding="utf-8") - return plug +@pytest.mark.parametrize("explicit", [False, True]) +def test_acquisition_receives_consent_and_reports_refusal(tmp_path, monkeypatch, explicit): + import importlib - -def test_no_package_json_is_a_noop(tmp_path, lazy_on): - plug = tmp_path / "plain" - plug.mkdir() - assert ws.install_node_sidecar(plug, npm_bin="npm") is None - - -def test_ci_when_lockfile_present(tmp_path, lazy_on): - plug = _plug(tmp_path, with_lock=True) + monkeypatch.setattr(importlib.import_module("pm.ensure"), "lazy_installs_allowed", lambda: True) + (tmp_path / "package.json").write_text('{}') calls = [] - - def runner(cmd, **k): - calls.append(cmd) - return SimpleNamespace(returncode=0, stdout="", stderr="") - - assert ws.install_node_sidecar(plug, npm_bin="npm", runner=runner) is None - assert calls == [["npm", "ci", "--no-audit", "--no-fund"]] + def refused(name, **kwargs): + calls.append((name, kwargs)) + raise InstallError(name, "acquisition refused") + monkeypatch.setattr(pm, "ensure", refused) + reason = install_node_sidecar(tmp_path, explicit=explicit) + assert "acquisition refused" in reason + assert calls == [("npm", {"explicit": explicit})] -def test_install_without_lockfile(tmp_path, lazy_on): - plug = _plug(tmp_path) # no package-lock.json - calls = [] - - def runner(cmd, **k): - calls.append(cmd) - return SimpleNamespace(returncode=0, stdout="", stderr="") - - assert ws.install_node_sidecar(plug, npm_bin="npm", runner=runner) is None - assert calls == [["npm", "install", "--no-audit", "--no-fund"]] - - -def test_lazy_off_refuses(tmp_path, monkeypatch): - import sys - - if "pm.ensure" not in sys.modules: - import importlib - - importlib.import_module("pm.ensure") - ensure_mod = sys.modules["pm.ensure"] - monkeypatch.setattr(ensure_mod, "lazy_installs_allowed", lambda: False) - plug = _plug(tmp_path) - reason = ws.install_node_sidecar(plug, npm_bin="npm") - assert reason and "disabled" in reason - - -def test_npm_failure_returns_reason_not_raise(tmp_path, lazy_on): - plug = _plug(tmp_path) - - def runner(cmd, **k): - return SimpleNamespace(returncode=1, stdout="", stderr="ERESOLVE unable to resolve dependency tree") - - reason = ws.install_node_sidecar(plug, npm_bin="npm", runner=runner) - assert "exited 1" in reason and "ERESOLVE" in reason - - -def test_real_npm_installs_locked_sidecar_and_keeps_parent_unchanged(tmp_path, lazy_on): - import json - import os - import shutil - import subprocess - - npm = shutil.which("npm.cmd" if os.name == "nt" else "npm") - node = shutil.which("node") +@pytest.mark.platforms("posix") +def test_real_npm_uses_paired_node_with_empty_ambient_path(tmp_path, monkeypatch): + npm, node = shutil.which("npm"), shutil.which("node") if not npm or not node: pytest.skip("npm and node are required") - dependency = tmp_path / "side-dep" + # npm's real JS entrypoint uses /usr/bin/env node. Its paired Node lives + # in a different PATH entry, just as the two PM packages do. + npm_cli = next(iter(Path(npm).resolve().parent.parent.glob("lib/npm*/bin/npm-cli.js")), Path(npm).resolve()) + npm_bin, node_bin = tmp_path / "npm/bin", tmp_path / "node/bin" + npm_bin.mkdir(parents=True) + node_bin.mkdir(parents=True) + wrapper = npm_bin / "npm" + wrapper.write_text(f'#!/bin/sh\nexec node {shlex.quote(str(npm_cli))} "$@"\n') + wrapper.chmod(0o755) + (node_bin / "node").symlink_to(node) + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home")) + monkeypatch.setenv("PATH", "") + monkeypatch.setenv("HERMES_DISABLE_LAZY_INSTALLS", "1") + context = Runner("npm", compose_env([ + {"PATH": [str(node_bin)]}, {"PATH": [str(npm_bin)]}, + ])) + calls = [] + def acquire(name, **kwargs): + calls.append((name, kwargs)) + return context + monkeypatch.setattr(pm, "ensure", acquire) + dependency, plugin = tmp_path / "side-dep", tmp_path / "plugin" dependency.mkdir() + plugin.mkdir() (dependency / "package.json").write_text(json.dumps({"name": "side-dep", "version": "1.0.0", "main": "index.js"})) (dependency / "index.js").write_text("module.exports = 'isolated';") - plugin = tmp_path / "plugin" - plugin.mkdir() - (plugin / "package.json").write_text(json.dumps({"name": "plugin", "version": "1.0.0", "dependencies": {"side-dep": "file:../side-dep"}})) + manifest = {"name": "plugin", "version": "1.0.0", "dependencies": {"side-dep": "file:../side-dep"}} + (plugin / "package.json").write_text(json.dumps(manifest)) ambient = dict(os.environ) - assert ws.install_node_sidecar(plugin, npm_bin=npm) is None + assert install_node_sidecar(plugin, explicit=True) is None lock = (plugin / "package-lock.json").read_bytes() - assert ws.install_node_sidecar(plugin, npm_bin=npm) is None + # ci must remove an extraneous directory; another `install` would not + # reliably prove that the lockfile selected the clean-install path. + stale = plugin / "node_modules/stale-file" + stale.write_text("remove me") + assert install_node_sidecar(plugin, explicit=True) is None + assert not stale.exists() assert (plugin / "package-lock.json").read_bytes() == lock - assert dict(os.environ) == ambient - result = subprocess.run([node, "-e", "console.log(require('side-dep'))"], cwd=plugin, capture_output=True, text=True, timeout=30) + result = context.run(["node", "-e", "console.log(require('side-dep'))"], cwd=plugin, capture_output=True, text=True, timeout=30) assert result.returncode == 0, result.stderr assert result.stdout.strip() == "isolated" assert not (tmp_path / "node_modules").exists() + assert dict(os.environ) == ambient + assert calls == [("npm", {"explicit": True})] * 2 -def test_runner_explosion_is_a_reason(tmp_path, lazy_on): - plug = _plug(tmp_path) +def test_npm_process_failure_is_reported(tmp_path, monkeypatch): + (tmp_path / "package.json").write_text('{}') + binary = tmp_path / ("npm.cmd" if os.name == "nt" else "npm") + binary.write_text("process boundary fixture") + binary.chmod(0o755) + class FailedRunner: + env = {"PATH": str(tmp_path)} + def run(self, command, **kwargs): + return subprocess.CompletedProcess(command, 1, "", "ERESOLVE dependency conflict") + monkeypatch.setattr(pm, "ensure", lambda *args, **kwargs: FailedRunner()) + reason = install_node_sidecar(tmp_path, explicit=True) + assert "exited 1" in reason and "ERESOLVE" in reason - def runner(cmd, **k): - raise OSError("spawn denied") - reason = ws.install_node_sidecar(plug, npm_bin="npm", runner=runner) - assert "failed to run" in reason +def test_on_demand_sidecar_install_respects_lazy_refusal(tmp_path, monkeypatch): + (tmp_path / "package.json").write_text('{}') + monkeypatch.setenv("HERMES_DISABLE_LAZY_INSTALLS", "1") + binary = tmp_path / ("npm.cmd" if os.name == "nt" else "npm") + binary.write_text("process boundary fixture") + binary.chmod(0o755) + class InstalledRunner: + env = {"PATH": str(tmp_path)} + def run(self, *args, **kwargs): + pytest.fail("installed npm must not mutate sidecars when on-demand installs are disabled") + monkeypatch.setattr(pm, "ensure", lambda *args, **kwargs: InstalledRunner()) + reason = install_node_sidecar(tmp_path) + assert reason and "disabled" in reason \ No newline at end of file diff --git a/tests/pm/test_plugin_survival_contract.py b/tests/pm/test_plugin_survival_contract.py index 235d98e5db..696038a952 100644 --- a/tests/pm/test_plugin_survival_contract.py +++ b/tests/pm/test_plugin_survival_contract.py @@ -87,8 +87,6 @@ def test_sidecar_no_root_pyproject_excludes_nested_and_external(tmp_path, monkey assert ws._is_member_candidate(wrapper) is False, ( "a wrapper root without pyproject/dep keys must never be a member candidate" ) - scan = ws.scan_plugin(wrapper) - assert scan["pyproject"] is False and scan["legacy_deps"] is False members = ws.enabled_member_dirs() member_names = [p.name for p in members] diff --git a/tests/pm/test_plugins_state.py b/tests/pm/test_plugins_state.py index 73fb8de8be..37ffd9fc20 100644 --- a/tests/pm/test_plugins_state.py +++ b/tests/pm/test_plugins_state.py @@ -1,9 +1,4 @@ -"""pm.plugins_state: order-preserving enabled reads + disable write-back. - -The union is cross-profile and recency-ordered; the bisect writes its -disable decisions back through the same config.yaml the plugins CLI -owns. -""" +"""pm.plugins_state: complete, order-preserving cross-profile discovery.""" from __future__ import annotations @@ -102,30 +97,6 @@ def test_enabled_list_preserves_config_order(homes): assert by_root[default_home / "plugins"] == ["z-first-enabled", "a-second"] -def test_disable_plugins_removes_across_homes(homes): - default_home, profile_home = homes - _write_config(default_home, ["bad-plug", "keep-plug"]) - _write_config(profile_home, ["bad-plug", "other"]) - - removed = pstate.disable_plugins(["bad-plug"]) - assert removed[str(default_home)] == ["bad-plug"] - assert removed[str(profile_home)] == ["bad-plug"] - - by_root = pstate.enabled_plugins_ordered() - assert by_root[default_home / "plugins"] == ["keep-plug"] - assert by_root[profile_home / "plugins"] == ["other"] - - -def test_disable_plugins_noop_when_not_enabled(homes): - default_home, _ = homes - _write_config(default_home, ["keep-plug"]) - removed = pstate.disable_plugins(["not-there"]) - assert removed == {} - # config untouched - by_root = pstate.enabled_plugins_ordered() - assert by_root[default_home / "plugins"] == ["keep-plug"] - - @pytest.mark.parametrize("content", ["{ not yaml", "[]", "plugins: wrong", "plugins:\n enabled: wrong", "memory: wrong"]) def test_enabled_read_refuses_invalid_existing_config(homes, content): default_home, _ = homes @@ -189,47 +160,6 @@ def test_memory_provider_already_enabled_not_duplicated(homes): assert by_root[default_home / "plugins"] == ["dual"] # once, not twice -def test_disable_preserves_comments_and_formatting(homes): - """C18: the disable write must go through the round-trip YAML writer — - comments/quotes/formatting OUTSIDE the mutated plugins.enabled key - survive (a truncate reserialization of the whole file loses them).""" - default_home, _ = homes - (default_home / "config.yaml").write_text( - "# my personal config — do not reformat\n" - "plugins:\n" - " enabled:\n" - " - 'bad-plug'\n" - " - keep-plug\n" - "model: 'glm-5.3'\n" - "# model notes below\n", - encoding="utf-8", - ) - pstate.disable_plugins(["bad-plug"]) - text = (default_home / "config.yaml").read_text(encoding="utf-8") - assert "# my personal config — do not reformat" in text - assert "# model notes below" in text - assert "'glm-5.3'" in text # quoting style preserved - assert "- keep-plug" in text - # and the removal actually happened - assert "bad-plug" not in text - - -def test_disable_write_failure_is_surfaced(homes, monkeypatch): - """A failed write must not silently claim the plugin was removed.""" - default_home, profile_home = homes - _write_config(default_home, ["bad-plug"]) - _write_config(profile_home, ["bad-plug"]) - - import utils - - def boom(path, key_path, value): - raise OSError("disk full") - - monkeypatch.setattr(utils, "atomic_roundtrip_yaml_update", boom) - with pytest.raises(OSError, match="disk full"): - pstate.disable_plugins(["bad-plug"]) - - def test_read_parses_config_once_per_home(homes, monkeypatch): """enabled_plugins_ordered must parse each home's config.yaml once, not once for plugins.enabled and again for memory.provider.""" @@ -249,16 +179,3 @@ def test_read_parses_config_once_per_home(homes, monkeypatch): monkeypatch.setattr(utils, "fast_safe_load", counting) pstate.enabled_plugins_ordered() assert len(calls) == 2 # one per home, not one per query - - -def test_disable_surfaces_malformed_config(homes): - """An existing but unparseable config.yaml must not be silently - skipped — disable would report success while the plugin stays - enabled in that home. It must raise, naming the home.""" - default_home, profile_home = homes - _write_config(default_home, ["bad-plug"]) - (profile_home / "config.yaml").write_text( - "plugins:\n enabled: [unclosed\n", encoding="utf-8" - ) - with pytest.raises(ValueError, match=re.escape(str(profile_home))): - pstate.disable_plugins(["bad-plug"]) diff --git a/tests/pm/test_receipt.py b/tests/pm/test_receipt.py index d00c8ecf9d..6d94bb3847 100644 --- a/tests/pm/test_receipt.py +++ b/tests/pm/test_receipt.py @@ -50,18 +50,15 @@ def test_begin_record_finalize_roundtrip(homed): receipt.begin("sync") receipt.record_step("uv-lock", True) receipt.record_venv_rebuild(True) - receipt.record_bisect( - [{"plugin": "bad", "action": "disabled", "reason": "conflict"}] - ) receipt.record_feature_list(["web", "acp"]) - path = receipt.finalize("bisected") + path = receipt.finalize("ok") assert path is not None and path.is_file() data = json.loads(path.read_text(encoding="utf-8-sig")) assert data["kind"] == "sync" - assert data["outcome"] == "bisected" + assert data["outcome"] == "ok" assert data["venv_rebuild"] == {"ok": True, "reason": ""} - assert data["plugin_bisect"][0]["plugin"] == "bad" + assert data["steps"][0]["name"] == "uv-lock" assert data["feature_list"] == ["web", "acp"] @@ -253,10 +250,10 @@ def test_copied_context_finalize_does_not_finish_parent(homed): def test_recorded_values_are_not_mutable_through_the_input(): receipt.begin("sync") - decisions = [{"plugin": "a", "action": "kept"}] - receipt.record_bisect(decisions) - decisions[0]["action"] = "disabled" - assert receipt.snapshot()["plugin_bisect"][0]["action"] == "kept" + checks = [{"plugin": "a", "result": {"compatible": True}}] + receipt.record_plugin_checks(checks) + checks[0]["result"]["compatible"] = False + assert receipt.snapshot()["plugin_checks"][0]["result"]["compatible"] is True def test_snapshot_returns_a_copy(): diff --git a/tests/pm/test_source_update_launch.py b/tests/pm/test_source_update_launch.py index 52517f2c5f..cdde8c09e6 100644 --- a/tests/pm/test_source_update_launch.py +++ b/tests/pm/test_source_update_launch.py @@ -65,16 +65,140 @@ def source_launch(tmp_path, monkeypatch, isolated_python): ) pm.lock_project(root, offline=True, explicit=True) - # Exercise the launcher's real store lookup without fabricating tool facts. - # This is a real executable, not a fake installer or successful shell stub. - store_python = tmp_path / "store" / "python-test" / "bin" / "python3" + # Acquisition uses the real host interpreter; publish its installed fact + # through PM too. Unrecorded store bytes are deliberately not launchable. + from pm.store import current_target, tree_digest + + store = paths.store_root() + entry = store / "python-test" + store_python = entry / "bin" / "python3" store_python.parent.mkdir(parents=True) - # A Nix Python wrapper resets sys.executable to its own path. PM installs - # an actual interpreter, so use the underlying binary rather than a wrapper. + # A Nix wrapper resets sys.executable; use the underlying binary instead. store_python.symlink_to(sys._base_executable) + Facts(paths.facts_path()).record( + "python", "test", entry.name, {"PATH": [str(store_python.parent)]}, store, + target=current_target(), digest=tree_digest(entry), + ) return root, store_python, command +@pytest.mark.platforms("posix") +@pytest.mark.parametrize("update", ["launch", "sync", "checkout", "pm-update"]) +def test_source_python_pin_update_survives_real_gc(source_launch, tmp_path, monkeypatch, update): + from hermes_cli import _launchers, update_cmd_maint + from pm.cli import cmd_gc + from pm.lock import Lockfile + from pm.store import current_target, tree_digest + from tests.hermes_cli.test_source_launcher_publication import BOOT_FILES + + root, old_python, _ = source_launch + repository = Path(__file__).resolve().parents[2] + for relative in BOOT_FILES: + destination = root / relative + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(repository / relative, destination) + (root / "source_probe.py").write_text( + "import json, sys, selected_probe\n" + "print(json.dumps({'executable': sys.executable, 'value': selected_probe.VALUE}))\n", + encoding="utf-8", + ) + pin = Lockfile(paths.lockfile_path()) + pin.set_pin("python", "A", {}) + pin.save() + pm.sync_venv(explicit=True, project_root=root) + # Initial installation is allowed to publish. No explicit writer is called + # after replacement: the source-update owner must refresh this same command. + _launchers.ensure_install_launchers(root, root / ".hermes" / "bin") + command = _launchers.installation_command(root, module="source_probe") + (site_packages(selected_venv(root)) / "selected_probe.py").write_text("VALUE = 'A'\n") + child_env = {**os.environ, "HERMES_DISABLE_LAZY_INSTALLS": "1"} + before = subprocess.run(command, env=child_env, capture_output=True, text=True, timeout=30) + assert before.returncode == 0, before.stderr + assert json.loads(before.stdout)["executable"] == str(old_python) + + # Only acquisition is substituted. This is a new real interpreter entry, + # a changed tool pin and a real Facts publication, not a launcher rewrite. + store = paths.store_root() + new_python = store / "python-B" / "bin" / "python3" + new_python.parent.mkdir(parents=True) + new_python.symlink_to(sys._base_executable) + Facts(paths.facts_path()).record( + "python", "B", "python-B", {"PATH": [str(new_python.parent)]}, store, + target=current_target(), digest=tree_digest(new_python.parent.parent), + ) + if update != "pm-update": + pin.set_pin("python", "B", {}) + pin.save() + assert not pm.venv_is_current(project_root=root) + previous = selected_venv(root) + if update == "launch": + assert venv_sync.prepare_launch(root, []) == new_python + elif update == "sync": + assert venv_sync.sync(root) == {"state": "synced", "ok": True} + elif update == "checkout": + # Frontend compilation is not part of the dependency/launcher contract. + with monkeypatch.context() as build: + build.setattr(update_cmd_maint.subprocess, "run", lambda *args, **kwargs: None) + # PM's worker uses Popen, so its transaction still runs unchanged. + update_cmd_maint._prepare_updated_checkout(root, desktop=False) + else: + from types import SimpleNamespace + from pm import cli + from pm.update import Resolved + + monkeypatch.setattr(paths, "repo_root", lambda: root) + monkeypatch.setattr(cli, "repo_root", lambda: root) + # The latest release and artifact acquisition are fixture inputs; the + # CLI must still pin, ensure, synchronize and publish through its owners. + monkeypatch.setattr(cli, "resolve_package", lambda *a, **k: Resolved("python", "A", "semver", "B")) + monkeypatch.setattr(cli, "_pin_artifacts", lambda *a: {}) + monkeypatch.setattr(importlib.import_module("pm.ensure"), "sync_venv", pm.sync_venv) + assert cli.cmd_update(SimpleNamespace(names=["python"], target=None, check=False, uv=False, npm=False)) == 0 + assert Lockfile(paths.lockfile_path()).version("python") == "B" + assert pm.venv_is_current(project_root=root) + assert selected_venv(root) != previous + (site_packages(selected_venv(root)) / "selected_probe.py").write_text("VALUE = 'B'\n") + monkeypatch.setattr(paths, "repo_root", lambda: root) + assert cmd_gc(None) == 0 + assert not old_python.exists(), "real PM GC did not remove the superseded Python" + assert new_python.is_file() + after = subprocess.run(command, env=child_env, capture_output=True, text=True, timeout=30) + assert after.returncode == 0, after.stderr + assert json.loads(after.stdout) == {"executable": str(new_python), "value": "B"} + + +@pytest.mark.platforms("posix") +def test_launcher_publication_failure_retries_without_rebuilding_dependencies(source_launch, monkeypatch): + from hermes_cli import _launchers + + root, store_python, _ = source_launch + with monkeypatch.context() as failed_publication: + failed_publication.setattr(_launchers, "ensure_install_launchers", lambda *a: []) + result = venv_sync.sync(root) + assert not result["ok"] and "launcher publication failed" in result["detail"] + assert pm.venv_is_current(project_root=root) + committed = runtime_facts_path(root).read_bytes() + assert venv_sync.prepare_launch(root, []) == store_python + assert runtime_facts_path(root).read_bytes() == committed + assert (root / ".hermes" / "bin" / "hermes").is_file() + + +@pytest.mark.platforms("posix") +@pytest.mark.parametrize("checkout", [False, True]) +def test_source_publication_leaves_external_install_launchers_alone(source_launch, checkout): + root, _, _ = source_launch + (root / "install-stamp.json").write_text( + json.dumps({"updateMechanism": "external", "distribution": "nix"}), encoding="utf-8", + ) + if not checkout: + (root / ".git").rmdir() + launcher = root / ".hermes" / "bin" / "hermes" + launcher.parent.mkdir(parents=True) + launcher.write_text("externally owned launcher\n", encoding="utf-8") + assert venv_sync.sync(root)["ok"] + assert launcher.read_text(encoding="utf-8") == "externally owned launcher\n" + + def _fact(root): fact = Facts(runtime_facts_path(root), strict=True).get("venv") assert fact is not None diff --git a/tests/pm/test_worker.py b/tests/pm/test_worker.py index 44d02f15e2..fc674cd9bd 100644 --- a/tests/pm/test_worker.py +++ b/tests/pm/test_worker.py @@ -127,6 +127,77 @@ def _current_environment(tmp_path, monkeypatch, members): return repo +@pytest.mark.parametrize("member_shape", ["paths", "sources"]) +@pytest.mark.parametrize("route", ["worker", "direct", "foreign-runtime"]) +def test_currency_probe_preserves_union_and_candidate_inputs(client, tmp_path, monkeypatch, isolated_python, + member_shape, route): + import json + from hermes_cli.runtime_paths import runtime_facts_path, selected_venv + from pm.lock import Facts + from pm.packages import Venv + + candidate = tmp_path / "candidate" + candidate.mkdir() + manifest = candidate / "plugin.yaml" + manifest.write_text('name: candidate\npython_dependencies: ["candidate-dep==1"]\n') + members = [candidate] if member_shape == "paths" else {tmp_path / "installed": candidate} + repo = _current_environment(tmp_path, monkeypatch, members) + environment = selected_venv(repo) + recorded = ["incumbent-extra", "provider-extra"] + facts_path = runtime_facts_path(repo) + Facts(facts_path).record_state( + "venv", Venv(repo).expected_stamp(recorded, plugin_dirs=members), recorded, + environment=environment, + ) + monkeypatch.setattr(client, "is_runtime", lambda: route != "worker") + if route == "foreign-runtime": + monkeypatch.setattr(paths, "repo_root", lambda: tmp_path / "other-project") + root_args = {"project_root": repo} if route != "worker" else {} + acquisitions = [] + + def ready_runtime(*, bootstrap): + assert bootstrap is False, "currency probe attempted to bootstrap PM" + acquisitions.append(bootstrap) + return isolated_python + + monkeypatch.setattr("pm.runtime.runtime_python", ready_runtime) + if route != "direct": + engine = importlib.import_module("pm.ensure") + monkeypatch.setattr(engine, "venv_is_current", lambda **kw: pytest.fail("probe ran in caller")) + callbacks = [] + + def select(): + callbacks.append("selected") + return members + + def snapshot(): + return {path.relative_to(tmp_path): (path.read_bytes() if path.is_file() else None) + for path in tmp_path.rglob("*")} + + before = snapshot() + assert client.venv_is_current(extras=["provider-extra"], plugin_dirs=select, **root_args) + assert callbacks == ["selected"] + assert client.venv_is_current(extras=[], plugin_dirs=members, **root_args) + assert not client.venv_is_current(extras=["new-extra"], plugin_dirs=members, **root_args) + assert not client.venv_is_current(extras=recorded, plugin_dirs=[], **root_args) + assert snapshot() == before, "currency queries changed dependency state" + assert bool(acquisitions) is (route != "direct") + + manifest.write_text('name: candidate\npython_dependencies: ["candidate-dep==2"]\n') + changed = snapshot() + assert not client.venv_is_current(extras=["provider-extra"], plugin_dirs=select, **root_args) + assert snapshot() == changed + assert selected_venv(repo) == environment + # Corruption must not be mistaken for a missing or current environment. + data = json.loads(facts_path.read_text()) + data["packages"]["venv"]["extras"] = "provider-extra" + facts_path.write_text(json.dumps(data)) + malformed = snapshot() + with pytest.raises(ValueError, match="invalid recorded dependency state"): + client.venv_is_current(extras=[], plugin_dirs=members, **root_args) + assert snapshot() == malformed + + def _assert_worker_holds_lock(repo): from hermes_cli.runtime_paths import install_state_dir from hermes_cli.runtime_state import _lock diff --git a/tests/pm/test_workspace.py b/tests/pm/test_workspace.py index 551f9abb29..5db887213d 100644 --- a/tests/pm/test_workspace.py +++ b/tests/pm/test_workspace.py @@ -11,6 +11,10 @@ core + plugin deps into ONE lock; conflict = loud refusal. from __future__ import annotations import os +import subprocess +import json +import shutil +import sys from pathlib import Path import pytest @@ -27,6 +31,15 @@ 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 + + wheels = tmp_path / "wheels" + wheels.mkdir() + _wheel(wheels, "httpx", "0.28.1") + _wheel(wheels, "rich", "13.9.4") + uv = shutil.which("uv") + assert uv + monkeypatch.setattr("pm._uv._toolchain", lambda **kwargs: (Path(uv), Path(sys.executable))) core = tmp_path / "core" core.mkdir() (core / "pyproject.toml").write_text( @@ -34,7 +47,9 @@ def layout(tmp_path, monkeypatch): 'name = "hermes-agent"\n' 'version = "0.1.0"\n' 'requires-python = ">=3.11"\n' - 'dependencies = ["httpx==0.28.1"]\n', + 'dependencies = ["httpx==0.28.1"]\n' + '[tool.uv]\npackage=false\nno-index=true\n' + f'find-links=[{json.dumps(wheels.as_posix())}]\n', encoding="utf-8", ) plugins = tmp_path / "home" / "plugins" @@ -62,7 +77,8 @@ def test_workspace_root_is_per_install_not_in_the_store(layout): def test_build_writes_core_pyproject_verbatim(layout): _, core, plug_a, _ = layout - root = ws.build_root([plug_a]) + root = ws.workspace_root() + ws.lock_and_sync([plug_a], root=root, venv_dir=root.parent / "env") text = (root / "pyproject.toml").read_text(encoding="utf-8") core_text = (core / "pyproject.toml").read_text(encoding="utf-8") # core's project table is carried verbatim (name, deps, requires-python) @@ -78,7 +94,8 @@ def test_members_keep_their_source_with_the_generation(layout): import tomllib _, _, plug_a, _ = layout - root = ws.build_root([plug_a]) + root = ws.workspace_root() + ws.lock_and_sync([plug_a], root=root, venv_dir=root.parent / "env") document = tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8")) [relative] = document["tool"]["uv"]["workspace"]["members"] copied = root / relative / "pyproject.toml" @@ -91,16 +108,18 @@ def test_members_keep_their_source_with_the_generation(layout): def test_build_is_idempotent(layout): _, _, plug_a, _ = layout - ws.build_root([plug_a]) + root = ws.workspace_root() + ws.lock_and_sync([plug_a], root=root, venv_dir=root.parent / "env") first = (ws.workspace_root() / "pyproject.toml").read_text(encoding="utf-8") - ws.build_root([plug_a]) + ws.lock_and_sync([plug_a], root=root, venv_dir=root.parent / "env") second = (ws.workspace_root() / "pyproject.toml").read_text(encoding="utf-8") assert first == second def test_zero_plugins_still_builds_a_root_with_no_members(layout): _, _, _, _ = layout - root = ws.build_root([]) + root = ws.workspace_root() + ws.lock_and_sync([], root=root, venv_dir=root.parent / "env") text = (root / "pyproject.toml").read_text(encoding="utf-8") assert 'name = "hermes-agent"' in text assert "[tool.uv.workspace]" not in text or "members = []" in text @@ -175,7 +194,7 @@ def test_enabled_member_dirs_finds_enabled_dep_plugins(tmp_path, monkeypatch): (orphan / "pyproject.toml").write_text("[project]\n", encoding="utf-8") # enabled order = enable recency (legacy enabled first/older, modern - # newest LAST) — order must carry through for the bisect tiebreak. + # newest LAST) — discovery preserves the configured order. monkeypatch.setattr( "pm.plugins_state.enabled_plugins_ordered", lambda: {plugins: ["legacy-plug", "modern-plug", "plain-plug"]}, @@ -199,24 +218,6 @@ def test_enabled_member_dirs_empty_when_nothing_enabled(tmp_path, monkeypatch): assert ws.enabled_member_dirs() == [] -def test_scan_plugin_classifies_dep_surfaces(tmp_path): - full = tmp_path / "full-plug" - full.mkdir() - for name in ("pyproject.toml", "package.json", "packages.py", "plugin.yaml"): - (full / name).write_text("x\n", encoding="utf-8") - scan = ws.scan_plugin(full) - assert scan["pyproject"] and scan["package_json"] and scan["packages_py"] - assert not scan["legacy_deps"] - - legacy = tmp_path / "legacy-plug" - legacy.mkdir() - (legacy / "plugin.yaml").write_text( - "name: legacy\npip_dependencies:\n - \"x>=1\"\n", encoding="utf-8" - ) - scan = ws.scan_plugin(legacy) - assert scan["legacy_deps"] and not scan["pyproject"] - - def test_enabled_member_dirs_ignores_non_profile_entries(tmp_path, monkeypatch): import pm.plugins_state as pstate @@ -280,7 +281,7 @@ def test_sync_failure_is_never_a_conflict(tmp_path, monkeypatch): captured["cmd"] = cmd return FakeProc() - monkeypatch.setattr(ws.subprocess, "run", fake_run) + monkeypatch.setattr(subprocess, "run", fake_run) with pytest.raises(InstallError) as excinfo: ws.lock_and_sync([], [], venv_dir=tmp_path / "candidate") assert not isinstance(excinfo.value, ResolutionConflict) @@ -308,7 +309,7 @@ def test_staging_root_and_env_are_honored_without_live_mutation(monkeypatch, tmp monkeypatch.setattr(ws, "_generate_pyproject", lambda *a, **k: (staging, False)) monkeypatch.setattr("pm._uv._toolchain", lambda **kwargs: (Path("uv"), Path("pm-python"))) monkeypatch.setattr("pm.packages.uv_cache_dir", lambda: tmp_path / "cache") - monkeypatch.setattr(ws.subprocess, "run", fake_run) + monkeypatch.setattr(subprocess, "run", fake_run) live_key = "PM_WORKSPACE_TEST_SENTINEL" os.environ[live_key] = "live" @@ -343,7 +344,7 @@ def test_changed_root_seeds_from_committed_lock_unchanged_keeps_extended( stdout = "" monkeypatch.setattr("pm._uv._toolchain", lambda **kwargs: (Path("uv"), Path("pm-python"))) - monkeypatch.setattr(ws.subprocess, "run", lambda cmd, **k: FakeProc()) + monkeypatch.setattr(subprocess, "run", lambda cmd, **k: FakeProc()) root = ws.workspace_root() venv = tmp_path / "venv" diff --git a/tests/pm/test_workspace_build_inputs.py b/tests/pm/test_workspace_build_inputs.py index 46d2da56fc..dd6a6b4b7d 100644 --- a/tests/pm/test_workspace_build_inputs.py +++ b/tests/pm/test_workspace_build_inputs.py @@ -50,17 +50,20 @@ def test_real_build_inputs_stay_in_generated_root(tmp_path, monkeypatch): def test_source_refresh_does_not_need_metadata_change_and_refuses_live_root(tmp_path, monkeypatch): core = tmp_path / "core" core.mkdir() - (core / "pyproject.toml").write_text('[project]\nname="x"\nversion="1"\n') + (core / "pyproject.toml").write_text('[project]\nname="x"\nversion="1"\nrequires-python=">=3.14"\n[tool.uv]\npackage=false\nno-index=true\n') (core / "code.py").write_text("VALUE = 1\n") monkeypatch.setattr(workspace.paths, "repo_root", lambda: core) staged = tmp_path / "staged" - workspace.build_root([], root=staged) + uv = shutil.which("uv") + assert uv + monkeypatch.setattr("pm._uv._toolchain", lambda **kwargs: (Path(uv), Path(sys.executable))) + workspace.lock_and_sync([], root=staged, venv_dir=tmp_path / "env") (core / "code.py").write_text("VALUE = 2\n") - workspace.build_root([], root=staged) + workspace.lock_and_sync([], root=staged, venv_dir=tmp_path / "env") assert (staged / "code.py").read_text() == "VALUE = 2\n" before = (core / "code.py").read_bytes() with pytest.raises(workspace.InstallError, match="source"): - workspace.build_root([], root=core) + workspace.lock_and_sync([], root=core, venv_dir=tmp_path / "env") assert (core / "code.py").read_bytes() == before @@ -68,12 +71,22 @@ def test_legacy_member_is_generated_only_inside_workspace(tmp_path, monkeypatch) import tomllib core, plugin = tmp_path / "core", tmp_path / "readonly-plugin" core.mkdir(); plugin.mkdir() - (core / "pyproject.toml").write_text('[project]\nname="core"\nversion="1"\n') + wheels = tmp_path / "wheels" + wheels.mkdir() + _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' + f'find-links=[{json.dumps(wheels.as_posix())}]\n') manifest = plugin / "plugin.yaml" manifest.write_text('name: legacy\npython_dependencies: ["example>=1,<2"]\n') monkeypatch.setattr(workspace.paths, "repo_root", lambda: core) stamp = workspace.members_stamp([plugin]) - generated = workspace.build_root([plugin], tmp_path / "stage") + uv = shutil.which("uv") + assert uv + monkeypatch.setattr("pm._uv._toolchain", lambda **kwargs: (Path(uv), Path(sys.executable))) + generated = tmp_path / "stage" + workspace.lock_and_sync([plugin], root=generated, venv_dir=tmp_path / "env") metadata = tomllib.loads((generated / "pyproject.toml").read_text()) member = (generated / metadata["tool"]["uv"]["workspace"]["members"][0]).resolve() assert member.is_relative_to(generated) diff --git a/tests/scripts/test_docker_frontend_inputs.py b/tests/scripts/test_docker_frontend_inputs.py new file mode 100644 index 0000000000..2dfd5de83a --- /dev/null +++ b/tests/scripts/test_docker_frontend_inputs.py @@ -0,0 +1,79 @@ +"""Exercise the frontend COPY closure before an image/network build. + +PM environment construction is the only substituted boundary. The copied icon +provider must load, ask for its real dependency group, and invoke the copied +generator. Image-level runtime/permission coverage stays in tests/docker. +""" +from pathlib import Path +import runpy +import shlex +import shutil +from types import SimpleNamespace +from types import ModuleType + +from pathspec import PathSpec + +import pm + + +def test_frontend_copy_closure_reaches_the_icon_provider(tmp_path, monkeypatch): + repo = Path(__file__).resolve().parents[2] + # Apply the frontend's local COPY declarations, not a duplicated filename + # allowlist. Runtime-base supplies PM itself and is separately image-tested. + frontend = False + ignored = PathSpec.from_lines("gitignore", (repo / ".dockerignore").read_text().splitlines()) + + def excluded(directory, names): + return [name for name in names if ignored.match_file( + (Path(directory) / name).relative_to(repo).as_posix() + + ("/" if (Path(directory) / name).is_dir() else ""))] + for line in (repo / "Dockerfile").read_text().splitlines(): + if not line.startswith(("FROM ", "COPY ")): + continue + words = shlex.split(line.rstrip("\\"), comments=True) + if not words: + continue + if words[0] == "FROM": + frontend = words[-1] == "frontend_build" + if not frontend or words[0] != "COPY" or any(word.startswith("--") for word in words[1:]): + continue + sources, destination = words[1:-1], tmp_path / words[-1] + for pattern in sources: + for source in repo.glob(pattern): + target = destination / source.name if words[-1].endswith("/") else destination + if source.is_dir(): + shutil.copytree(source, destination, dirs_exist_ok=True, + ignore=excluded) + else: + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, target) + acquired = [] + + def environment(**kwargs): + acquired.append(kwargs) + return Path("/prepared/icon-python") + + launched = [] + monkeypatch.setattr(pm, "build_environment", environment) + provider = runpy.run_path(str(tmp_path / "scripts/build/icon_environment.py")) + + def run(argv, *, cwd): + assert Path(argv[2]).is_file(), "generator must also be in the stage" + assert cwd == tmp_path + launched.append(argv) + return SimpleNamespace(returncode=0) + + monkeypatch.setattr(provider["subprocess"], "run", run) + assert provider["main"](["--source", str(tmp_path), "--out", str(tmp_path / "icons")]) == 0 + assert acquired[0]["source"] == tmp_path + assert acquired[0]["groups"] == ["icon-build"] + assert acquired[0]["only_groups"] is True + assert launched[0][:2] == ["/prepared/icon-python", "-I"] + + # Exercise the generator's own source loader. Rasterization is not needed + # to prove that every composed SVG's artwork made it into the build context. + monkeypatch.setitem(__import__("sys").modules, "resvg_py", ModuleType("resvg_py")) + generator = runpy.run_path(str(tmp_path / "scripts/generate_icons.py")) + monkeypatch.setitem(generator["IconArt"].__init__.__globals__, "girl_bbox", lambda *args: (0, 0, 512, 512)) + art = generator["IconArt"](tmp_path) + assert art.master_mac and art.master_mac_dark diff --git a/tests/test_desktop_update_target.py b/tests/test_desktop_update_target.py index 49ea913e75..8a4596d3e7 100644 --- a/tests/test_desktop_update_target.py +++ b/tests/test_desktop_update_target.py @@ -11,6 +11,8 @@ import sys import pytest +from tests.installation_launcher_fixture import publish_fixture_launcher + SCRIPTS = Path(__file__).resolve().parents[1] / "scripts" / "desktop-update" FAKE_CLI = """ @@ -19,7 +21,9 @@ import os from pathlib import Path import sys -if __name__ == '__main__': +def main(): + if '--version' in sys.argv: + print('Install directory: ' + os.environ.get('HANDOFF_FOREIGN', str(Path(__file__).resolve().parents[1]))); return 0 if '--help' in sys.argv: print('update options') sys.exit(0) @@ -29,13 +33,17 @@ if __name__ == '__main__': stream.write(json.dumps({'argv': sys.argv[1:], 'home': os.environ.get('HERMES_HOME'), 'install_root': os.environ.get('HERMES_INSTALL_ROOT'), 'cwd': os.getcwd()}) + '\\n') - sys.exit(1 if not previous else 0) + print('Desktop build failed') if os.environ.get('HANDOFF_EXIT') else None + sys.exit(int(os.environ['HANDOFF_EXIT']) if 'HANDOFF_EXIT' in os.environ else (1 if not previous else 0)) + +if __name__ == '__main__': + main() """ -def _run_handoff(tmp_path, target, *, windows=False, inherited_home=True): +def _run_handoff(tmp_path, target, *, windows=False, inherited_home=True, modern=False, code=0, userbin_only=False, foreign=False): install = tmp_path / "checkout with spaces" - if windows: + if windows and not modern: subprocess.run( [sys.executable, "-m", "venv", "--without-pip", str(install / "venv")], check=True, @@ -43,11 +51,17 @@ def _run_handoff(tmp_path, target, *, windows=False, inherited_home=True): timeout=60, ) package = ( - install / "venv" / "Lib" / "site-packages" if windows else install + install / "venv" / "Lib" / "site-packages" if windows and not modern else install ) / "hermes_cli" package.mkdir(parents=True) (package / "__init__.py").touch() (package / "main.py").write_text(FAKE_CLI, encoding="utf-8") + if modern: + launcher = publish_fixture_launcher(install, FAKE_CLI) + if userbin_only: + userbin = tmp_path / '.local/bin' + userbin.mkdir(parents=True) + launcher.rename(userbin / launcher.name) capture = tmp_path / "calls.jsonl" home = tmp_path / "profile home" if inherited_home else tmp_path home.mkdir(exist_ok=True) @@ -63,6 +77,11 @@ def _run_handoff(tmp_path, target, *, windows=False, inherited_home=True): env.pop("HERMES_HOME", None) if inherited_home: env["HERMES_HOME"] = str(home) + if modern: + env["HANDOFF_EXIT"] = str(code) + if foreign: + env["HANDOFF_FOREIGN"] = str(tmp_path) + env["HERMES_RUNTIME_DIR"] = str(tmp_path / "empty-store") if windows: # The disposable runtime has only the fixture CLI; verification is outside # this transport contract and runs its own harmless fixture implementation. @@ -82,15 +101,16 @@ def _run_handoff(tmp_path, target, *, windows=False, inherited_home=True): "-NoUi", ] else: - bin_dir = install / "venv" / "bin" - bin_dir.mkdir(parents=True) - (bin_dir / "python3").symlink_to(sys.executable) - hermes = bin_dir / "hermes" - hermes.write_text( - f'#!/usr/bin/env bash\nexec {shlex.quote(sys.executable)} -m hermes_cli.main "$@"\n', - encoding="utf-8", - ) - hermes.chmod(0o755) + if not modern: + bin_dir = install / "venv" / "bin" + bin_dir.mkdir(parents=True) + (bin_dir / "python3").symlink_to(sys.executable) + hermes = bin_dir / "hermes" + hermes.write_text( + f'#!/usr/bin/env bash\nexec {shlex.quote(sys.executable)} -m hermes_cli.main "$@"\n', + encoding="utf-8", + ) + hermes.chmod(0o755) command = [ "bash", str(SCRIPTS / "posix.sh"), @@ -225,3 +245,25 @@ def test_posix_rejects_invalid_or_conflicting_target_before_update(tmp_path, tar ) def test_windows_rejects_invalid_or_conflicting_target_before_update(tmp_path, target): _assert_rejected(tmp_path, target, windows=True) + + +@pytest.mark.platforms("posix") +@pytest.mark.parametrize("code", [0, 1, 2]) +def test_pm_handoff_uses_published_launcher_and_does_not_retry(tmp_path, code): + result, calls, home, install = _run_handoff(tmp_path, ["--channel", "canary"], modern=True, code=code) + assert result.returncode == code, result.stdout + result.stderr + assert calls == [{"argv": ["update", "--yes", "--gateway", "--channel", "canary"], + "home": str(home), "cwd": str(install), "install_root": str(install)}] + receipt = json.loads((home / ".hermes-update-result.json").read_text()) + assert receipt["ok"] == (code == 0) + assert not (install / "venv").exists() + + +@pytest.mark.platforms("posix") +@pytest.mark.parametrize("foreign", [False, True]) +def test_earlier_pm_userbin_publication_requires_exact_source_identity(tmp_path, foreign): + result, calls, home, install = _run_handoff(tmp_path, [], modern=True, userbin_only=True, foreign=foreign) + assert result.returncode == (3 if foreign else 0), result.stdout + result.stderr + assert len(calls) == (0 if foreign else 1) + assert not (install / '.hermes/bin/hermes').exists() + assert not (install / 'venv').exists() diff --git a/tests/test_desktop_update_windows_python_handoff.py b/tests/test_desktop_update_windows_python_handoff.py index e63eb9dd28..ea599fe84c 100644 --- a/tests/test_desktop_update_windows_python_handoff.py +++ b/tests/test_desktop_update_windows_python_handoff.py @@ -1,131 +1,75 @@ -"""Regression: the Windows Desktop update hand-off must run through python.exe. - -`scripts/desktop-update/windows.ps1` drives `hermes update` for the in-app -Desktop updater. It used to invoke the update through the venv's -`venv\\Scripts\\hermes.exe` console-script launcher. On Windows that launcher is -a real process that keeps `hermes.exe` mapped as its running image and spawns -`python.exe` as a child. The update ends in `uv pip install -e .`, which rewrites -the console-script shims -- including the `hermes.exe` the launcher still has -mapped -- and Windows refuses to replace a file mapped as a running image -("os error 32"). The rename fallback then defers to next reboot via -`MOVEFILE_DELAY_UNTIL_REBOOT`, which needs elevation a Desktop-driven update -does not have, so `uv pip install -e .` exits non-zero, the ZIP fallback repeats -the same sequence, the desktop build stage is never reached, and the pre-build -clean has already removed `apps/desktop/release` -- leaving an install whose -Start Menu shortcut points at a `Hermes.exe` that no longer exists. - -Driving the update as `python.exe -m hermes_cli.main update` puts the inherited -image handle on `python.exe`, which uv never has to replace, so the shim is an -ordinary unlocked file when uv rewrites it. - -This test is source-level because Linux CI cannot execute the PowerShell -hand-off. The invariant it guards is that every `Invoke-HermesStep` call site -(the update, its retry, and the desktop rebuild) drives `$pythonExe`, never the -`$hermesExe` shim. `hermes.exe` may still be *named* in the file for the -step-2 unlock preflight -- that is a lock probe, not an invocation -- so we -assert against the invocation sites specifically. -""" - +"""Native launch/result acceptance: real publisher, no checkout-local venv.""" from __future__ import annotations -import re +import json +import os from pathlib import Path +import subprocess +import pytest -REPO_ROOT = Path(__file__).resolve().parent.parent -WINDOWS_PS1 = REPO_ROOT / "scripts" / "desktop-update" / "windows.ps1" +from tests.installation_launcher_fixture import publish_fixture_launcher +ROOT = Path(__file__).resolve().parents[1] +CLI = """ +import json, os, sys +from pathlib import Path +def main(): + if '--version' in sys.argv: + print('Install directory: ' + str(Path(__file__).resolve().parents[1])); return 0 + if '--help' in sys.argv: + print('--keep-stash'); return 0 + with Path(os.environ['HANDOFF_CALLS']).open('a') as stream: + stream.write(json.dumps({'argv': sys.argv[1:], 'cwd': os.getcwd()}) + '\\n') + print('Desktop build failed') # no warning-driven second build on PM + return int(os.environ['HANDOFF_EXIT']) +if __name__ == '__main__': + sys.exit(main()) +""" -def _read() -> str: - # windows.ps1 is eol=crlf in .gitattributes, so checkouts materialize - # CRLF on disk (CI included). Normalize so the SelfTest-block strip's - # `\n}\n` anchors match regardless of the working-copy line endings. - return WINDOWS_PS1.read_text(encoding="utf-8").replace("\r\n", "\n") - - -def _handoff_source() -> str: - """The script with its ``-SelfTest*`` fixture blocks removed. - - Those blocks exercise the hand-off machinery deliberately -- the pipe-drain - fixture runs a synthetic PowerShell step through ``Invoke-HermesStep`` to - prove the drain cannot deadlock (#90455) -- so they are not update steps - and the "must drive python.exe" rule does not apply to them. Each exits - before any venv/desktop machinery runs. - - Scoped here rather than allow-listing a target, so the rule stays absolute - for every real step. The non-greedy match ends at the first closing brace - at the opening statement's indentation; inner braces are deeper. - """ - return re.sub( - r"\n(?P *)if \(\$SelfTest\w+\) \{.*?\n(?P=indent)\}\n", - "\n", - _read(), - flags=re.S, +@pytest.mark.platforms('windows') +@pytest.mark.parametrize('code', [0, 1, 2]) +def test_pm_handoff_runs_once_and_reports_the_actual_result(tmp_path: Path, code: int) -> None: + install = tmp_path / 'checkout with spaces' + publish_fixture_launcher(install, CLI) + (install / 'hermes_cli/desktop_update_verify.py').write_text('pass\n') + home = tmp_path / 'profile'; home.mkdir() + calls = tmp_path / 'calls.jsonl' + result = subprocess.run( + ['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', + str(ROOT / 'scripts/desktop-update/windows.ps1'), '-InstallRoot', str(install), '-NoUi'], + cwd=tmp_path, env={**os.environ, 'HERMES_HOME': str(home), + 'HERMES_RUNTIME_DIR': str(tmp_path / 'empty-store'), + 'HANDOFF_CALLS': str(calls), 'HANDOFF_EXIT': str(code)}, + capture_output=True, text=True, timeout=120, ) + assert result.returncode == code, result.stdout + result.stderr + assert [json.loads(line) for line in calls.read_text().splitlines()] == [ + {'argv': ['update', '--yes', '--gateway', '--branch', 'main', '--keep-stash'], 'cwd': str(install)} + ] + receipt = json.loads((home / '.hermes-update-result.json').read_text(encoding='utf-8-sig')) + assert receipt['ok'] == (code == 0) + assert not (home / '.hermes-update-in-progress').exists() -def test_invoke_hermes_step_calls_drive_python_not_the_shim() -> None: - source = _handoff_source() - - invocations = re.findall(r"Invoke-HermesStep\s+(\$\w+)", source) - assert invocations, ( - "Expected at least one Invoke-HermesStep call in " - "scripts/desktop-update/windows.ps1; the update hand-off structure " - "changed -- update this guard." - ) - - offenders = [exe for exe in invocations if exe != "$pythonExe"] - assert not offenders, ( - "Every Invoke-HermesStep call in scripts/desktop-update/windows.ps1 " - "must drive $pythonExe, not the hermes.exe shim. Driving the update " - "through the shim keeps hermes.exe mapped as a running image, so uv's " - "final shim rewrite fails with os error 32 and the Desktop update can " - "never complete. Offending target(s): " - f"{sorted(set(offenders))}." - ) - - -def test_update_invocation_uses_module_entrypoint() -> None: - source = _read() - - assert '@("-m", "hermes_cli.main", "update"' in source, ( - "The update step must invoke `python.exe -m hermes_cli.main update ...` " - "so the inherited image handle lands on python.exe, which uv never has " - "to replace." - ) - assert ( - '@("-m", "hermes_cli.main", "desktop", "--force-build", "--build-only")' - in source - ), ( - "The desktop rebuild step must also go through " - "`python.exe -m hermes_cli.main desktop ...` for the same reason." - ) - - -def test_update_no_longer_invokes_the_hermes_exe_shim() -> None: - source = _read() - - assert "Invoke-HermesStep $hermesExe" not in source, ( - "scripts/desktop-update/windows.ps1 still invokes the update through " - "the hermes.exe shim (`Invoke-HermesStep $hermesExe`). That is the " - "exact self-lock this fix removes -- route it through $pythonExe " - "instead." - ) - - -def test_desktop_relaunch_waits_for_an_in_place_rebuild() -> None: - source = _read() - relaunch = re.search( - r"function Start-DesktopRelaunch \{(?P.*?)\n\}\n\nfunction Invoke-HermesStep", - source, - re.DOTALL, - ) - assert relaunch, "Expected Start-DesktopRelaunch in the Windows hand-off script." - - body = relaunch.group("body") - assert "if (-not $RelaunchExe) { return $false }" in body - assert "$relaunchDeadline = (Get-Date).AddSeconds(120)" in body - assert "while (-not (Test-Path -LiteralPath $RelaunchExe))" in body - assert "if ((Get-Date) -ge $relaunchDeadline)" in body - assert "Start-Sleep -Milliseconds 500" in body - assert "[System.Windows.Forms.Application]::DoEvents()" in body +@pytest.mark.platforms('windows') +def test_earlier_pm_userbin_launcher_is_identity_checked(tmp_path: Path) -> None: + home = tmp_path / 'profile' + userbin = home / 'bin'; userbin.mkdir(parents=True) + root = tmp_path / 'source' + launcher = publish_fixture_launcher(root, CLI) + external = userbin / launcher.name + launcher.rename(external) + wrong = tmp_path / 'other' + (wrong / 'pm').mkdir(parents=True) + (wrong / 'hermes_cli').mkdir() + (wrong / 'hermes_cli/_launchers.py').touch() + helper = str(ROOT / 'scripts/desktop-update/runtime.ps1').replace("'", "''") + for target, expected_code in [(root, 0), (wrong, 1)]: + script = f". '{helper}'; try {{ @(Get-HermesRuntimeCommand -InstallRoot '{target}') | ConvertTo-Json -Compress }} catch {{ exit 1 }}" + result = subprocess.run(['powershell', '-NoProfile', '-Command', script], + env={**os.environ, 'HERMES_HOME': str(home)}, + capture_output=True, text=True, timeout=45) + assert result.returncode == expected_code, result.stdout + result.stderr + if expected_code == 0: + assert json.loads(result.stdout) == str(external) diff --git a/tests/test_desktop_update_windows_retry_policy.py b/tests/test_desktop_update_windows_retry_policy.py deleted file mode 100644 index e727835947..0000000000 --- a/tests/test_desktop_update_windows_retry_policy.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Windows Desktop handoff retry policy behavior.""" - -from __future__ import annotations - -import json -import subprocess -from pathlib import Path - -import pytest - - -REPO_ROOT = Path(__file__).resolve().parent.parent -RETRY_POLICY = REPO_ROOT / "scripts" / "desktop-update" / "retry-policy.ps1" - - -@pytest.mark.platforms("windows") -def test_retry_policy_distinguishes_self_lock_deferral(tmp_path: Path) -> None: - install_root = tmp_path / "hermes-agent" - install_root.mkdir() - marker = install_root / ".update-incomplete" - - policy = str(RETRY_POLICY).replace("'", "''") - root = str(install_root).replace("'", "''") - command = f""" - . '{policy}' - $withoutMarker = @( - (Test-HermesUpdateShouldRetry -ExitCode 0 -InstallRoot '{root}'), - (Test-HermesUpdateShouldRetry -ExitCode 1 -InstallRoot '{root}'), - (Test-HermesUpdateShouldRetry -ExitCode 2 -InstallRoot '{root}') - ) - New-Item -ItemType File -Path (Join-Path '{root}' '.update-incomplete') | Out-Null - $withMarker = Test-HermesUpdateShouldRetry -ExitCode 2 -InstallRoot '{root}' - @{{ withoutMarker = $withoutMarker; withMarker = $withMarker }} | - ConvertTo-Json -Compress - """ - result = subprocess.run( - [ - "powershell", - "-NoProfile", - "-ExecutionPolicy", - "Bypass", - "-Command", - command, - ], - check=False, - capture_output=True, - text=True, - timeout=120, - ) - - assert result.returncode == 0, result.stdout + result.stderr - assert json.loads(result.stdout) == { - "withoutMarker": [False, True, False], - "withMarker": True, - } - assert marker.exists() diff --git a/tests/test_hermes_constants.py b/tests/test_hermes_constants.py index 7c0073a68c..c9bd0760b1 100644 --- a/tests/test_hermes_constants.py +++ b/tests/test_hermes_constants.py @@ -149,15 +149,8 @@ class TestGetProcessHermesHome: -@pytest.mark.skipif(os.name == "nt", reason="POSIX shell stubs; Windows uses .cmd shims") class TestNodeToolRunnable: - """node_tool_runnable() rejects broken Hermes-managed npm/node wrappers.""" - - def _stub(self, tmp_path, name, body, mode=0o755): - path = tmp_path / name - path.write_text(body) - path.chmod(mode) - return path + """Empty executable paths cannot be probed.""" def test_none_and_empty_rejected(self): assert node_tool_runnable(None) is False @@ -510,6 +503,9 @@ class TestAgentBrowserRunnable: assert agent_browser_runnable(None) is False assert agent_browser_runnable("") is False + def test_install_command_is_not_a_runnable_browser(self): + assert agent_browser_runnable("npx agent-browser") is False + def test_dangling_symlink_rejected(self, tmp_path): link = tmp_path / "agent-browser" link.symlink_to(tmp_path / "does-not-exist") diff --git a/tests/test_install_ps1_desktop_stage.py b/tests/test_install_ps1_desktop_stage.py index 7a7ad55faa..d6ec277731 100644 --- a/tests/test_install_ps1_desktop_stage.py +++ b/tests/test_install_ps1_desktop_stage.py @@ -15,7 +15,7 @@ Boundary: the test generates a PowerShell wrapper that defines stub functions (New-Object intercepting WScript.Shell, icacls, ie4uinit.exe) and then DOT-SOURCES the real install.ps1 with -Stage desktop — the full stage runs in one real PowerShell process against a temp home/install -dir, with every external effect either fake (compiled fake venv python) +dir, with every external effect either fake (compiled external bootstrap python) or logged instead of written. Nothing touches the user's known folders. If the artifact check fails, the assertion message carries the fake python's actual logged arguments for debugging. @@ -31,6 +31,8 @@ from pathlib import Path import pytest +from tests.installation_launcher_fixture import publish_fixture_launcher + pytestmark = pytest.mark.platforms("windows") REPO_ROOT = Path(__file__).resolve().parents[1] @@ -126,6 +128,7 @@ function ie4uinit.exe { # Load the definitions, then execute the real stage dispatcher. . $InstallerPath -InstallDir $InstallDir -HermesHome $HermesHome +function Get-BootstrapPython { return $env:FAKE_BOOT_PY } Invoke-StageByName 'desktop' exit $LASTEXITCODE ''' @@ -214,7 +217,7 @@ def test_complete_stage_writes_pinned_install_marker(tmp_path: Path) -> None: def test_desktop_stage_uses_pm_sync_and_product_cli(tmp_path: Path) -> None: """-Stage desktop (without -IncludeDesktop — the standalone contract) runs the CURRENT paths: pm's venv sync for wake/voice, then - `hermes desktop --build-only` through the venv python; the produced + `hermes desktop --build-only` through the published installation launcher; the produced artifact is probed, ACL-granted, and shortcut-ed — with icacls, ie4uinit.exe, and WScript.Shell intercepted in the wrapper boundary so nothing outside the temp dirs is touched.""" @@ -223,7 +226,7 @@ def test_desktop_stage_uses_pm_sync_and_product_cli(tmp_path: Path) -> None: pytest.skip("Windows PowerShell is required") install_dir = tmp_path / "install" - scripts = install_dir / "venv" / "Scripts" + scripts = tmp_path / "store" / "python" scripts.mkdir(parents=True) py_log = tmp_path / "fake-python.log" wsh_log = tmp_path / "wsh.log" @@ -231,7 +234,10 @@ def test_desktop_stage_uses_pm_sync_and_product_cli(tmp_path: Path) -> None: fake_python = scripts / "python.exe" _compile_fake_python(powershell, fake_python) - (install_dir / "hermes").write_text("# python entry shim\n", encoding="ascii") + publish_fixture_launcher(install_dir, "import os, subprocess, sys\ndef main():\n return subprocess.call([os.environ['FAKE_BOOT_PY'], *sys.argv[1:]])\nif __name__ == '__main__': sys.exit(main())\n") + runtime_dir = install_dir / "scripts" / "desktop-update" + runtime_dir.mkdir(parents=True) + shutil.copyfile(REPO_ROOT / "scripts/desktop-update/runtime.ps1", runtime_dir / "runtime.ps1") wrapper = tmp_path / "boundary-wrapper.ps1" wrapper.write_text(_WRAPPER, encoding="utf-8-sig") @@ -239,6 +245,8 @@ def test_desktop_stage_uses_pm_sync_and_product_cli(tmp_path: Path) -> None: **os.environ, "PATHEXT": ";".join(dict.fromkeys([*os.environ.get("PATHEXT", "").split(";"), ".EXE"])), "FAKE_PY_LOG": str(py_log), + "FAKE_BOOT_PY": str(fake_python), + "HERMES_RUNTIME_DIR": str(tmp_path / "empty-store"), "FAKE_INSTALL_DIR": str(install_dir), "WSH_LOG": str(wsh_log), "ICACLS_LOG": str(icacls_log), @@ -259,8 +267,8 @@ def test_desktop_stage_uses_pm_sync_and_product_cli(tmp_path: Path) -> None: assert isinstance(calls, list) # 1. wake/voice extras via pm's venv sync (the pm-owned path). assert any( - len(c) >= 2 and c[0] == "-c" and "sync_venv" in c[1] - and "'wake'" in c[1] and "'voice'" in c[1] and "explicit=True" in c[1] + len(c) >= 3 and c[:2] == ["-I", "-c"] and "from pm import sync_venv" in c[2] + and "'wake'" in c[2] and "'voice'" in c[2] and "explicit=True" in c[2] for c in calls ), calls # 2. the build through the parsed product CLI (never a `build` subcommand diff --git a/tests/test_install_sh_launch_handoff.py b/tests/test_install_sh_launch_handoff.py new file mode 100644 index 0000000000..dad8c53f65 --- /dev/null +++ b/tests/test_install_sh_launch_handoff.py @@ -0,0 +1,24 @@ +"""Post-PM installer stages use the real installation-bound publication.""" +import json +import os +from pathlib import Path +import subprocess + +import pytest +from tests.installation_launcher_fixture import publish_fixture_launcher + +ROOT = Path(__file__).resolve().parents[1] + +@pytest.mark.platforms('posix') +@pytest.mark.parametrize('stage, expected', [('setup', ['setup']), ('gateway', ['gateway', 'install']), ('desktop', ['desktop', '--build-only'])]) +def test_installer_post_pm_stages(tmp_path: Path, stage: str, expected: list[str]) -> None: + install = tmp_path / 'source tree' + calls = tmp_path / 'calls.json' + publish_fixture_launcher(install, "import json, os, sys\nfrom pathlib import Path\ndef main():\n Path(os.environ['CALLS']).write_text(json.dumps(sys.argv[1:])); return int(os.environ['STAGE_EXIT'])\n") + command = ['bash', '-c', 'source "$1" --manifest >/dev/null; INSTALL_DIR="$2"; NON_INTERACTIVE=false; "stage_$3"', 'test', str(ROOT / 'scripts/install.sh'), str(install), stage] + env = {**os.environ, 'HOME': str(tmp_path), 'HERMES_HOME': str(tmp_path / 'home'), 'HERMES_RUNTIME_DIR': str(tmp_path / 'store'), 'CALLS': str(calls)} + for code in [0, 9]: + result = subprocess.run(command, cwd=tmp_path, env={**env, 'STAGE_EXIT': str(code)}, capture_output=True, text=True, timeout=20) + assert (result.returncode == 0) == (code == 0), result.stdout + result.stderr + assert json.loads(calls.read_text()) == expected + assert not (install / 'venv').exists() \ No newline at end of file diff --git a/tests/test_node_resolution.py b/tests/test_node_resolution.py new file mode 100644 index 0000000000..512d123766 --- /dev/null +++ b/tests/test_node_resolution.py @@ -0,0 +1,387 @@ +"""Node discovery reads PM state; legacy home-local trees are never activated.""" + +import hashlib +import json +from pathlib import Path +import shutil +import subprocess + +import pytest + +import hermes_constants +import pm +from pm import paths +from pm.lock import Facts, Lockfile +from pm.package import Runner +from pm.registry import get_package +from pm.store import current_target, tree_digest + + +@pytest.fixture +def node_store(tmp_path, monkeypatch): + node = shutil.which("node") + if node is None: + pytest.skip("requires an already-installed Node executable") + assert node is not None + monkeypatch.setenv("PATH", str(Path(node).parent)) + home = tmp_path / "home" + home.mkdir() + store = home / "tools" + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setenv("HERMES_RUNTIME_DIR", str(store)) + monkeypatch.setenv("HERMES_DISABLE_LAZY_INSTALLS", "1") + lock_path = tmp_path / "lock.json" + monkeypatch.setattr(paths, "lockfile_path", lambda: lock_path) + lock = Lockfile(lock_path) + target = current_target() + package = get_package("node") + version = subprocess.run( + [node, "--version"], capture_output=True, text=True, check=True, timeout=10, + ).stdout.strip().removeprefix("v") + entry = store / package.store_entry(version, target) + binary = package.binary(entry, target) + assert binary is not None + binary.parent.mkdir(parents=True) + binary.symlink_to(node) + digest = hashlib.sha256(Path(node).read_bytes()).hexdigest() + lock.set_pin("node", version, {target: {"url": Path(node).as_uri(), "sha256": digest}}) + lock.save() + Facts(paths.facts_path()).record( + "node", version, entry.name, package.env(entry, target), store, + target=target, artifacts=[digest], digest=tree_digest(entry), + ) + return home, binary, node + + +@pytest.mark.platforms("posix") +def test_pm_node_wins_over_legacy_tree_and_composes_child_environment(node_store, monkeypatch): + home, binary, external = node_store + legacy = home / "node" / "bin" / "node" + legacy.parent.mkdir(parents=True) + legacy.symlink_to(external) + monkeypatch.setenv("PATH", str(Path(external).parent)) + before = paths.facts_path().read_bytes() + + selected = pm.installed_package("node") + assert selected is not None and selected.binary == binary + resolved = hermes_constants.find_node_executable("node") + assert resolved is not None and resolved == str(binary) + base = {"PATH": str(Path(external).parent), "CALLER_VALUE": "preserved"} + environment = hermes_constants.with_hermes_node_path(base) + assert environment == pm.env_for("npm", base_env=base) + assert shutil.which("node", path=environment["PATH"]) == str(binary) + child = subprocess.run( + [resolved, "-p", "process.version"], + env=environment, capture_output=True, text=True, check=True, timeout=10, + ) + assert child.stdout.strip().removeprefix("v") == selected.version + assert paths.facts_path().read_bytes() == before + assert legacy.is_file() + assert base == {"PATH": str(Path(external).parent), "CALLER_VALUE": "preserved"} + + +@pytest.mark.platforms("posix") +def test_passive_discovery_never_installs_or_repairs_a_legacy_tree(tmp_path, monkeypatch): + home = tmp_path / "home" + legacy = home / "node" / "bin" / "node" + legacy.parent.mkdir(parents=True) + legacy.write_text("#!/bin/sh\nexit 1\n", encoding="utf-8") + legacy.chmod(0o755) + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setenv("HERMES_RUNTIME_DIR", str(home / "tools")) + monkeypatch.setenv("HERMES_DISABLE_LAZY_INSTALLS", "1") + monkeypatch.setenv("PATH", "") + before = {p.relative_to(home): p.read_bytes() for p in home.rglob("*") if p.is_file()} + attempts = [] + + def forbidden_install(*args, **kwargs): + attempts.append((args, kwargs)) + raise AssertionError("passive lookup must not provision anything") + + monkeypatch.setattr(pm, "ensure", forbidden_install) + assert hermes_constants.find_node_executable("node") is None + assert hermes_constants.find_node_executable("npm") is None + assert hermes_constants.with_hermes_node_path({"PATH": ""}) == {"PATH": ""} + assert attempts == [] + assert {p.relative_to(home): p.read_bytes() for p in home.rglob("*") if p.is_file()} == before + assert not (home / "tools").exists() + + # A user-owned PATH toolchain remains usable without acquiring a PM one. + external = tmp_path / "external" + external.mkdir() + npm = external / "npm" + npm.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + npm.chmod(0o755) + monkeypatch.setenv("PATH", str(external)) + assert hermes_constants.find_node_executable("npm") == str(npm) + assert attempts == [] + assert not (home / "tools").exists() + + +@pytest.mark.platforms("posix") +def test_explicit_node_path_is_not_replaced_by_managed_name(node_store): + _home, _binary, external = node_store + assert hermes_constants.find_node_executable(external) == external + assert hermes_constants.find_node_executable(str(Path(external).with_name("missing-node"))) is None + assert hermes_constants.find_node_executable("not-a-node-command") is None + + +@pytest.mark.platforms("posix") +def test_npm_and_npx_use_the_paired_pm_entry(node_store, monkeypatch): + home, node, external = node_store + npm = Path(external).with_name("npm") + npx = Path(external).with_name("npx") + if not npm.is_file() or not npx.is_file(): + pytest.skip("requires already-installed npm and npx") + version = subprocess.run( + [str(npm), "--version"], capture_output=True, text=True, check=True, timeout=10, + ).stdout.strip() + package = get_package("npm") + target = current_target() + store = paths.store_root() + entry = store / package.store_entry(version, target) + binary = package.binary(entry, target) + assert binary is not None + binary.parent.mkdir(parents=True) + binary.symlink_to(npm) + companion = binary.with_name("npx") + companion.symlink_to(npx) + lock = Lockfile(paths.lockfile_path()) + digest = hashlib.sha256(npm.read_bytes()).hexdigest() + lock.set_pin("npm", version, {target: {"url": npm.as_uri(), "sha256": digest}}) + lock.save() + Facts(paths.facts_path()).record( + "npm", version, entry.name, package.env(entry, target), store, + target=target, artifacts=[digest], digest=tree_digest(entry), + ) + legacy = home / "node" / "bin" / "npm" + legacy.parent.mkdir(parents=True) + legacy.symlink_to(npm) + before = paths.facts_path().read_bytes() + monkeypatch.setenv("PATH", "") + + assert hermes_constants.find_node_executable("npm") == str(binary) + assert hermes_constants.find_node_executable("npx") == str(companion) + environment = hermes_constants.with_hermes_node_path({"PATH": ""}) + assert shutil.which("npm", path=environment["PATH"]) == str(binary) + assert shutil.which("node", path=environment["PATH"]) == str(node) + for command in ("npm", "npx"): + resolved = hermes_constants.find_node_executable(command) + assert resolved is not None + result = subprocess.run( + [resolved, "--version"], env=environment, + capture_output=True, text=True, check=True, timeout=10, + ) + assert result.stdout.strip() == version + assert paths.facts_path().read_bytes() == before + companion.unlink() + assert hermes_constants.find_node_executable("npx") is None + + +@pytest.mark.platforms("windows") +def test_windows_path_prefers_launchable_npm_cmd(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "absent-store")) + monkeypatch.setenv("PATH", str(tmp_path)) + for name in ("npm", "npm.ps1", "npm.cmd"): + (tmp_path / name).write_text("@exit /b 0\n", encoding="utf-8") + assert hermes_constants.find_node_executable("npm") == str(tmp_path / "npm.cmd") + + +@pytest.fixture +def npm_probe(node_store, monkeypatch): + home, node, _external = node_store + store = paths.store_root() + package = get_package("npm") + target = current_target() + entry = store / package.store_entry("1.0.0", target) + binary = package.binary(entry, target) + assert binary is not None + binary.parent.mkdir(parents=True) + binary.write_text( + '#!/usr/bin/env node\n' + 'const fs = require("fs"); const path = require("path");\n' + 'const i = process.argv.indexOf("--prefix");\n' + 'const root = i < 0 ? process.cwd() : process.argv[i + 1];\n' + 'fs.mkdirSync(path.join(root, "node_modules/.bin"), {recursive:true});\n' + 'fs.writeFileSync(path.join(root, "node_modules/.bin/test-server"), "ready");\n' + 'fs.writeFileSync(path.join(root, "called.json"), JSON.stringify({argv: process.argv, env: process.env}));\n', + encoding="utf-8", + ) + binary.chmod(0o755) + lock = Lockfile(paths.lockfile_path()) + lock.set_pin("npm", "1.0.0", {}) + lock.save() + + def publish(): + Facts(paths.facts_path()).record( + "npm", "1.0.0", entry.name, package.env(entry, target), store, + ) + return Runner("npm", pm.env_for("npm")) + + monkeypatch.setenv("PATH", "") + return home, node, binary, publish + + +@pytest.fixture +def npm_consumers(npm_probe, tmp_path, monkeypatch): + from agent.lsp.install import _install_npm + from gateway.config import PlatformConfig + from hermes_cli.main_platform_setup import _whatsapp_install_bridge + from hermes_cli.web_routers.messaging import _ensure_whatsapp_bridge_dependencies + from plugins.platforms.photon import adapter as photon, cli + from plugins.platforms.whatsapp.adapter import WhatsAppAdapter + + home, _node, _binary, _publish = npm_probe + bridge = tmp_path / "bridge" + bridge.mkdir() + (bridge / "package.json").write_text('{"name":"test-bridge"}', encoding="utf-8") + adapter = WhatsAppAdapter(PlatformConfig(extra={"bridge_script": str(bridge / "bridge.js")})) + monkeypatch.setattr(photon, "_sidecar_dir", lambda: bridge) + monkeypatch.setattr(cli, "_sidecar_dir", lambda: bridge) + return { + "lsp": (lambda: _install_npm("test-pkg", "test-server"), home / "lsp"), + "whatsapp": (lambda: adapter._ensure_bridge_deps(bridge), bridge), + "photon": (photon._reinstall_sidecar_deps, bridge), + "photon-cli": (cli._install_sidecar, bridge), + "cli": (lambda: _whatsapp_install_bridge(bridge), bridge), + "dashboard": (lambda: _ensure_whatsapp_bridge_dependencies(bridge), bridge), + } + + +@pytest.mark.platforms("posix") +@pytest.mark.parametrize("consumer", ["lsp", "whatsapp", "photon", "photon-cli", "cli", "dashboard"]) +def test_npm_consumers_execute_with_pm_node(npm_probe, npm_consumers, consumer): + _home, node, binary, publish = npm_probe + publish() + call, output_dir = npm_consumers[consumer] + call() + 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) + + +@pytest.mark.platforms("posix") +@pytest.mark.parametrize("surface", ["cli", "dashboard", "whatsapp", "photon", "photon-cli"]) +def test_missing_npm_acquires_npm_closure_at_install_boundary(npm_probe, npm_consumers, monkeypatch, surface): + _home, node, binary, publish = npm_probe + calls = [] + + def ensure(name, **kwargs): + calls.append((name, kwargs.get("explicit", False))) + assert name == "npm" + return publish() + + monkeypatch.setattr(pm, "ensure", ensure) + call, bridge = npm_consumers[surface] + call() + assert calls == [("npm", surface in {"cli", "dashboard", "photon-cli"})] + result = json.loads((bridge / "called.json").read_text()) + assert Path(result["argv"][1]) == binary + assert shutil.which("node", path=result["env"]["PATH"]) == str(node) + + +@pytest.mark.platforms("posix") +@pytest.mark.parametrize("surface", ["whatsapp", "photon"]) +def test_runtime_npm_refusal_never_falls_back_to_literal_npm(npm_probe, npm_consumers, monkeypatch, surface): + call, bridge = npm_consumers[surface] + spawns = [] + run = subprocess.run + + def forbidden_spawn(*args, **kwargs): + if args[0][0] == "git": # Fatal-status reporting may read the code revision. + return run(*args, **kwargs) + spawns.append(args) + raise AssertionError("refused PM preparation must not spawn npm") + + monkeypatch.setattr(subprocess, "run", forbidden_spawn) + call() + assert spawns == [] + assert not (bridge / "called.json").exists() + assert pm.installed_package("npm") is None + + +@pytest.mark.parametrize("allowed", [False, True]) +def test_adapter_availability_never_provisions_missing_node(tmp_path, monkeypatch, allowed): + from plugins.platforms.photon import adapter as photon + from plugins.platforms.whatsapp import adapter as whatsapp + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "missing-tools")) + monkeypatch.setenv("PATH", "") + monkeypatch.delenv("PHOTON_NODE_BIN", raising=False) + monkeypatch.setattr(photon, "_sidecar_dir", lambda: tmp_path) + monkeypatch.setattr(photon, "HTTPX_AVAILABLE", True) + monkeypatch.setattr(pm, "lazy_installs_allowed", lambda: allowed) + installs = [] + + def forbidden_ensure(*args, **kwargs): + installs.append(args) + raise AssertionError("availability must not install") + + monkeypatch.setattr(pm, "ensure", forbidden_ensure) + assert whatsapp.check_whatsapp_requirements() is allowed + assert photon.check_requirements() is allowed + assert installs == [] + assert not (tmp_path / "missing-tools").exists() + + +@pytest.mark.platforms("posix") +def test_dashboard_pairing_prepares_npm_before_node_lookup(npm_probe, tmp_path, monkeypatch): + from gateway.platforms import whatsapp_common + from hermes_cli.web_routers.messaging import _spawn_whatsapp_pairing_process + + _home, node, _npm, publish = npm_probe + node_facts = paths.facts_path().read_bytes() + paths.facts_path().unlink() + bridge = tmp_path / "bridge" + bridge.mkdir() + (bridge / "bridge.js").write_text('console.log(JSON.stringify({argv:process.argv, path:process.env.PATH}));\n') + monkeypatch.setattr(whatsapp_common, "resolve_whatsapp_bridge_dir", lambda: bridge) + installs = [] + + def ensure(name, **kwargs): + installs.append((name, kwargs.get("explicit", False))) + paths.facts_path().write_bytes(node_facts) + return publish() + + monkeypatch.setattr(pm, "ensure", ensure) + child = _spawn_whatsapp_pairing_process(tmp_path / "session", "bot") + try: + output, _ = child.communicate(timeout=10) + assert child.returncode == 0 + finally: + if child.poll() is None: + child.kill() + child.wait(timeout=10) + assert installs == [("npm", True)] + result = json.loads(output) + assert "--pair-json" in result["argv"] + assert shutil.which("node", path=result["path"]) == str(node) + + +@pytest.mark.platforms("posix") +def test_lsp_node_server_inherits_pm_runtime_and_preserves_overrides(node_store, tmp_path, monkeypatch): + from agent.lsp.servers import ServerContext, find_server_for_file + + _home, node, _external = node_store + script = tmp_path / "language-server" + script.write_text('#!/usr/bin/env node\nconsole.log(JSON.stringify({path:process.env.PATH, flag:process.env.LSP_FLAG}));\n') + script.chmod(0o755) + monkeypatch.setenv("PATH", "") + ctx = ServerContext( + workspace_root=str(tmp_path), install_strategy="off", + binary_overrides={"typescript": [str(script)]}, + env_overrides={"typescript": {"LSP_FLAG": "project-value"}}, + ) + server = find_server_for_file(str(tmp_path / "test.ts")) + assert server is not None + spec = server.build_spawn(str(tmp_path), ctx) + assert spec is not None + child = subprocess.run(spec.command, env=spec.env, capture_output=True, text=True, timeout=10) + assert child.returncode == 0, child.stderr + observed = json.loads(child.stdout) + assert shutil.which("node", path=observed["path"]) == str(node) + assert observed["flag"] == "project-value" + ctx.env_overrides["typescript"]["PATH"] = "/explicit/project/tools" + spec = server.build_spawn(str(tmp_path), ctx) + assert spec is not None and spec.env["PATH"] == "/explicit/project/tools" diff --git a/tests/test_old_updater_additional_shims.py b/tests/test_old_updater_additional_shims.py index 4de25ee590..112566bec0 100644 --- a/tests/test_old_updater_additional_shims.py +++ b/tests/test_old_updater_additional_shims.py @@ -33,12 +33,14 @@ def no_external_work(monkeypatch): assert {p: p.read_bytes() for p in home.rglob("*") if p.is_file()} == before_files -@pytest.mark.parametrize("command", ["", "hermes -p ops gateway run", "hermes --profile=ops gateway run"]) -def test_retired_profile_probe_returns_unknown(command, no_external_work): +@pytest.mark.parametrize("command,profile", [ + ("", None), ("hermes -p ops gateway run", "ops"), ("hermes --profile=ops gateway run", "ops"), +]) +def test_live_profile_parser_does_no_external_work(command, profile, no_external_work): from gateway.status import profile_flag_value - # The old scanner compares this value to a profile before selecting a PID. - assert profile_flag_value(command) is None + # This classifier still protects current gateway identity checks. + assert profile_flag_value(command) == profile @pytest.mark.parametrize("refresh", [False, True]) @@ -68,7 +70,7 @@ def test_retired_constants_reload_stops_old_gateway_recovery(no_external_work, m 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 "relaunch" in capsys.readouterr().err.lower() + assert "run `hermes` again" in capsys.readouterr().err.lower() assert vars(hermes_constants) == before @@ -80,7 +82,7 @@ def test_retired_pip_install_stops_before_reporting_success(kwargs, no_external_ result = _pip_install(["--quiet", "honcho-ai"], **kwargs) pytest.fail(f"retired installer returned a result: {result}") assert exc.value.code == 0 - assert "relaunch" in capsys.readouterr().err.lower() + assert "run `hermes` again" in capsys.readouterr().err.lower() def test_retired_root_stops_before_inventing_portable_git_path(no_external_work, capsys): @@ -89,7 +91,7 @@ def test_retired_root_stops_before_inventing_portable_git_path(no_external_work, with pytest.raises(SystemExit) as exc: get_default_hermes_root() / "git" / "mingw64" / "libexec" / "git-core" / "git.exe" assert exc.value.code == 0 - assert "relaunch" in capsys.readouterr().err.lower() + assert "run `hermes` again" in capsys.readouterr().err.lower() @pytest.mark.parametrize("prompt", [True, False]) @@ -125,7 +127,7 @@ def test_retired_install_specs_stops_before_reporting_success(specs, no_external result = install_specs(specs, timeout=120) pytest.fail(f"retired installer returned a result: {result}") assert exc.value.code == 0 - assert "relaunch" in capsys.readouterr().err.lower() + assert "run `hermes` again" in capsys.readouterr().err.lower() def test_retired_subprocess_run_stops_powershell_installer(no_external_work, capsys): @@ -137,4 +139,4 @@ def test_retired_subprocess_run_stops_powershell_installer(no_external_work, cap env=dict(os.environ), check=True, capture_output=True, ) assert exc.value.code == 0 - assert "relaunch" in capsys.readouterr().err.lower() + assert "run `hermes` again" in capsys.readouterr().err.lower() diff --git a/tests/test_old_updater_shims.py b/tests/test_old_updater_shims.py index 4ddb9539b0..d2436dff1c 100644 --- a/tests/test_old_updater_shims.py +++ b/tests/test_old_updater_shims.py @@ -85,6 +85,8 @@ def test_ensure_uv_stops_both_historical_return_contracts(unpack, no_external_wo ("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", (), + {"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): diff --git a/tests/test_project_metadata.py b/tests/test_project_metadata.py index aca8d7339b..32387727fe 100644 --- a/tests/test_project_metadata.py +++ b/tests/test_project_metadata.py @@ -10,13 +10,6 @@ def _load_optional_dependencies(): return project["optional-dependencies"] -def _load_package_data(): - pyproject_path = Path(__file__).resolve().parents[1] / "pyproject.toml" - with pyproject_path.open("rb") as handle: - tool = tomllib.load(handle)["tool"] - return tool["setuptools"]["package-data"] - - def test_wake_dependencies_and_runtime_gate_agree_on_supported_targets(): from packaging.markers import Marker, default_environment from packaging.requirements import Requirement @@ -196,30 +189,3 @@ def test_dingtalk_extra_includes_qrcode_for_qr_auth(): dingtalk_extra = optional_dependencies["dingtalk"] assert any(dep.startswith("qrcode") for dep in dingtalk_extra) - - - - - - -def _uv_lock_version(package: str) -> str: - """Resolved version of ``package`` in uv.lock, or fail loudly.""" - versions = _uv_lock_versions(package) - assert versions, f"{package} not found in uv.lock" - assert len(versions) == 1, f"{package} resolves to multiple versions in uv.lock: {versions}" - return next(iter(versions)) - - -def _uv_lock_versions(package: str) -> set[str]: - """All resolved versions of ``package`` in uv.lock (normally 0 or 1).""" - import re - - lock_path = Path(__file__).resolve().parents[1] / "uv.lock" - lock = lock_path.read_text(encoding="utf-8-sig") - return { - m.group(1) - for m in re.finditer( - rf'\[\[package\]\]\nname = "{re.escape(package)}"\nversion = "([^"]+)"', - lock, - ) - } diff --git a/tests/test_windows_subprocess_no_window_flags.py b/tests/test_windows_subprocess_no_window_flags.py index 29a6b46490..4acfe3af72 100644 --- a/tests/test_windows_subprocess_no_window_flags.py +++ b/tests/test_windows_subprocess_no_window_flags.py @@ -189,39 +189,6 @@ def test_shell_hooks_hide_hook_command_windows(monkeypatch): assert "process_group" not in captured[0][1] -def test_agent_browser_npx_warmup_hides_npx_window(monkeypatch): - """warm_agent_browser_npx_cache spawns via subprocess.Popen (not .run, - since the T3 security-hardening rewrite added process-tree containment - via Popen + communicate()) — the console-hiding flag must still survive - that rewrite. On Windows the real implementation now ORs in - CREATE_NEW_PROCESS_GROUP alongside windows_hide_flags()'s bits (for - _kill_process_tree's taskkill /T to have a coherent tree to kill), so - this checks the CREATE_NO_WINDOW bit is present rather than exact - equality with the whole creationflags value.""" - from tools import browser_tool_install - - captured = [] - - class _FakePopen: - def __init__(self, cmd, **kwargs): - captured.append((cmd, kwargs)) - self.returncode = 0 - - def communicate(self, timeout=None): - return ("1.2.3\n", "") - - monkeypatch.setattr( - browser_tool_install.shutil, "which", - lambda name, path=None: "/usr/bin/npx", - ) - monkeypatch.setattr("tools.browser_tool_install.node_tool_runnable", lambda p: True) - monkeypatch.setattr("tools.browser_tool_install.windows_hide_flags", lambda: _CREATE_NO_WINDOW) - monkeypatch.setattr(browser_tool_install.subprocess, "Popen", _FakePopen) - - assert browser_tool_install.warm_agent_browser_npx_cache() is True - assert captured[0][0][0] == "/usr/bin/npx" - assert captured[0][1]["creationflags"] & _CREATE_NO_WINDOW == _CREATE_NO_WINDOW - diff --git a/tests/tools/test_browser_chromium_autoinstall.py b/tests/tools/test_browser_chromium_autoinstall.py index 61ce6a3658..063d470617 100644 --- a/tests/tools/test_browser_chromium_autoinstall.py +++ b/tests/tools/test_browser_chromium_autoinstall.py @@ -9,10 +9,8 @@ from tools import browser_tool_install as bt_install @pytest.fixture(autouse=True) def reset_state(): bt._chromium_autoinstall_attempted = False - bt._cached_chromium_installed = None yield bt._chromium_autoinstall_attempted = False - bt._cached_chromium_installed = None def test_install_uses_pm_once_and_preserves_failure(monkeypatch): diff --git a/tests/tools/test_browser_chromium_check.py b/tests/tools/test_browser_chromium_check.py index e582ca591d..b235bb965d 100644 --- a/tests/tools/test_browser_chromium_check.py +++ b/tests/tools/test_browser_chromium_check.py @@ -17,31 +17,15 @@ from tools import browser_tool_cloud as bt_cloud @pytest.fixture(autouse=True) -def _reset_chromium_cache(monkeypatch, tmp_path): +def _isolated_browser_store(monkeypatch, tmp_path): monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "tools")) - bt._cached_chromium_installed = None - yield - bt._cached_chromium_installed = None -class TestChromiumSearchRoots: - def test_respects_playwright_browsers_path_env(self, monkeypatch, tmp_path): - monkeypatch.setenv("PLAYWRIGHT_BROWSERS_PATH", str(tmp_path)) - roots = bt_install._chromium_search_roots() - assert str(tmp_path) == roots[0] - - - def test_always_includes_default_ms_playwright_cache(self, monkeypatch): - monkeypatch.delenv("PLAYWRIGHT_BROWSERS_PATH", raising=False) - roots = bt_install._chromium_search_roots() - home = os.path.expanduser("~") - assert any(r == os.path.join(home, ".cache", "ms-playwright") for r in roots) - class TestChromiumInstalled: def test_shell_only_cache_does_not_satisfy_full_browser(self, monkeypatch, tmp_path): monkeypatch.delenv("AGENT_BROWSER_EXECUTABLE_PATH", raising=False) - monkeypatch.setattr(bt_install, "_chromium_search_roots", lambda: [str(tmp_path)]) + monkeypatch.setenv("PLAYWRIGHT_BROWSERS_PATH", str(tmp_path)) (tmp_path / "chromium_headless_shell-1234").mkdir() assert bt_install._chromium_installed() is False @@ -49,7 +33,7 @@ class TestChromiumInstalled: """Pinned-store-only (gap plan D3): a system Chromium in PATH does NOT satisfy the check — only AGENT_BROWSER_EXECUTABLE_PATH or the pinned browser store do.""" - monkeypatch.setattr(bt_install, "_chromium_search_roots", lambda: [str(tmp_path)]) + monkeypatch.setenv("PLAYWRIGHT_BROWSERS_PATH", str(tmp_path)) monkeypatch.delenv("AGENT_BROWSER_EXECUTABLE_PATH", raising=False) monkeypatch.setattr( shutil, @@ -67,13 +51,13 @@ class TestChromiumInstalled: assert bt_install._chromium_installed() is True - def test_result_cached(self, monkeypatch, tmp_path): - monkeypatch.setenv("PLAYWRIGHT_BROWSERS_PATH", str(tmp_path)) - (tmp_path / "chromium-1208").mkdir() - assert bt_install._chromium_installed() is True - # Delete after first call — cached True should still return True. - (tmp_path / "chromium-1208").rmdir() + def test_removed_override_is_no_longer_ready(self, monkeypatch, tmp_path): + browser = tmp_path / "browser" + browser.touch() + monkeypatch.setenv("AGENT_BROWSER_EXECUTABLE_PATH", str(browser)) assert bt_install._chromium_installed() is True + browser.unlink() + assert bt_install._chromium_installed() is False class TestCheckBrowserRequirementsChromium: @@ -82,8 +66,9 @@ class TestCheckBrowserRequirementsChromium: monkeypatch.setattr(bt, "_is_camofox_mode", lambda: False) monkeypatch.setattr(bt_install, "_find_agent_browser", lambda **_kw: "/usr/local/bin/agent-browser") monkeypatch.setattr(bt_cloud, "_get_cloud_provider", lambda: None) - monkeypatch.setenv("PLAYWRIGHT_BROWSERS_PATH", str(tmp_path)) - (tmp_path / "chromium-1208").mkdir() + browser = tmp_path / "browser" + browser.touch() + monkeypatch.setenv("AGENT_BROWSER_EXECUTABLE_PATH", str(browser)) assert bt_install.check_browser_requirements() is True diff --git a/tests/tools/test_browser_hardening.py b/tests/tools/test_browser_hardening.py index bc2c4f8cd7..a5cd444601 100644 --- a/tests/tools/test_browser_hardening.py +++ b/tests/tools/test_browser_hardening.py @@ -17,8 +17,6 @@ from tools import browser_tool_lifecycle as bt_lifecycle def _reset_caches(): """Reset all module-level caches so tests start clean.""" import tools.browser_tool as bt - bt._cached_agent_browser = None - bt._agent_browser_resolved = False bt._cached_command_timeout = None bt._command_timeout_resolved = False # lru_cache for _discover_homebrew_node_dirs @@ -50,42 +48,6 @@ class TestDeadCodeRemoval: assert "browser_close" not in names -# --------------------------------------------------------------------------- -# Caching: _find_agent_browser -# --------------------------------------------------------------------------- - -class TestFindAgentBrowserCache: - - def test_cached_after_first_call(self): - import tools.browser_tool as bt - with patch("shutil.which", return_value="/usr/bin/agent-browser"), \ - patch("tools.browser_tool_install.agent_browser_runnable", return_value=True): - result1 = bt_install._find_agent_browser() - result2 = bt_install._find_agent_browser() - assert result1 == result2 == "/usr/bin/agent-browser" - assert bt._agent_browser_resolved is True - - - def test_not_found_cached_raises_on_subsequent(self): - """After FileNotFoundError, subsequent calls should raise from cache.""" - from pathlib import Path - - original_exists = Path.exists - - def mock_exists(self): - if "node_modules" in str(self) and "agent-browser" in str(self): - return False - return original_exists(self) - - with patch("shutil.which", return_value=None), \ - patch("os.path.isdir", return_value=False), \ - patch.object(Path, "exists", mock_exists): - with pytest.raises(FileNotFoundError): - bt_install._find_agent_browser() - # Second call should also raise (from cache) - with pytest.raises(FileNotFoundError, match="cached"): - bt_install._find_agent_browser() - # --------------------------------------------------------------------------- # Caching: _get_command_timeout diff --git a/tests/tools/test_browser_homebrew_paths.py b/tests/tools/test_browser_homebrew_paths.py deleted file mode 100644 index 3ec129aa7b..0000000000 --- a/tests/tools/test_browser_homebrew_paths.py +++ /dev/null @@ -1,496 +0,0 @@ -"""Tests for macOS Homebrew PATH discovery in browser_tool.py.""" - -import json -import os -import shutil -import sys -from pathlib import Path -from unittest.mock import patch, MagicMock, mock_open - -import pytest - -from tools.browser_tool_install import _find_agent_browser, check_browser_requirements -from tools.browser_tool_session import _run_browser_command -from tools.browser_tool import AGENT_BROWSER_NPX_SPEC, _SANE_PATH -from tools.browser_tool_install import _agent_browser_candidate_present, _discover_homebrew_node_dirs -from tools.browser_tool_lightpanda_fallback import _run_chrome_fallback_command -import tools.browser_tool as _bt -from tools import browser_tool_install as bt_install - - -@pytest.fixture(autouse=True) -def _clear_browser_caches(): - """Clear lru_cache and manual caches between tests.""" - _discover_homebrew_node_dirs.cache_clear() - _bt._cached_agent_browser = None - _bt._agent_browser_resolved = False - yield - _discover_homebrew_node_dirs.cache_clear() - _bt._cached_agent_browser = None - _bt._agent_browser_resolved = False - - -class TestSanePath: - """Verify _SANE_PATH includes fallback directories used by browser_tool.""" - - def test_includes_standard_dirs(self): - path_parts = _SANE_PATH.split(os.pathsep) - assert "/usr/local/bin" in path_parts - assert "/usr/bin" in path_parts - assert "/bin" in path_parts - - -class TestDiscoverHomebrewNodeDirs: - """Tests for _discover_homebrew_node_dirs().""" - - def test_returns_empty_when_no_homebrew(self): - """Non-macOS systems without /opt/homebrew/opt should return empty.""" - with patch("os.path.isdir", return_value=False): - assert _discover_homebrew_node_dirs() == () - - - def test_excludes_plain_node(self): - """'node' (unversioned) should be excluded — covered by /opt/homebrew/bin.""" - with patch("os.path.isdir", return_value=True), \ - patch("os.listdir", return_value=["node"]): - result = _discover_homebrew_node_dirs() - assert result == () - - def test_handles_oserror_gracefully(self): - """Should return empty list if listdir raises OSError.""" - with patch("os.path.isdir", return_value=True), \ - patch("os.listdir", side_effect=OSError("Permission denied")): - assert _discover_homebrew_node_dirs() == () - - -class TestFindAgentBrowser: - """Tests for _find_agent_browser() Homebrew path search.""" - - def test_finds_in_current_path(self): - """Should return result from shutil.which if available on current PATH.""" - with patch("shutil.which", return_value="/usr/local/bin/agent-browser"), \ - patch("tools.browser_tool_install.agent_browser_runnable", return_value=True): - assert _find_agent_browser() == "/usr/local/bin/agent-browser" - - - def test_raises_when_not_found(self): - """Should raise FileNotFoundError when nothing works.""" - original_path_exists = Path.exists - - def mock_path_exists(self): - if "node_modules" in str(self) and "agent-browser" in str(self): - return False - return original_path_exists(self) - - with patch("shutil.which", return_value=None), \ - patch("os.path.isdir", return_value=False), \ - patch.object(Path, "exists", mock_path_exists), \ - patch( - "tools.browser_tool_install._discover_homebrew_node_dirs", - return_value=[], - ): - with pytest.raises(FileNotFoundError, match="agent-browser CLI not found"): - _find_agent_browser() - - def test_finds_in_local_node_modules_bin(self): - """Should fall through to the repo's node_modules/.bin when both the - bare PATH and the extended (Homebrew/fallback) PATH miss.""" - repo_root = Path(_bt.__file__).parent.parent - local_bin_dir = repo_root / "node_modules" / ".bin" - local_bin_path = str(local_bin_dir / "agent-browser") - - def mock_which(cmd, path=None): - if cmd == "agent-browser" and path and str(local_bin_dir) in path: - return local_bin_path - return None - - original_is_dir = Path.is_dir - - def mock_is_dir(self): - if self == local_bin_dir: - return True - return original_is_dir(self) - - with patch("shutil.which", side_effect=mock_which), \ - patch("os.path.isdir", return_value=False), \ - patch.object(Path, "is_dir", mock_is_dir), \ - patch("tools.browser_tool_install.agent_browser_runnable", return_value=True), \ - patch( - "tools.browser_tool_install._discover_homebrew_node_dirs", - return_value=[], - ): - result = _find_agent_browser() - - assert result == local_bin_path - - def test_extended_path_hit_validate_false_skips_runnable_check(self, tmp_path): - """Readiness probes (validate=False, used by _has_agent_browser) must - resolve a candidate found via the extended PATH's path= kwarg lookup - without calling agent_browser_runnable — that keeps the probe a cheap - existence check with no subprocess spawn.""" - fake_binary = tmp_path / "agent-browser" - fake_binary.write_text("#!/bin/sh\n") - fake_binary.chmod(0o755) - - def mock_which(cmd, path=None): - if cmd == "agent-browser" and path: - return str(fake_binary) - return None # bare (path=None) PATH lookup misses - - with patch("shutil.which", side_effect=mock_which), \ - patch("os.path.isdir", return_value=True), \ - patch( - "tools.browser_tool_install.agent_browser_runnable", - side_effect=AssertionError( - "validate=False must not call agent_browser_runnable" - ), - ), \ - patch( - "tools.browser_tool_install._discover_homebrew_node_dirs", - return_value=["/opt/homebrew/bin"], - ): - result = _find_agent_browser(validate=False) - - assert result == str(fake_binary) - - def test_local_bin_hit_validate_false_skips_runnable_check(self, tmp_path): - """Same no-subprocess-spawn contract for the node_modules/.bin - candidate: validate=False relies on _agent_browser_candidate_present's - existence+exec-bit check instead of shelling out to --version.""" - repo_root = Path(_bt.__file__).parent.parent - local_bin_dir = repo_root / "node_modules" / ".bin" - - fake_binary = tmp_path / "agent-browser" - fake_binary.write_text("#!/bin/sh\n") - fake_binary.chmod(0o755) - - def mock_which(cmd, path=None): - if cmd == "agent-browser" and path and str(local_bin_dir) in path: - return str(fake_binary) - return None - - original_is_dir = Path.is_dir - - def mock_is_dir(self): - if self == local_bin_dir: - return True - return original_is_dir(self) - - with patch("shutil.which", side_effect=mock_which), \ - patch("os.path.isdir", return_value=False), \ - patch.object(Path, "is_dir", mock_is_dir), \ - patch( - "tools.browser_tool_install.agent_browser_runnable", - side_effect=AssertionError( - "validate=False must not call agent_browser_runnable" - ), - ), \ - patch( - "tools.browser_tool_install._discover_homebrew_node_dirs", - return_value=[], - ): - result = _find_agent_browser(validate=False) - - assert result == str(fake_binary) - - def test_npx_fallback_validate_false(self): - """The npx sentinel must resolve through the validate=False path too, - independent of the fully-mocked coverage in test_nous_subscription.py.""" - def mock_which(cmd, path=None): - if cmd == "agent-browser": - return None - if cmd == "npx": - return "/usr/bin/npx" - return None - - original_path_exists = Path.exists - - def mock_path_exists(self): - if "node_modules" in str(self) and "agent-browser" in str(self): - return False - return original_path_exists(self) - - with patch("shutil.which", side_effect=mock_which), \ - patch("os.path.isdir", return_value=False), \ - patch.object(Path, "exists", mock_path_exists), \ - patch("tools.browser_tool_install.node_tool_runnable", return_value=True), \ - patch( - "tools.browser_tool_install._discover_homebrew_node_dirs", - return_value=[], - ): - result = _find_agent_browser(validate=False) - - assert result == "npx agent-browser" - - -class TestAgentBrowserCandidatePresent: - """Direct unit tests for the validate=False candidate check used by every - branch of _find_agent_browser's readiness-probe (no-subprocess) mode.""" - - def test_none_is_false(self): - assert _agent_browser_candidate_present(None) is False - - def test_empty_string_is_false(self): - assert _agent_browser_candidate_present("") is False - - def test_npx_sentinel_is_true_without_touching_filesystem(self): - assert _agent_browser_candidate_present("npx agent-browser") is True - - def test_executable_file_is_true(self, tmp_path): - binary = tmp_path / "agent-browser" - binary.write_text("#!/bin/sh\n") - binary.chmod(0o755) - assert _agent_browser_candidate_present(str(binary)) is True - - @pytest.mark.skipif( - sys.platform == "win32", - reason="exec-bit is not meaningful on Windows; os.name == 'nt' short-circuits", - ) - def test_nonexecutable_file_is_false(self, tmp_path): - binary = tmp_path / "agent-browser" - binary.write_text("#!/bin/sh\n") - binary.chmod(0o644) - assert _agent_browser_candidate_present(str(binary)) is False - - def test_nonexistent_path_is_false(self, tmp_path): - assert _agent_browser_candidate_present(str(tmp_path / "missing")) is False - - -class TestBrowserRequirements: - def test_cdp_override_does_not_require_agent_browser_cli(self, monkeypatch): - monkeypatch.setenv("BROWSER_CDP_URL", "ws://127.0.0.1:9222/devtools/browser/test") - monkeypatch.setattr("tools.browser_tool._is_camofox_mode", lambda: False) - monkeypatch.setattr("tools.browser_tool_install._find_agent_browser", lambda: (_ for _ in ()).throw(FileNotFoundError("not found"))) - - assert check_browser_requirements() is True - - -class TestRunBrowserCommandPathConstruction: - """Verify _run_browser_command() includes Homebrew node dirs in subprocess PATH.""" - - def test_subprocess_preserves_executable_path_with_spaces(self, tmp_path): - """A local agent-browser path containing spaces must stay one argv entry.""" - captured_cmd = None - - mock_proc = MagicMock() - mock_proc.returncode = 0 - mock_proc.wait.return_value = 0 - - def capture_popen(cmd, **kwargs): - nonlocal captured_cmd - captured_cmd = cmd - return mock_proc - - fake_session = { - "session_name": "test-session", - "session_id": "test-id", - "cdp_url": None, - } - fake_json = json.dumps({"success": True}) - browser_path = "/Users/test/Library/Application Support/hermes/node_modules/.bin/agent-browser" - hermes_home = str(tmp_path / "hermes-home") - - with patch("tools.browser_tool_install._find_agent_browser", return_value=browser_path), \ - patch("tools.browser_tool_install._chromium_installed", return_value=True), \ - patch("tools.browser_tool_session._get_session_info", return_value=fake_session), \ - patch("tools.browser_tool._socket_safe_tmpdir", return_value=str(tmp_path)), \ - patch("tools.browser_tool_install._discover_homebrew_node_dirs", return_value=[]), \ - patch("hermes_constants.Path.home", return_value=tmp_path), \ - patch("subprocess.Popen", side_effect=capture_popen), \ - patch("os.open", return_value=99), \ - patch("os.close"), \ - patch("tools.interrupt.is_interrupted", return_value=False), \ - patch.dict( - os.environ, - { - "PATH": "/usr/bin:/bin", - "HOME": "/home/test", - "HERMES_HOME": hermes_home, - }, - clear=True, - ): - with patch("builtins.open", mock_open(read_data=fake_json)): - _run_browser_command("test-task", "navigate", ["https://example.com"]) - - assert captured_cmd is not None - assert captured_cmd[0] == browser_path - assert captured_cmd[1:5] == [ - "--session", - "test-session", - "--json", - "navigate", - ] - - - def test_npx_sentinel_resolves_via_resolve_npx_bin_with_pinned_spec(self, tmp_path): - """When _find_agent_browser resolves the npx sentinel, the cmd prefix - must come from _resolve_npx_bin() (not a bare shutil.which("npx"), which - could let a broken system npx shadow a healthy Hermes-managed one) and - use the pinned agent-browser npx spec, not a bare "agent-browser".""" - captured_cmd = None - - mock_proc = MagicMock() - mock_proc.returncode = 0 - mock_proc.wait.return_value = 0 - - def capture_popen(cmd, **kwargs): - nonlocal captured_cmd - captured_cmd = cmd - return mock_proc - - fake_session = { - "session_name": "test-session", - "session_id": "test-id", - "cdp_url": None, - } - fake_json = json.dumps({"success": True}) - hermes_home = str(tmp_path / "hermes-home") - - with patch("tools.browser_tool_install._find_agent_browser", return_value="npx agent-browser"), \ - patch("tools.browser_tool_install._resolve_npx_bin", return_value="/opt/hermes/node/bin/npx"), \ - patch("tools.browser_tool_install._chromium_installed", return_value=True), \ - patch("tools.browser_tool_session._get_session_info", return_value=fake_session), \ - patch("tools.browser_tool._socket_safe_tmpdir", return_value=str(tmp_path)), \ - patch("tools.browser_tool_install._discover_homebrew_node_dirs", return_value=[]), \ - patch("hermes_constants.Path.home", return_value=tmp_path), \ - patch("subprocess.Popen", side_effect=capture_popen), \ - patch("os.open", return_value=99), \ - patch("os.close"), \ - patch("tools.interrupt.is_interrupted", return_value=False), \ - patch.dict( - os.environ, - { - "PATH": "/usr/bin:/bin", - "HOME": "/home/test", - "HERMES_HOME": hermes_home, - }, - clear=True, - ): - with patch("builtins.open", mock_open(read_data=fake_json)): - _run_browser_command("test-task", "navigate", ["https://example.com"]) - - assert captured_cmd is not None - assert captured_cmd[:5] == [ - "/opt/hermes/node/bin/npx", "--ignore-scripts", "--prefer-offline", "-y", - AGENT_BROWSER_NPX_SPEC, - ] - assert captured_cmd[5:9] == ["--session", "test-session", "--json", "navigate"] - - -class TestRunChromeFallbackCommandNpxResolution: - """_run_chrome_fallback_command builds its own npx cmd prefix independently - of _run_browser_command's — it must resolve npx the same way (via - _resolve_npx_bin(), not a bare shutil.which("npx")) and use the pinned - agent-browser npx spec.""" - - def test_npx_sentinel_resolves_via_resolve_npx_bin_with_pinned_spec(self, tmp_path): - captured_cmds = [] - - mock_proc = MagicMock() - mock_proc.returncode = 0 - mock_proc.wait.return_value = 0 - - def capture_popen(cmd, **kwargs): - captured_cmds.append(cmd) - return mock_proc - - url_result = {"success": True, "data": {"url": "https://example.com"}} - - with patch("tools.browser_tool_session._run_browser_command", return_value=url_result), \ - patch("tools.browser_tool_install._find_agent_browser", return_value="npx agent-browser"), \ - patch("tools.browser_tool_install._resolve_npx_bin", return_value="/opt/hermes/node/bin/npx"), \ - patch("tools.browser_tool_install._chromium_installed", return_value=True), \ - patch("tools.browser_tool_install._running_in_docker", return_value=False), \ - patch("tools.browser_tool._socket_safe_tmpdir", return_value=str(tmp_path)), \ - patch("subprocess.Popen", side_effect=capture_popen): - _run_chrome_fallback_command("test-task", "navigate", ["https://example.com"], timeout=10) - - assert captured_cmds, "expected at least one Popen call for the chrome-fallback session" - first_cmd = captured_cmds[0] - assert first_cmd[:5] == [ - "/opt/hermes/node/bin/npx", "--ignore-scripts", "--prefer-offline", "-y", - AGENT_BROWSER_NPX_SPEC, - ] - assert first_cmd[5] == "--engine" and first_cmd[6] == "chrome" - assert first_cmd[7] == "--session" and first_cmd[8].startswith("h_cfb_") - assert first_cmd[9] == "--json" - - -class TestResolveNpxBinPriority: - """The extended/managed search must be checked before a bare ambient - PATH lookup, so a broken/unexpected system npx can't shadow a healthy - Hermes-managed one — and each candidate must be validated (actually - runs) before being trusted, mirroring _find_agent_browser's own - validation discipline for agent-browser itself.""" - - def test_prefers_managed_extended_path_over_bare_path(self, monkeypatch): - - monkeypatch.setattr("tools.browser_tool_install._merge_browser_path", lambda _p: "/hermes/node/bin") - monkeypatch.setattr( - shutil, "which", - lambda cmd, path=None: ( - "/hermes/node/bin/npx" if path == "/hermes/node/bin" - else "/usr/local/bin/npx" - ), - ) - monkeypatch.setattr("tools.browser_tool_install.node_tool_runnable", lambda p: True) - - assert bt_install._resolve_npx_bin() == "/hermes/node/bin/npx" - - def test_falls_back_to_bare_path_when_managed_candidate_is_broken(self, monkeypatch): - - monkeypatch.setattr("tools.browser_tool_install._merge_browser_path", lambda _p: "/hermes/node/bin") - monkeypatch.setattr( - shutil, "which", - lambda cmd, path=None: ( - "/hermes/node/bin/npx" if path == "/hermes/node/bin" - else "/usr/local/bin/npx" - ), - ) - monkeypatch.setattr("tools.browser_tool_install.node_tool_runnable", lambda p: p == "/usr/local/bin/npx") - - assert bt_install._resolve_npx_bin() == "/usr/local/bin/npx" - - def test_returns_none_when_nothing_runnable(self, monkeypatch): - - monkeypatch.setattr("tools.browser_tool_install._merge_browser_path", lambda _p: "") - monkeypatch.setattr(shutil, "which", lambda cmd, path=None: "/usr/local/bin/npx") - monkeypatch.setattr("tools.browser_tool_install.node_tool_runnable", lambda p: False) - - assert bt_install._resolve_npx_bin() is None - - def test_skips_extended_lookup_when_merge_browser_path_returns_empty(self, monkeypatch): - """_merge_browser_path("") returning a falsy string (no extended - candidate dirs found on disk) must short-circuit straight to the - bare-PATH rung — shutil.which must not be called with a path="" - kwarg (which would silently mean "search cwd only" on some - platforms rather than "no extended search"), and node_tool_runnable - must only be asked about the one real candidate.""" - - which_calls = [] - - def fake_which(cmd, path=None): - which_calls.append((cmd, path)) - return "/usr/bin/npx" if path is None else None - - monkeypatch.setattr("tools.browser_tool_install._merge_browser_path", lambda _p: "") - monkeypatch.setattr(shutil, "which", fake_which) - monkeypatch.setattr("tools.browser_tool_install.node_tool_runnable", lambda p: p == "/usr/bin/npx") - - assert bt_install._resolve_npx_bin() == "/usr/bin/npx" - assert which_calls == [("npx", None)] - - def test_falls_back_to_bare_path_when_extended_dir_has_no_npx(self, monkeypatch): - """A non-empty extended search PATH that simply doesn't contain an - npx binary (shutil.which returns None there) must fall through to - the bare-PATH rung rather than treating "no extended npx" the same - as "extended npx found but broken".""" - - monkeypatch.setattr("tools.browser_tool_install._merge_browser_path", lambda _p: "/hermes/node/bin") - monkeypatch.setattr( - shutil, "which", - lambda cmd, path=None: None if path == "/hermes/node/bin" else "/usr/bin/npx", - ) - monkeypatch.setattr("tools.browser_tool_install.node_tool_runnable", lambda p: True) - - assert bt_install._resolve_npx_bin() == "/usr/bin/npx" diff --git a/tests/tools/test_browser_npx_warmup.py b/tests/tools/test_browser_npx_warmup.py deleted file mode 100644 index e0728c4f9d..0000000000 --- a/tests/tools/test_browser_npx_warmup.py +++ /dev/null @@ -1,321 +0,0 @@ -"""Tests for tools.browser_tool_install.warm_agent_browser_npx_cache (#43564, security -hardening follow-up on PR #44772 review). - -warm_agent_browser_npx_cache() is the fire-and-forget helper `hermes update` / -`hermes doctor --fix` call to pre-fetch agent-browser via npx so the first real -browser-tool invocation in a session doesn't pay npx's registry-lookup cost. -It must never raise, must accurately report success/failure via its return -value, must use a credential-scrubbed and PATH-propagated environment (it -runs registry-fetched, potentially install-scripted npm code on every -`hermes update` — not only when a browser tool is actually used), must pass ---ignore-scripts (AGENT_BROWSER_NPX_SPEC is a floating ^0.26.0 range, not an -exact pin), and must kill the whole process tree — not just the top-level -npx PID — on timeout. -""" - -from __future__ import annotations - -import subprocess -import pytest -from unittest.mock import MagicMock, patch - -from tools.browser_tool import AGENT_BROWSER_NPX_SPEC -from tools.browser_tool_install import warm_agent_browser_npx_cache -from tools.browser_tool_lifecycle import _legacy_kill_process_tree - - -def _mock_proc(returncode=0, communicate_side_effect=None, pid=4242): - proc = MagicMock() - proc.pid = pid - if communicate_side_effect is not None: - proc.communicate.side_effect = communicate_side_effect - else: - proc.communicate.return_value = ("", "") - proc.returncode = returncode - return proc - - -def test_returns_false_without_spawning_when_npx_unresolvable(): - with patch("tools.browser_tool_install._resolve_npx_bin", return_value=None), patch( - "subprocess.Popen" - ) as mock_popen: - assert warm_agent_browser_npx_cache() is False - mock_popen.assert_not_called() - - -def test_invokes_npx_with_ignore_scripts_prefer_offline_and_pinned_spec(): - with patch("tools.browser_tool_install._resolve_npx_bin", return_value="/usr/bin/npx"), patch( - "subprocess.Popen", return_value=_mock_proc() - ) as mock_popen: - assert warm_agent_browser_npx_cache() is True - - mock_popen.assert_called_once() - args, _kwargs = mock_popen.call_args - assert args[0] == [ - "/usr/bin/npx", "--ignore-scripts", "--prefer-offline", "-y", - AGENT_BROWSER_NPX_SPEC, "--version", - ] - - -def test_stdin_is_explicitly_devnull_not_inherited(): - """Every subprocess call in tools/ must set stdin= explicitly - (scripts/check_subprocess_stdin.py) — in the TUI gateway, an inherited - stdin fd can be consumed by a child and cause the gateway's own - JSON-RPC stdin read to see a premature EOF (issue #14036). This call - has no reason to read from stdin at all, so it must be DEVNULL, not - merely "present in kwargs somewhere" (the checker is a literal-argument - textual scan, so stdin= folded into a shared kwargs dict wouldn't - satisfy it either — it must appear as a literal keyword on the call).""" - with patch("tools.browser_tool_install._resolve_npx_bin", return_value="/usr/bin/npx"), \ - patch("subprocess.Popen", return_value=_mock_proc()) as mock_popen: - warm_agent_browser_npx_cache() - - _args, kwargs = mock_popen.call_args - assert kwargs.get("stdin") == subprocess.DEVNULL - - -def test_captures_stdout_and_stderr_instead_of_inheriting_parent_fds(): - """The npx registry fetch runs on every `hermes update` — its stdout/ - stderr must not bleed into the caller's own output (and, on POSIX, an - inherited fd is one more handle a runaway grandchild could hold open).""" - with patch("tools.browser_tool_install._resolve_npx_bin", return_value="/usr/bin/npx"), \ - patch("subprocess.Popen", return_value=_mock_proc()) as mock_popen: - warm_agent_browser_npx_cache() - - _args, kwargs = mock_popen.call_args - assert kwargs.get("stdout") == subprocess.PIPE - assert kwargs.get("stderr") == subprocess.PIPE - - -def test_uses_credential_scrubbed_environment(): - """Must not inherit the full parent environment — matching every other - agent-browser subprocess spawn (_build_browser_env), not the ambient - os.environ with every provider/gateway credential Hermes holds.""" - scrubbed_env = {"PATH": "/scrubbed/bin", "SCRUBBED": "1"} - with patch("tools.browser_tool_install._resolve_npx_bin", return_value="/usr/bin/npx"), \ - patch("tools.browser_tool._build_browser_env", return_value=dict(scrubbed_env)), \ - patch("tools.browser_tool_install._merge_browser_path", side_effect=lambda p: p), \ - patch("subprocess.Popen", return_value=_mock_proc()) as mock_popen: - warm_agent_browser_npx_cache() - - _args, kwargs = mock_popen.call_args - assert kwargs["env"]["SCRUBBED"] == "1" - assert "OPENAI_API_KEY" not in kwargs["env"] - - -def test_merges_extended_path_so_managed_only_npx_can_find_sibling_node(): - """If npx was resolved via the Hermes-managed/extended search (not the - ambient PATH), the child's own PATH must include that same directory — - npx's #!/usr/bin/env node shebang resolves `node` via the child's PATH - at exec time, not the resolving process's PATH.""" - with patch("tools.browser_tool_install._resolve_npx_bin", return_value="/opt/hermes/node/bin/npx"), \ - patch("tools.browser_tool._build_browser_env", return_value={"PATH": "/usr/bin"}), \ - patch( - "tools.browser_tool_install._merge_browser_path", - return_value="/opt/hermes/node/bin:/usr/bin", - ) as mock_merge, \ - patch("subprocess.Popen", return_value=_mock_proc()) as mock_popen: - warm_agent_browser_npx_cache() - - mock_merge.assert_called_once_with("/usr/bin") - _args, kwargs = mock_popen.call_args - assert kwargs["env"]["PATH"] == "/opt/hermes/node/bin:/usr/bin" - - -@pytest.mark.platforms("linux") -def test_runs_in_its_own_process_group_on_posix(monkeypatch): - monkeypatch.setattr("os.name", "posix") - with patch("tools.browser_tool_install._resolve_npx_bin", return_value="/usr/bin/npx"), \ - patch("subprocess.Popen", return_value=_mock_proc()) as mock_popen: - warm_agent_browser_npx_cache() - - _args, kwargs = mock_popen.call_args - assert kwargs.get("start_new_session") is True - - -def test_uses_new_process_group_creationflag_on_windows_instead_of_start_new_session(): - """start_new_session is a POSIX-only Popen kwarg (raises on Windows). - The Windows equivalent for _kill_process_tree's taskkill /T to have a - coherent tree to kill is CREATE_NEW_PROCESS_GROUP via creationflags.""" - with patch("os.name", "nt"), \ - patch("tools.browser_tool_install._resolve_npx_bin", return_value="C:\\npx.cmd"), \ - patch("tools.browser_tool._build_browser_env", return_value={"PATH": "C:\\Windows"}), \ - patch("tools.browser_tool_install._merge_browser_path", side_effect=lambda p: p), \ - patch("subprocess.Popen", return_value=_mock_proc()) as mock_popen: - warm_agent_browser_npx_cache() - - _args, kwargs = mock_popen.call_args - assert "start_new_session" not in kwargs - create_new_pgroup = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) - assert kwargs["creationflags"] & create_new_pgroup == create_new_pgroup - - -def test_timeout_kills_the_whole_process_tree_not_just_the_pid(): - """subprocess.Popen.kill() only signals the direct child; npm/npx can - fork descendants that survive it and hold a capture pipe open past the - nominal timeout. On timeout, the whole process group/tree must be - killed, not just the top-level PID.""" - proc = _mock_proc( - communicate_side_effect=[ - subprocess.TimeoutExpired(cmd=["npx"], timeout=60.0), ("", ""), - ] - ) - with patch("tools.browser_tool_install._resolve_npx_bin", return_value="/usr/bin/npx"), \ - patch("subprocess.Popen", return_value=proc), \ - patch("tools.browser_tool_lifecycle._kill_process_tree") as mock_kill: - assert warm_agent_browser_npx_cache(timeout=60.0) is False - - mock_kill.assert_called_once_with(proc) - assert proc.communicate.call_count == 2, ( - "must attempt a second, bounded communicate() after the kill to reap " - "the now-dead process and drain its pipes, not just abandon it" - ) - - -def test_timeout_cleanup_communicate_itself_raising_does_not_propagate(): - """The post-kill drain call is itself best-effort — if the process is - stuck badly enough that even the 5s cleanup communicate() times out (or - raises for any other reason), that must not escape and crash the - fire-and-forget caller (hermes_cli/doctor.py calls this bare).""" - proc = _mock_proc( - communicate_side_effect=[ - subprocess.TimeoutExpired(cmd=["npx"], timeout=60.0), - subprocess.TimeoutExpired(cmd=["npx"], timeout=5), - ] - ) - with patch("tools.browser_tool_install._resolve_npx_bin", return_value="/usr/bin/npx"), \ - patch("subprocess.Popen", return_value=proc), \ - patch("tools.browser_tool_lifecycle._kill_process_tree") as mock_kill: - assert warm_agent_browser_npx_cache(timeout=60.0) is False - - mock_kill.assert_called_once_with(proc) - - -def test_returns_false_on_nonzero_exit(): - with patch("tools.browser_tool_install._resolve_npx_bin", return_value="/usr/bin/npx"), patch( - "subprocess.Popen", return_value=_mock_proc(returncode=1) - ): - assert warm_agent_browser_npx_cache() is False - - -def test_returns_false_instead_of_raising_on_popen_failure(): - with patch("tools.browser_tool_install._resolve_npx_bin", return_value="/usr/bin/npx"), patch( - "subprocess.Popen", side_effect=OSError("fork failed") - ): - assert warm_agent_browser_npx_cache() is False - - -def test_returns_false_instead_of_raising_on_unexpected_communicate_exception(): - """Fire-and-forget contract: hermes_cli/doctor.py calls this bare (no - try/except of its own), so any exception must be swallowed here.""" - proc = _mock_proc(communicate_side_effect=OSError("broken pipe")) - with patch("tools.browser_tool_install._resolve_npx_bin", return_value="/usr/bin/npx"), \ - patch("subprocess.Popen", return_value=proc), \ - patch("tools.browser_tool_lifecycle._kill_process_tree") as mock_kill: - assert warm_agent_browser_npx_cache() is False - mock_kill.assert_called_once_with(proc) - - -@pytest.mark.platforms("linux") -class TestLegacyKillProcessTree: - """Contract of the pre-#85125 local fallback (used when agent.deadline - delegation fails); the delegating wrapper is covered in - tests/agent/test_treekill_consolidation.py.""" - - def test_posix_kills_process_group_term_then_kill(self, monkeypatch): - import signal - - proc = MagicMock() - proc.pid = 999 - monkeypatch.setattr("os.name", "posix") - monkeypatch.setattr("os.getpgid", lambda pid: 999) - killpg_calls = [] - monkeypatch.setattr( - "os.killpg", lambda pgid, sig: killpg_calls.append((pgid, sig)) - ) - - _legacy_kill_process_tree(proc) - - assert killpg_calls == [(999, signal.SIGTERM), (999, signal.SIGKILL)] - - def test_posix_missing_process_returns_silently(self, monkeypatch): - proc = MagicMock() - proc.pid = 999 - monkeypatch.setattr("os.name", "posix") - - def _raise(pid): - raise ProcessLookupError() - - monkeypatch.setattr("os.getpgid", _raise) - - _legacy_kill_process_tree(proc) # must not raise - - def test_posix_missing_killpg_attribute_falls_back_to_proc_kill(self, monkeypatch): - """Some POSIX-like environments may lack os.killpg entirely (the - implementation resolves it defensively via - ``getattr(os, "killpg", None)`` — flagged by - scripts/check-windows-footguns.py against a bare ``os.killpg`` - reference). When that resolution comes back None, the fallback must - be a plain ``proc.kill()`` of just the top-level PID, not an - AttributeError.""" - import os as os_module - - proc = MagicMock() - proc.pid = 999 - monkeypatch.setattr("os.name", "posix") - monkeypatch.delattr(os_module, "killpg", raising=False) - - _legacy_kill_process_tree(proc) - - proc.kill.assert_called_once() - - def test_posix_missing_killpg_fallback_proc_kill_failure_does_not_raise(self, monkeypatch): - import os as os_module - - proc = MagicMock() - proc.pid = 999 - proc.kill.side_effect = OSError("already reaped") - monkeypatch.setattr("os.name", "posix") - monkeypatch.delattr(os_module, "killpg", raising=False) - - _legacy_kill_process_tree(proc) # must not raise - - def test_posix_sigterm_permission_denied_does_not_attempt_sigkill(self, monkeypatch): - """If SIGTERM itself is rejected (e.g. a stale pgid reused by an - unrelated, unkillable process), the loop must bail out rather than - plow ahead into a second signal against the wrong target.""" - import signal - - proc = MagicMock() - proc.pid = 999 - monkeypatch.setattr("os.name", "posix") - monkeypatch.setattr("os.getpgid", lambda pid: 999) - killpg_calls = [] - - def fake_killpg(pgid, sig): - killpg_calls.append((pgid, sig)) - raise PermissionError() - - monkeypatch.setattr("os.killpg", fake_killpg) - - _legacy_kill_process_tree(proc) # must not raise - - assert killpg_calls == [(999, signal.SIGTERM)] - - def test_windows_uses_taskkill_with_tree_and_force_flags(self, monkeypatch): - proc = MagicMock() - proc.pid = 4321 - monkeypatch.setattr("os.name", "nt") - with patch("subprocess.run") as mock_run: - _legacy_kill_process_tree(proc) - - mock_run.assert_called_once() - cmd = mock_run.call_args.args[0] - assert cmd == ["taskkill", "/PID", "4321", "/T", "/F"] - - def test_windows_taskkill_failure_does_not_raise(self, monkeypatch): - proc = MagicMock() - proc.pid = 4321 - monkeypatch.setattr("os.name", "nt") - with patch("subprocess.run", side_effect=OSError("taskkill missing")): - _legacy_kill_process_tree(proc) # must not raise diff --git a/tests/tools/test_browser_pm.py b/tests/tools/test_browser_pm.py new file mode 100644 index 0000000000..e2f81119c8 --- /dev/null +++ b/tests/tools/test_browser_pm.py @@ -0,0 +1,290 @@ +"""Browser consumers use real PM facts, not ambient npm caches.""" + +import json +import os +import sys +from pathlib import Path + +import pytest + +import pm +from pm import paths +from tools import browser_tool as bt +from tools import browser_tool_install as install +from tools import browser_tool_session as session + + +@pytest.fixture +def browser_store(tmp_path, monkeypatch): + home = tmp_path / "home with spaces" + home.mkdir() + store = home / "tools" + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setenv("HERMES_RUNTIME_DIR", str(store)) + monkeypatch.setenv("HERMES_DISABLE_LAZY_INSTALLS", "1") + monkeypatch.setenv("PATH", "") + monkeypatch.setattr(bt, "_SANE_PATH_DIRS", ()) + monkeypatch.setattr(install, "_discover_homebrew_node_dirs", lambda: ()) + monkeypatch.delenv("AGENT_BROWSER_EXECUTABLE_PATH", raising=False) + monkeypatch.delenv("PLAYWRIGHT_BROWSERS_PATH", raising=False) + lock = pm.Lockfile(paths.lockfile_path()) + target = pm.current_target() + + def publish(name, content="browser-fixture"): + package = pm.get_package(name) + version = lock.version(name) + assert version is not None + entry = store / package.store_entry(version, target) + if name == "chromium": + binary = entry / "browser" / ("chrome.exe" if os.name == "nt" else "chrome") + else: + binary = package.binary(entry, target) + assert binary is not None + binary.parent.mkdir(parents=True, exist_ok=True) + binary.write_text(content) + binary.chmod(0o755) + pm.Facts(store / "facts.json").record( + name, version, entry.name, package.env(entry, target), store, + target=target, artifacts=[a["sha256"] for a in lock.artifacts(name, target)], + ) + installed = pm.installed_package(name) + assert installed is not None and installed.binary == binary + return binary + + return home, store, publish + + +def test_unrecorded_playwright_cache_is_not_a_pm_browser(browser_store): + home, _, _ = browser_store + (home / ".cache" / "ms-playwright" / "chromium-1234").mkdir(parents=True) + assert install._chromium_installed() is False + + +def test_doctor_fix_publishes_and_reads_the_pm_browser(browser_store, monkeypatch): + from hermes_cli import doctor_tools + import pm.client + + _, _, publish = browser_store + monkeypatch.setenv("PATH", "") + requests = [] + + def request(operation, payload, **kwargs): + # Only the worker/download boundary is replaced; selection, admission, + # facts and environment composition are the actual PM implementation. + requests.append((operation, payload)) + publish("chromium") + publish("agent-browser") + + monkeypatch.setattr(pm.client, "_request", request) + assert doctor_tools._check_agent_browser(False) is False + assert requests == [] + assert doctor_tools._check_agent_browser(True) is True + assert requests == [("ensure", {"name": "agent-browser", "explicit": True})] + + +def test_missing_browser_refuses_lazy_install_even_with_npx(browser_store, monkeypatch, tmp_path): + _, store, _ = browser_store + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + npx = bin_dir / ("npx.exe" if os.name == "nt" else "npx") + npx.write_bytes(b"not an agent-browser installation") + npx.chmod(0o755) + monkeypatch.setenv("PATH", str(bin_dir)) + with pytest.raises(FileNotFoundError, match="agent-browser CLI not found"): + install._find_agent_browser(validate=False) + with pytest.raises(FileNotFoundError, match="[Ll]azy|[Aa]utomatic|disabled"): + install._find_agent_browser() + assert not store.exists() + + +def test_late_pm_install_is_visible_without_cache_reset(browser_store, monkeypatch): + _, _, publish = browser_store + monkeypatch.setenv("PATH", "") + with pytest.raises(FileNotFoundError): + install._find_agent_browser() + binary = publish("agent-browser") + assert install._find_agent_browser() == str(binary) + + +def test_execution_acquires_missing_browser_through_pm(browser_store, monkeypatch): + import pm.client + + _, _, publish = browser_store + monkeypatch.setenv("PATH", "") + monkeypatch.setenv("HERMES_DISABLE_LAZY_INSTALLS", "0") + assert pm.lazy_installs_allowed() + requests = [] + + def request(operation, payload, **kwargs): + requests.append((operation, payload)) + publish("chromium") + publish("agent-browser") + + monkeypatch.setattr(pm.client, "_request", request) + result = install._find_agent_browser() + installed = pm.installed_package("agent-browser") + assert installed is not None and result == str(installed.binary) + assert requests == [("ensure", {"name": "agent-browser", "explicit": False})] + assert install._chromium_installed() + + +def test_termux_ownership_policy_never_provisions(browser_store, monkeypatch): + # Exercise the real environment policy, not an emulated Android binary. + from hermes_cli import doctor_tools + + monkeypatch.setenv("TERMUX_VERSION", "test") + monkeypatch.setenv("PATH", "") + monkeypatch.setenv("HERMES_DISABLE_LAZY_INSTALLS", "0") + monkeypatch.setattr(install, "_running_in_docker", lambda: False) + monkeypatch.setattr(bt, "_chromium_autoinstall_attempted", False) + + def forbidden(*args, **kwargs): + pytest.fail("Termux browser installation is externally owned") + + monkeypatch.setattr(pm, "ensure", forbidden) + with pytest.raises(FileNotFoundError, match="npm install -g agent-browser"): + install._find_agent_browser() + assert install._maybe_autoinstall_chromium() is False + assert doctor_tools._check_agent_browser(True) is False + + +def test_cdp_override_does_not_require_pm_browser(browser_store, monkeypatch): + monkeypatch.setenv("BROWSER_CDP_URL", "ws://127.0.0.1:9222/devtools/browser/test") + monkeypatch.setattr(bt, "_is_browser_use_cli_mode", lambda: False) + monkeypatch.setattr(bt, "_is_camofox_mode", lambda: False) + assert install.check_browser_requirements() is True + + +@pytest.mark.platforms("linux", "darwin") +def test_external_browser_on_path_remains_supported_without_pm(browser_store, monkeypatch, tmp_path): + _, store, _ = browser_store + bin_dir = tmp_path / "external browser" + bin_dir.mkdir() + binary = bin_dir / "agent-browser" + binary.write_text(f"#!{sys.executable}\nprint('external-agent-browser')\n") + binary.chmod(0o755) + monkeypatch.setenv("PATH", str(bin_dir)) + assert install._find_agent_browser(validate=False) == str(binary) + assert install._find_agent_browser() == str(binary) + assert not store.exists() + + +@pytest.mark.parametrize("entrypoint", [bt.warm_agent_browser_npx_cache, install.warm_agent_browser_npx_cache]) +def test_historical_warmer_is_inert(entrypoint, monkeypatch): + def forbidden(*args, **kwargs): + pytest.fail("historical browser warmer must do no work") + + monkeypatch.setattr("subprocess.Popen", forbidden) + monkeypatch.setattr(pm, "ensure", forbidden) + assert entrypoint(timeout=0.1) is False + + +def test_external_chromium_override_survives_pm_composition(browser_store, monkeypatch, tmp_path): + _, _, publish = browser_store + publish("chromium") + override = tmp_path / "user browser" + override.write_bytes(b"external") + monkeypatch.setenv("AGENT_BROWSER_EXECUTABLE_PATH", str(override)) + assert install._chromium_installed() + assert session._agent_browser_command_env(str(tmp_path))["AGENT_BROWSER_EXECUTABLE_PATH"] == str(override) + + +def test_restricted_path_discovers_external_browser_without_execution(browser_store, monkeypatch, tmp_path): + _, store, _ = browser_store + external_bin = tmp_path / "external-homebrew" / "bin" + external_bin.mkdir(parents=True) + binary = external_bin / ("agent-browser.exe" if os.name == "nt" else "agent-browser") + binary.write_bytes(b"external browser; presence check must not execute") + binary.chmod(0o755) + monkeypatch.setenv("PATH", str(tmp_path / "empty-path")) + monkeypatch.setattr(bt, "_SANE_PATH_DIRS", (str(external_bin),)) + + def forbidden(*args, **kwargs): + pytest.fail("passive external browser discovery must not execute or install") + + monkeypatch.setattr("subprocess.Popen", forbidden) + monkeypatch.setattr(pm, "ensure", forbidden) + assert install._find_agent_browser(validate=False) == str(binary) + assert not store.exists() + + +def test_pm_browser_wins_over_legacy_and_ambient_installs(browser_store, monkeypatch): + home, store, publish = browser_store + binary = publish("agent-browser") + publish("chromium") + legacy = home / "node_modules" / ".bin" + legacy.mkdir(parents=True) + external = legacy / ("agent-browser.exe" if os.name == "nt" else "agent-browser") + external.write_bytes(b"legacy browser") + external.chmod(0o755) + monkeypatch.setenv("PATH", str(legacy)) + before = (store / "facts.json").read_bytes() + assert install._find_agent_browser(validate=False) == str(binary) + assert (store / "facts.json").read_bytes() == before + + +@pytest.mark.platforms("linux", "darwin") +def test_exact_pm_child_receives_composed_scrubbed_environment(browser_store, monkeypatch, tmp_path): + _, store, publish = browser_store + binary = publish("agent-browser", f"#!{sys.executable}\n" + '''import json, os, sys +print(json.dumps({"argv": sys.argv, "env": dict(os.environ)})) +''') + chromium = publish("chromium") + monkeypatch.setenv("PATH", str(tmp_path / "hostile-path")) + system_bin = tmp_path / "external-system-bin" + system_bin.mkdir() + monkeypatch.setattr(bt, "_SANE_PATH_DIRS", (str(system_bin),)) + monkeypatch.setenv("OPENAI_API_KEY", "must-not-reach-browser") + monkeypatch.setenv("BROWSERBASE_API_KEY", "browser-provider-key") + before = dict(os.environ) + socket_dir = tmp_path / "session" + socket_dir.mkdir() + command = install._find_agent_browser(validate=False) + env = session._agent_browser_command_env(str(socket_dir)) + proc = session._popen_agent_browser([command, "--json", "get", "url"], env, str(socket_dir), "probe") + assert proc.wait(timeout=10) == 0 + output, error = session._read_command_output_files(str(socket_dir / "_stdout_probe"), str(socket_dir / "_stderr_probe")) + assert error == "" + child = json.loads(output) + assert Path(child["argv"][0]) == binary + assert child["argv"][1:] == ["--json", "get", "url"] + assert child["env"]["AGENT_BROWSER_EXECUTABLE_PATH"] == str(chromium) + assert child["env"]["PLAYWRIGHT_BROWSERS_PATH"] == str(store) + assert child["env"]["PATH"].split(os.pathsep)[0] == str(binary.parent) + assert str(system_bin) in child["env"]["PATH"].split(os.pathsep)[1:] + assert "OPENAI_API_KEY" not in child["env"] + assert child["env"]["BROWSERBASE_API_KEY"] == "browser-provider-key" + assert child["env"]["AGENT_BROWSER_SOCKET_DIR"] == str(socket_dir) + assert dict(os.environ) == before + + +@pytest.mark.platforms("linux", "darwin") +def test_runtime_and_chrome_fallback_launch_the_same_pm_binary(browser_store, monkeypatch, tmp_path): + from tools import browser_tool_cloud as cloud + from tools import browser_tool_lightpanda_fallback as fallback + + _, _, publish = browser_store + binary = publish("agent-browser", f"#!{sys.executable}\n" + '''import json, os, sys +print(json.dumps({"success": True, "data": { + "url": "https://example.com/", "argv": sys.argv, + "browser": os.environ.get("AGENT_BROWSER_EXECUTABLE_PATH") +}})) +''') + chromium = publish("chromium") + monkeypatch.setenv("PATH", "") + monkeypatch.setattr(bt, "_socket_safe_tmpdir", lambda: str(tmp_path)) + monkeypatch.setattr(cloud, "_is_local_mode", lambda: True) + monkeypatch.setattr(cloud, "_get_browser_engine", lambda: "auto") + monkeypatch.setattr(cloud, "_is_headed_mode", lambda: False) + monkeypatch.setattr(session, "_get_session_info", lambda task_id: {"session_name": "fixture", "cdp_url": None}) + monkeypatch.setattr("tools.interrupt.is_interrupted", lambda: False) + + direct = session._run_browser_command("fixture", "get", ["url"]) + chrome = fallback._run_chrome_fallback_command("fixture", "get", ["url"], timeout=10) + for result in (direct, chrome): + assert result["success"] is True + assert result["data"]["argv"][0] == str(binary) + assert result["data"]["browser"] == str(chromium) + assert direct["data"]["argv"][1:3] == ["--session", "fixture"] + assert chrome["data"]["argv"][1:3] == ["--engine", "chrome"] \ No newline at end of file diff --git a/tests/tools/test_browser_process_tree.py b/tests/tools/test_browser_process_tree.py new file mode 100644 index 0000000000..785df6058a --- /dev/null +++ b/tests/tools/test_browser_process_tree.py @@ -0,0 +1,60 @@ +"""Browser cleanup fallback contracts, retained after retiring the npx warmer.""" + +import os +import signal +from unittest.mock import MagicMock, patch + +import pytest + +from tools.browser_tool_lifecycle import _legacy_kill_process_tree + + +@pytest.mark.platforms("linux", "darwin") +def test_posix_kills_process_group_term_then_kill(monkeypatch): + proc = MagicMock(pid=999) + monkeypatch.setattr(os, "getpgid", lambda pid: 999) + calls = [] + monkeypatch.setattr(os, "killpg", lambda pgid, sig: calls.append((pgid, sig))) + _legacy_kill_process_tree(proc) + assert calls == [(999, signal.SIGTERM), (999, signal.SIGKILL)] + + +@pytest.mark.platforms("linux", "darwin") +def test_posix_missing_process_returns_silently(monkeypatch): + def missing(pid): + raise ProcessLookupError() + + monkeypatch.setattr(os, "getpgid", missing) + _legacy_kill_process_tree(MagicMock(pid=999)) + + +@pytest.mark.platforms("linux", "darwin") +@pytest.mark.parametrize("kill_error", [None, OSError("already reaped")]) +def test_missing_killpg_falls_back_to_proc_kill(monkeypatch, kill_error): + proc = MagicMock(pid=999) + proc.kill.side_effect = kill_error + monkeypatch.delattr(os, "killpg", raising=False) + _legacy_kill_process_tree(proc) + proc.kill.assert_called_once() + + +@pytest.mark.platforms("linux", "darwin") +def test_permission_denied_does_not_attempt_sigkill(monkeypatch): + monkeypatch.setattr(os, "getpgid", lambda pid: 999) + calls = [] + + def denied(pgid, sig): + calls.append((pgid, sig)) + raise PermissionError() + + monkeypatch.setattr(os, "killpg", denied) + _legacy_kill_process_tree(MagicMock(pid=999)) + assert calls == [(999, signal.SIGTERM)] + + +@pytest.mark.platforms("win32") +@pytest.mark.parametrize("error", [None, OSError("taskkill missing")]) +def test_windows_taskkill_targets_tree_and_is_best_effort(error): + with patch("subprocess.run", side_effect=error) as run: + _legacy_kill_process_tree(MagicMock(pid=4321)) + assert run.call_args.args[0] == ["taskkill", "/PID", "4321", "/T", "/F"] \ No newline at end of file diff --git a/tests/tools/test_browser_real_profile.py b/tests/tools/test_browser_real_profile.py index a24853444e..1526558dc6 100644 --- a/tests/tools/test_browser_real_profile.py +++ b/tests/tools/test_browser_real_profile.py @@ -238,6 +238,7 @@ class TestRealProfileCdpLaunch: def test_snapshot_failure_fails_closed(self): self._reset() with patch.object(bt_cloud, "_use_real_profile", return_value=True), \ + patch.object(bt_real_profile, "_agent_browser_get_cdp", return_value=None), \ patch("hermes_cli.browser_connect.detect_default_chromium", return_value="chrome"), \ patch("hermes_cli.browser_connect.snapshot_real_profile", return_value=(None, "boom")): cdp, err = bt_real_profile._real_profile_cdp() diff --git a/tests/tools/test_browser_secret_exfil.py b/tests/tools/test_browser_secret_exfil.py index af940bec37..9405d6a82f 100644 --- a/tests/tools/test_browser_secret_exfil.py +++ b/tests/tools/test_browser_secret_exfil.py @@ -1,6 +1,7 @@ """Tests for secret exfiltration prevention in browser and web tools.""" import json +import socket from unittest.mock import patch, MagicMock import pytest @@ -37,6 +38,9 @@ class TestBrowserSecretExfil: url = "https://example.com/callback?token=opaque-oauth-code&signature=abc123" mock_result = {"success": True, "data": {"title": "ok", "url": url}} with patch("tools.browser_tool_cloud._is_local_backend", return_value=False), \ + patch("socket.getaddrinfo", return_value=[ + (socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 443)) + ]), \ patch("tools.browser_tool._navigation_session_key", return_value="default"), \ patch("tools.browser_tool_session._get_session_info", return_value={"_first_nav": False}), \ patch("tools.browser_tool_session._run_browser_command", return_value=mock_result) as mock_run: diff --git a/tests/tools/test_computer_use.py b/tests/tools/test_computer_use.py index 363520f765..470c6cb0e6 100644 --- a/tests/tools/test_computer_use.py +++ b/tests/tools/test_computer_use.py @@ -729,49 +729,6 @@ class TestElementLabelParsing: assert labels[201] == "" # pure order number, no label -class TestUpdateCheck: - """cua_driver_update_check() / _nudge(): native `check-update --json`. - - Prefers cua-driver's source-of-truth update check over a hardcoded - version floor. Stays quiet (None) when indeterminate: an old driver with - no `check-update` verb, offline, an `error` payload, or unparseable output. - """ - - @pytest.fixture(autouse=True) - def _driver_resolves(self): - # The update check now short-circuits to None when no driver - # resolves; CI has none installed, so pin a resolved path. - with patch( - "tools.computer_use.cua_backend_driver.resolve_cua_driver_cmd", - return_value="/usr/local/bin/cua-driver", - ): - yield - - @staticmethod - def _run_returning(stdout: str): - fake = MagicMock() - fake.stdout = stdout - return patch("tools.computer_use.cua_backend.subprocess.run", return_value=fake) - - def test_update_available(self): - from tools.computer_use import cua_backend - from tools.computer_use import cua_backend_driver - payload = '{"current_version":"0.3.1","latest_version":"0.3.2","update_available":true}' - with self._run_returning(payload): - st = cua_backend_driver.cua_driver_update_check() - assert st is not None and st["update_available"] is True - msg = cua_backend.cua_driver_update_nudge() - assert msg is not None - assert "0.3.2" in msg and "0.3.1" in msg - - def test_error_payload_is_indeterminate(self): - from tools.computer_use import cua_backend - from tools.computer_use import cua_backend_driver - payload = '{"current_version":"0.3.2","update_available":false,"error":"github 503"}' - with self._run_returning(payload): - assert cua_backend_driver.cua_driver_update_check() is None - assert cua_backend.cua_driver_update_nudge() is None - class TestLazyMcpInstall: """`mcp` is an optional extra; the backend lazy-installs it on start(). @@ -786,11 +743,12 @@ class TestLazyMcpInstall: "cua_driver_runtime_contract_status", return_value={"ready": True}, ), \ - patch.object(cua_backend, "_maybe_nudge_update"), \ + patch("pm.ensure") as driver_ensure, \ patch("pm.ensure_import") as mock_ensure, \ patch.object(cua_backend._CuaDriverSession, "start") as mock_sess_start: cua_backend.CuaDriverBackend().start() mock_ensure.assert_called_once_with("computer-use") + driver_ensure.assert_called_once_with("cua-driver") mock_sess_start.assert_called_once() def test_start_reports_incompatible_existing_driver_before_mcp_setup(self): @@ -804,7 +762,7 @@ class TestLazyMcpInstall: cua_backend, "cua_driver_runtime_contract_status", return_value=state, - ), patch("pm.ensure_import") as mock_ensure: + ), patch("pm.ensure"), patch("pm.ensure_import") as mock_ensure: with pytest.raises(RuntimeError, match="hermes computer-use install"): cua_backend.CuaDriverBackend().start() @@ -824,7 +782,7 @@ class TestLazyMcpInstall: "cua_driver_runtime_contract_status", return_value={"ready": True}, ), \ - patch.object(cua_backend, "_maybe_nudge_update"), \ + patch("pm.ensure"), \ patch("pm.ensure_import", side_effect=unavailable), \ patch.object(cua_backend._CuaDriverSession, "start") as mock_sess_start: with pytest.raises(FeatureUnavailable): @@ -832,120 +790,26 @@ class TestLazyMcpInstall: mock_sess_start.assert_not_called() # never reaches the MCP session -class TestContractAutoRepair: - """An installed-but-incompatible driver is repaired automatically, once. - - The 0.20 runtime-contract gate fails closed; when the failure is an old - installed driver (a state Hermes' own version-floor bump created), - start() runs the standard install/repair path once instead of failing - every computer_use call until the user runs the CLI by hand. - """ - - def _incompatible(self): - return { - "ready": False, - "binary": "/usr/local/bin/cua-driver", - "version": "0.19.3", - "reason": "Hermes computer use requires cua-driver 0.20.0 or newer", - } - - def test_start_auto_repairs_incompatible_driver(self, monkeypatch): - from unittest.mock import MagicMock, patch +class TestDriverPreparation: + @pytest.mark.parametrize("override", [False, True]) + def test_failed_contract_never_retries_or_replaces_override(self, monkeypatch, override): from tools.computer_use import cua_backend - monkeypatch.setattr(cua_backend, "_contract_repair_attempted", False) - backend = cua_backend.CuaDriverBackend() - backend._session = MagicMock() - - with patch.object( - cua_backend, - "cua_driver_runtime_contract_status", - side_effect=[self._incompatible(), {"ready": True}], - ), \ - patch("hermes_cli.tools_config.install_cua_driver", - return_value=True) as installer, \ - patch.object(cua_backend, "_maybe_nudge_update"), \ - patch("pm.ensure_import"): - backend.start() - - installer.assert_called_once_with( - upgrade=False, show_installer_progress=False - ) - backend._session.start.assert_called_once() - - def test_failed_repair_surfaces_original_error(self, monkeypatch): - from unittest.mock import patch - from tools.computer_use import cua_backend - - monkeypatch.setattr(cua_backend, "_contract_repair_attempted", False) - with patch.object( - cua_backend, - "cua_driver_runtime_contract_status", - return_value=self._incompatible(), - ), \ - patch("hermes_cli.tools_config.install_cua_driver", - return_value=False), \ - patch("pm.ensure_import") as mock_ensure: - with pytest.raises(RuntimeError, match="0.20.0 or newer"): + if override: + monkeypatch.setenv("HERMES_CUA_DRIVER_CMD", "/opt/custom/cua-driver") + else: + monkeypatch.delenv("HERMES_CUA_DRIVER_CMD", raising=False) + with patch.object(cua_backend, "cua_driver_runtime_contract_status", + return_value={"ready": False, "reason": "invalid manifest"}), \ + patch("pm.ensure") as ensure, \ + patch("pm.ensure_import") as sdk: + with pytest.raises(RuntimeError, match="invalid manifest"): cua_backend.CuaDriverBackend().start() - mock_ensure.assert_not_called() - - def test_repair_is_attempted_once_per_process(self, monkeypatch): - from unittest.mock import patch - from tools.computer_use import cua_backend - - monkeypatch.setattr(cua_backend, "_contract_repair_attempted", False) - with patch.object( - cua_backend, - "cua_driver_runtime_contract_status", - return_value=self._incompatible(), - ), \ - patch("hermes_cli.tools_config.install_cua_driver", - return_value=False) as installer, \ - patch("pm.ensure_import"): - for _ in range(2): - with pytest.raises(RuntimeError): - cua_backend.CuaDriverBackend().start() - installer.assert_called_once() - - def test_explicit_override_is_never_repaired(self, monkeypatch): - from unittest.mock import patch - from tools.computer_use import cua_backend - - monkeypatch.setattr(cua_backend, "_contract_repair_attempted", False) - monkeypatch.setenv("HERMES_CUA_DRIVER_CMD", "/opt/custom/cua-driver") - with patch.object( - cua_backend, - "cua_driver_runtime_contract_status", - return_value=self._incompatible(), - ), \ - patch("hermes_cli.tools_config.install_cua_driver") as installer, \ - patch("pm.ensure_import"): - with pytest.raises(RuntimeError, match="HERMES_CUA_DRIVER_CMD"): - cua_backend.CuaDriverBackend().start() - installer.assert_not_called() - - def test_missing_binary_is_not_repaired(self, monkeypatch): - from unittest.mock import patch - from tools.computer_use import cua_backend - - monkeypatch.setattr(cua_backend, "_contract_repair_attempted", False) - state = { - "ready": False, - "binary": None, - "version": None, - "reason": "cua-driver is not installed", - } - with patch.object( - cua_backend, - "cua_driver_runtime_contract_status", - return_value=state, - ), \ - patch("hermes_cli.tools_config.install_cua_driver") as installer, \ - patch("pm.ensure_import"): - with pytest.raises(RuntimeError, match="not installed"): - cua_backend.CuaDriverBackend().start() - installer.assert_not_called() + if override: + ensure.assert_not_called() + else: + ensure.assert_called_once_with("cua-driver") + sdk.assert_not_called() class TestCaptureAfterAppContext: @@ -2312,12 +2176,11 @@ class TestSessionLifecycle: "structuredContent": None, "isError": False, }) - # Stub the optional-dep lazy-install so start() runs end-to-end - # without trying to pip-install anything. + # Session lifecycle is independent of PM acquisition. with patch( "tools.computer_use.cua_backend.cua_driver_runtime_contract_status", return_value={"ready": True}, - ), patch("pm.ensure_import"): + ), patch("pm.ensure"), patch("pm.ensure_import"): backend.start() # First call_tool after _session.start() must be start_session @@ -2344,7 +2207,7 @@ class TestSessionLifecycle: with patch( "tools.computer_use.cua_backend.cua_driver_runtime_contract_status", return_value={"ready": True}, - ), patch("pm.ensure_import"): + ), patch("pm.ensure"), patch("pm.ensure_import"): backend.start() # must not raise diff --git a/tests/tools/test_dockerfile_immutable_install.py b/tests/tools/test_dockerfile_immutable_install.py index 21a5c938c3..41f5e74ac5 100644 --- a/tests/tools/test_dockerfile_immutable_install.py +++ b/tests/tools/test_dockerfile_immutable_install.py @@ -1,4 +1,8 @@ -"""Contract tests for the Docker image's immutable /opt/hermes install tree.""" +"""Packaging guards not yet covered by the image-runtime suite. + +Write permissions and lazy-install policy are exercised by tests/docker. +Keep stamp placement and Photon preparation until those image checks exist. +""" from __future__ import annotations import re @@ -12,33 +16,6 @@ def _dockerfile_text() -> str: return DOCKERFILE.read_text() -def test_dockerfile_makes_opt_hermes_readonly_for_hermes_user() -> None: - text = _dockerfile_text() - - # --chmod on the source COPY bakes read-only perms at copy time instead - # of a separate chmod -R pass (which walked ~30k files — #49113). - assert "COPY --link --chmod=a+rX,go-w . ." in text - # The old tree-walking passes must not be present. - assert "chown -R root:root /opt/hermes" not in text - assert "chmod -R a+rX /opt/hermes" not in text - assert "chmod -R a-w /opt/hermes" not in text - - -def test_dockerfile_does_not_chown_install_trees_to_hermes() -> None: - text = _dockerfile_text() - forbidden_patterns = ( - r"chown\s+-R\s+hermes:hermes\s+/opt/hermes/\.venv", - r"chown\s+-R\s+hermes:hermes\s+/opt/hermes/ui-tui", - r"chown\s+-R\s+hermes:hermes\s+/opt/hermes/gateway", - r"chown\s+-R\s+hermes:hermes\s+/opt/hermes/node_modules", - ) - for pattern in forbidden_patterns: - assert not re.search(pattern, text), ( - "runtime install trees under /opt/hermes must stay immutable; " - f"found forbidden pattern {pattern!r}" - ) - - def test_dockerfile_bakes_code_scoped_install_method_stamp() -> None: """The 'docker' install-method stamp is baked next to the code. @@ -62,15 +39,6 @@ def test_dockerfile_bakes_code_scoped_install_method_stamp() -> None: assert shim_block, "install-method stamp must be in the shim-wiring RUN block" -def test_dockerfile_disables_lazy_installs() -> None: - """The published image fully disables runtime lazy installs so nothing - can mutate the sealed venv (the old durable-target redirect machinery is - gone — no code reads HERMES_LAZY_INSTALL_TARGET anymore). - """ - assert "ENV HERMES_DISABLE_LAZY_INSTALLS=1" in _dockerfile_text() - assert "HERMES_LAZY_INSTALL_TARGET" not in _dockerfile_text() - - def test_dockerfile_bakes_photon_sidecar_deps() -> None: """The Photon sidecar's node_modules must be baked at build time (NS-606). diff --git a/tools/browser_tool.py b/tools/browser_tool.py index f46d4b374a..7d972dae03 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -89,7 +89,7 @@ except ImportError: logger = logging.getLogger(__name__) # Standard PATH entries for environments with minimal PATH (e.g. systemd services). -# Includes macOS Homebrew locations needed for agent-browser, npx, and node. +# Includes macOS Homebrew locations for externally installed browser helpers. _SANE_PATH_DIRS = ( "/opt/homebrew/bin", "/opt/homebrew/sbin", @@ -126,13 +126,6 @@ MIN_SNAPSHOT_THRESHOLD = 1000 MAX_STORED_SNAPSHOT_CHARS = 2_000_000 _EMPTY_OK_COMMANDS: frozenset = frozenset({"close", "record"}) # legitimately empty stdout -# Sentinel _find_agent_browser returns/caches to mean "resolve via npx" rather -# than a concrete path (also compared in hermes_cli/tools_config.py and doctor.py). -NPX_AGENT_BROWSER_SENTINEL = "npx agent-browser" -# Pinned to match scripts/install.sh / install.ps1's managed install so a bare-npx -# resolution gets the same version instead of floating latest. Update together. -AGENT_BROWSER_NPX_SPEC = "agent-browser@^0.26.0" - # Process caches (``_cached_X`` + ``_X_resolved`` pairs) for config-derived lookups; # reset by ``cleanup_all_browsers``. Written/read by the sibling modules via ``browser_tool_origin``. # The config-derived ones are keyed by profile home (``hermes_home_key()``): the multiplexed @@ -151,15 +144,12 @@ _cached_cloud_providers: Dict[tuple[str, tuple[int, int]], Optional[BrowserProvi _cloud_provider_cache_lock = threading.RLock() _allow_private_urls_resolved = False _cached_allow_private_urls: Optional[bool] = None -_cached_agent_browser: Optional[str] = None -_agent_browser_resolved = False _cached_browser_engine: Optional[str] = None # agent-browser v0.25.3+ ``--engine lightpanda`` _browser_engine_resolved = False _auto_local_for_private_urls_resolved = False _cached_auto_local_for_private_urls: bool = True _cached_headed_mode: Optional[bool] = None _headed_mode_resolved = False -_cached_chromium_installed: Optional[bool] = None _chromium_autoinstall_attempted = False # one-shot: a failed 170MB download must not retry per call # Mask secrets in logged CDP URLs; agent.redact.redact_cdp_url is the single policy. diff --git a/tools/browser_tool_install.py b/tools/browser_tool_install.py index be0696606d..4764c881a3 100644 --- a/tools/browser_tool_install.py +++ b/tools/browser_tool_install.py @@ -1,22 +1,15 @@ -"""agent-browser / Chromium discovery and install: PATH merging, npx resolution, candidate binaries, Chromium detection + auto-install, requirement checks. +"""PM-owned agent-browser / Chromium discovery, acquisition and readiness. Split out of ``tools/browser_tool.py``. Facade-owned state is read through ``_bt`` (``tools.browser_tool``, resolved per call) — no import cycle.""" -import contextlib import functools import os import shutil -import subprocess -import sys -from pathlib import Path -from typing import List, Optional -from hermes_cli._subprocess_compat import windows_hide_flags -from hermes_constants import agent_browser_runnable, get_hermes_home, is_termux as _is_termux_environment, node_tool_runnable +from hermes_constants import agent_browser_runnable, is_termux as _is_termux_environment from tools.browser_tool_origin import origin_module as _origin from tools import browser_tool_cdp as _cdp from tools import browser_tool_cloud as _cloud -from tools import browser_tool_lifecycle as _lifecycle from tools import browser_tool_lightpanda_fallback as _lp @@ -37,11 +30,9 @@ def _discover_homebrew_node_dirs() -> tuple[str, ...]: def _browser_candidate_path_dirs() -> list[str]: - """Return ordered browser CLI PATH candidates shared by discovery and execution.""" + """System PATH fallbacks for externally owned browser helpers.""" _bt = _origin() - home = get_hermes_home() - managed = (home / "node" / "bin", home / "node", home / "node_modules" / ".bin") - return [*map(str, managed), *_discover_homebrew_node_dirs(), *_bt._SANE_PATH_DIRS] + return [*_discover_homebrew_node_dirs(), *_bt._SANE_PATH_DIRS] def _merge_browser_path(existing_path: str = "") -> str: @@ -55,192 +46,62 @@ def _merge_browser_path(existing_path: str = "") -> str: def _browser_install_hint() -> str: - return "npm install -g agent-browser && agent-browser install" if _is_termux_environment() else "hermes pm install agent-browser (system libraries: npx playwright install-deps chromium)" - - -def _is_npx_agent_browser_sentinel(browser_cmd: str) -> bool: - return browser_cmd.strip() == _origin().NPX_AGENT_BROWSER_SENTINEL - - -def _requires_real_termux_browser_install(browser_cmd: str) -> bool: - return _is_termux_environment() and _cloud._is_local_mode() and _is_npx_agent_browser_sentinel(browser_cmd) - - -def _termux_browser_install_error() -> str: - return f"Local browser automation on Termux cannot rely on the bare npx fallback. Install agent-browser explicitly first: {_browser_install_hint()}" + if _is_termux_environment(): + return "npm install -g agent-browser && agent-browser install" + return "hermes pm install agent-browser (system libraries: npx playwright install-deps chromium)" def _agent_browser_candidate_present(path: str | None) -> bool: if not path: return False - if " " in path and path.split()[0].endswith("npx"): - return True - return os.path.exists(path) and (os.name == "nt" or os.access(path, os.X_OK)) - - -def _resolve_npx_bin() -> Optional[str]: - """Resolve a runnable npx, extended (Hermes-managed/Homebrew) PATH first. - - Bare PATH first would let a broken system npx shadow a healthy managed one, - so every candidate is validated with ``node_tool_runnable`` before use. - """ - extended_path = _merge_browser_path("") - for path in ([extended_path] if extended_path else []) + [None]: - npx = shutil.which("npx", path=path) - if npx and node_tool_runnable(npx): - return npx - return None - - -def _agent_browser_candidates(extended_path: str): - """Yield agent-browser lookup candidates lazily: ambient PATH → extended PATH → repo-local node_modules/.bin. - - The local lookup uses ``shutil.which`` with an explicit path so Windows resolves the ``.cmd`` shim - (CreateProcess cannot run npm's extensionless POSIX shim — WinError 193). - """ - yield shutil.which("agent-browser") - if extended_path: - yield shutil.which("agent-browser", path=extended_path) - local_bin_dir = Path(__file__).parent.parent / "node_modules" / ".bin" - if local_bin_dir.is_dir(): - yield shutil.which("agent-browser", path=str(local_bin_dir)) + return os.path.isfile(path) and (os.name == "nt" or os.access(path, os.X_OK)) def _find_agent_browser(*, validate: bool = True) -> str: - """Find the agent-browser CLI: PATH, Homebrew/managed dirs, local node_modules/.bin, npx fallback, lazy install. + """Select PM's exact binary, then an external PATH/Homebrew installation. - A bare ``shutil.which`` hit is NOT trusted: agent-browser's npm postinstall re-points a global symlink at our - local node_modules binary, which vanishes on the next ``hermes update`` and leaves a dangling link ``which`` - still reports (exec fails with 127). Candidates are validated with ``agent_browser_runnable`` before caching - so a dead one falls through. ``validate=False`` (schema-time check_fn) only tests presence and never caches. - Raises FileNotFoundError when agent-browser is not installed. + Termux owns its browser installation. Elsewhere PM may acquire a missing + CLI at execution time, subject to its lazy-install policy. Readiness checks + (``validate=False``) never execute or install anything. Selection is not + cached: a new PM fact or profile must be visible immediately. """ - _bt = _origin() + import pm - def _not_found(cached: bool) -> FileNotFoundError: - return FileNotFoundError(f"agent-browser CLI not found{' (cached)' if cached else ''}. Install it with: " - f"{_browser_install_hint()}\nOr ensure npx is available in your PATH.") - - def _accept(candidate: str) -> str: - # Set resolved at each accept site (not before the search) so a concurrent reader never sees - # resolved=True with a None cache. - if validate: - _bt._cached_agent_browser = candidate - _bt._agent_browser_resolved = True - return candidate - - if _bt._agent_browser_resolved: - if _bt._cached_agent_browser is None: - raise _not_found(cached=True) - return _bt._cached_agent_browser - ok = agent_browser_runnable if validate else _agent_browser_candidate_present - extended_path = _merge_browser_path("") - for candidate in _agent_browser_candidates(extended_path): - if candidate and ok(candidate): - return _accept(candidate) - # npx fallback (also searches the extended PATH) - if _resolve_npx_bin(): - return _accept(_bt.NPX_AGENT_BROWSER_SENTINEL) - if not validate: - raise FileNotFoundError("agent-browser CLI not found") - try: # Nothing found — try lazy installation before giving up. - from hermes_cli.dep_ensure import ensure_dependency - if ensure_dependency("browser"): - home = get_hermes_home() - managed = (home / "node_modules" / ".bin", home / "node" / "bin", home / "node") - for path in (None, *([extended_path] if extended_path else []), *map(str, managed)): - recheck = shutil.which("agent-browser", path=path) - if recheck and agent_browser_runnable(recheck): - return _accept(recheck) - except Exception: - pass - _bt._agent_browser_resolved = True - raise _not_found(cached=False) + termux = _is_termux_environment() + if not termux: + installed = pm.installed_package("agent-browser") + if installed and installed.binary is not None: + return str(installed.binary) + usable = agent_browser_runnable if validate else _agent_browser_candidate_present + for search_path in (None, _merge_browser_path("")): + if search_path == "": + continue + candidate = shutil.which("agent-browser", path=search_path) + if candidate and usable(candidate): + return candidate + hint = f"agent-browser CLI not found. Install it with: {_browser_install_hint()}" + if validate and not termux: + try: + pm.ensure("agent-browser") + except (pm.InstallError, OSError) as exc: + raise FileNotFoundError(f"{hint}\n{exc}") from exc + installed = pm.installed_package("agent-browser") + if installed and installed.binary is not None: + return str(installed.binary) + raise FileNotFoundError(hint) def warm_agent_browser_npx_cache(timeout: float = 60.0) -> bool: - """Best-effort pre-fetch of the agent-browser npm package via npx (``hermes update`` / ``doctor --fix``). - - Runs with the credential-scrubbed env every other agent-browser spawn uses (registry-fetched npm code must - never see the operator keyring), in its own process group, and tree-kills on timeout so a surviving - descendant cannot hold the capture pipe open. Never raises; True only when npx exited 0. - - agent-browser is no longer a root package.json dependency (#43564) — it resolves lazily via ``npx - agent-browser`` instead, which keeps it out of the npm workspace install graph entirely (nothing to - prune it anymore) but means the first real invocation in a session would otherwise pay npx's - registry-lookup/fetch cost. Calling this during ``hermes update`` (or ``hermes doctor --fix``) warms - npx's own cache ahead of time, restoring the "available before any session starts" property - agent-browser had while it was an eager root dependency — without re-entangling it with the workspace - graph. - """ - _bt = _origin() - npx_bin = _resolve_npx_bin() - if not npx_bin: - return False - env = _bt._build_browser_env() - env["PATH"] = _merge_browser_path(env.get("PATH", "")) - popen_kwargs: dict = {"stdout": subprocess.PIPE, "stderr": subprocess.PIPE, "text": True, "env": env} - if os.name == "posix": - popen_kwargs.update(creationflags=windows_hide_flags(), start_new_session=True) - else: - popen_kwargs["creationflags"] = windows_hide_flags() | getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) - # --ignore-scripts: AGENT_BROWSER_NPX_SPEC is a floating range; a compromised future patch must not run - # install-time lifecycle scripts here. --prefer-offline: once cached, repeat runs must not re-hit the registry. - cmd = [npx_bin, "--ignore-scripts", "--prefer-offline", "-y", _bt.AGENT_BROWSER_NPX_SPEC, "--version"] - try: - proc = subprocess.Popen(cmd, stdin=subprocess.DEVNULL, **popen_kwargs) - except Exception: - return False - try: - proc.communicate(timeout=timeout) - return proc.returncode == 0 - except Exception as exc: - _lifecycle._kill_process_tree(proc) - if isinstance(exc, subprocess.TimeoutExpired): - with contextlib.suppress(Exception): - proc.communicate(timeout=5) - return False - - -def _chromium_search_roots() -> List[str]: - """Full Chromium scan roots: ``PLAYWRIGHT_BROWSERS_PATH``, then the per-OS default cache.""" - env_path = os.environ.get("PLAYWRIGHT_BROWSERS_PATH", "").strip() - home = os.path.expanduser("~") - roots: List[str] = [env_path] if env_path and env_path != "0" else [] - roots.append(os.path.join(home, ".cache", "ms-playwright")) - if sys.platform == "darwin": - roots.append(os.path.join(home, "Library", "Caches", "ms-playwright")) - if sys.platform == "win32": - local = os.environ.get("LOCALAPPDATA") or os.path.join(home, "AppData", "Local") - roots.append(os.path.join(local, "ms-playwright")) - return roots - - -def _has_chromium_build(root: str) -> bool: - """True when ``root`` holds a full Playwright ``chromium-*`` build.""" - try: - return any(e.startswith("chromium-") for e in os.listdir(root)) - except OSError: - return False + """Historical updater export: no npx work is performed; relaunch instead.""" + return False def _chromium_installed() -> bool: - """True when a full Chromium build is on disk; cached. - - Checks ``AGENT_BROWSER_EXECUTABLE_PATH``, then the provisioned Playwright cache. - Without a binary the CLI hangs on first use until the command timeout fires, so the tool must not be advertised. - """ - _bt = _origin() - if _bt._cached_chromium_installed is not None: - return _bt._cached_chromium_installed + """An explicit browser executable or PM's selected full Chromium exists.""" from hermes_cli.browser_runtime import chromium_executable ab_path = chromium_executable() - _bt._cached_chromium_installed = bool( - (ab_path and (os.path.isfile(ab_path) or shutil.which(ab_path))) - or any(root and os.path.isdir(root) and _has_chromium_build(root) for root in _chromium_search_roots()) - ) - return _bt._cached_chromium_installed + return bool(ab_path and (os.path.isfile(ab_path) or shutil.which(ab_path))) def _maybe_autoinstall_chromium() -> bool: @@ -252,7 +113,7 @@ def _maybe_autoinstall_chromium() -> bool: if _bt._chromium_autoinstall_attempted: return _chromium_installed() _bt._chromium_autoinstall_attempted = True - if _running_in_docker(): + if _running_in_docker() or _is_termux_environment() or os.environ.get("AGENT_BROWSER_EXECUTABLE_PATH"): return False from pm import InstallError, ensure, lazy_installs_allowed if not lazy_installs_allowed(): @@ -263,7 +124,6 @@ def _maybe_autoinstall_chromium() -> bool: except (InstallError, OSError) as exc: _bt.logger.warning("browser: Chromium auto-install failed: %s", exc) return False - _bt._cached_chromium_installed = None return _chromium_installed() @@ -296,12 +156,10 @@ def check_browser_requirements() -> bool: return True # Do not exec ``agent-browser --version`` here: Windows .cmd shims flash a console during Desktop startup. Execution paths still validate. try: - browser_cmd = _find_agent_browser(validate=False) + _find_agent_browser(validate=False) except FileNotFoundError: return False - # Termux: the bare npx fallback is too fragile to advertise as a satisfied local dependency. - if _requires_real_termux_browser_install(browser_cmd): - return False + # Cloud mode also requires provider credentials; no local Chromium needed. provider = _cloud._get_cloud_provider() if provider is not None: diff --git a/tools/browser_tool_lifecycle.py b/tools/browser_tool_lifecycle.py index d56bc8b8f1..97444a50ae 100644 --- a/tools/browser_tool_lifecycle.py +++ b/tools/browser_tool_lifecycle.py @@ -688,13 +688,12 @@ def cleanup_all_browsers() -> None: pass _install._discover_homebrew_node_dirs.cache_clear() + _bt._chromium_autoinstall_attempted = False # Each resolved flag flips BEFORE its cache is nulled so a concurrent reader never # sees ``resolved=True`` with ``cache=None``. for flag, cache in ( - ("_agent_browser_resolved", "_cached_agent_browser"), ("_command_timeout_resolved", "_cached_command_timeout"), ("_snapshot_threshold_resolved", "_cached_snapshot_threshold"), - ("_chromium_autoinstall_attempted", "_cached_chromium_installed"), ("_browser_engine_resolved", "_cached_browser_engine"), ): setattr(_bt, flag, False) diff --git a/tools/browser_tool_session.py b/tools/browser_tool_session.py index 1dd189d9ad..b141226257 100644 --- a/tools/browser_tool_session.py +++ b/tools/browser_tool_session.py @@ -95,16 +95,7 @@ def _format_browser_timeout_error( def _agent_browser_argv(browser_cmd: str) -> list: - """Command prefix to invoke agent-browser (concrete binary, or the npx sentinel expanded). - - npx is resolved through the same PATH cascade as ``_find_agent_browser`` (a bare - ``which("npx")`` would let a broken system npx shadow a healthy managed one); if - absent the bare name gives a readable ``FileNotFoundError``. ``--ignore-scripts``: - the spec is a floating range — a compromised future patch must not run install scripts. - """ - if _install._is_npx_agent_browser_sentinel(browser_cmd): - _npx_bin = _install._resolve_npx_bin() or "npx" - return [_npx_bin, "--ignore-scripts", "--prefer-offline", "-y", _bt.AGENT_BROWSER_NPX_SPEC] + """Keep the selected executable, including spaces, as one argv entry.""" return [browser_cmd] @@ -122,13 +113,16 @@ def _agent_browser_command_env(socket_dir: str) -> Dict[str, str]: """Credential-scrubbed env for one command: PATH fallbacks, the session socket dir, and daemon-side idle self-termination (agent-browser 0.24+) mirroring the Python janitor unless the user set ``AGENT_BROWSER_IDLE_TIMEOUT_MS`` explicitly.""" + from pm import env_for + env = _bt._build_browser_env() + env["PATH"] = _install._merge_browser_path(env.get("PATH", "")) + env = env_for("agent-browser", base_env=env) from hermes_cli.browser_runtime import chromium_executable executable = chromium_executable() if executable: env["AGENT_BROWSER_EXECUTABLE_PATH"] = executable - env["PATH"] = _install._merge_browser_path(env.get("PATH", "")) env["AGENT_BROWSER_SOCKET_DIR"] = socket_dir if "AGENT_BROWSER_IDLE_TIMEOUT_MS" not in env: env["AGENT_BROWSER_IDLE_TIMEOUT_MS"] = str(_bt.BROWSER_SESSION_INACTIVITY_TIMEOUT * 1000) @@ -487,11 +481,6 @@ def _browser_command_preflight() -> Dict[str, Any]: _bt.logger.warning("agent-browser CLI not found: %s", e) return {"success": False, "error": str(e)} - if _install._requires_real_termux_browser_install(browser_cmd): - error = _install._termux_browser_install_error() - _bt.logger.warning("browser command blocked on Termux: %s", error) - return {"success": False, "error": error} - # Skip when engine=lightpanda — LP doesn't need Chromium for navigation. if ( _cloud._is_local_mode() diff --git a/tools/computer_use/cua_backend.py b/tools/computer_use/cua_backend.py index 4d2d00b4ec..59cfc13021 100644 --- a/tools/computer_use/cua_backend.py +++ b/tools/computer_use/cua_backend.py @@ -2,7 +2,7 @@ background loop (``cua_backend_session``); the same tool surface works on all three platforms, and per-host gaps (no DISPLAY, missing AT-SPI, TCC) surface via `hermes computer-use doctor` instead of failing silently. Install with `hermes computer-use install`. The macOS path uses private SkyLight SPIs that can break on OS updates. -Siblings: ``cua_backend_driver`` (binary/contract/update), ``cua_backend_capture`` + ``cua_backend_input`` +Siblings: ``cua_backend_driver`` (binary/contract), ``cua_backend_capture`` + ``cua_backend_input`` (mixins), ``cua_backend_parse``, ``cua_backend_session`` (bridge + session + CLI fallback), ``cua_backend_daemon`` (private daemon + macOS app identity). Siblings look this module's config/policy helpers up lazily.""" @@ -14,7 +14,6 @@ import logging import os import subprocess import sys -import threading import uuid from pathlib import PurePosixPath, PureWindowsPath from typing import Any, Dict, List, Optional, Tuple @@ -24,7 +23,7 @@ from tools.computer_use.backend import ActionResult, ComputerUseBackend from tools.computer_use.cua_backend_capture import _CaptureMixin from tools.computer_use.cua_backend_daemon import _EmbeddedCuaDaemon from tools.computer_use.cua_backend_driver import ( - _CUA_DRIVER_CMD_ENV, cua_driver_binary_available, cua_driver_runtime_contract_status, cua_driver_update_nudge, + _CUA_DRIVER_CMD_ENV, cua_driver_binary_available, cua_driver_runtime_contract_status, resolve_cua_driver_cmd) from tools.computer_use.cua_backend_input import _InputMixin from tools.computer_use.cua_backend_parse import _action_result_from @@ -190,48 +189,6 @@ def _empty_discovery_reason() -> str: "panel asleep) — wake the display or attach a monitor/HDMI dummy, then run `hermes computer-use doctor`") return "window discovery returned no windows; run `hermes computer-use doctor` (display reachability, AX capability)" -_update_checked = False -# One auto-repair attempt per process: when the runtime-contract gate fails for something a reinstall fixes -# (old version, missing manifest verbs) run the standard install path once instead of telling the user to. -# Guarded so a failing installer can't loop — the second start() goes straight to the error. -_contract_repair_attempted = False - -def _maybe_repair_runtime_contract(contract: Dict[str, Any]) -> Dict[str, Any]: - """Try one automatic driver repair; return the post-repair contract (or the original when no repair was - attempted / it failed). Never raises. An explicit ``HERMES_CUA_DRIVER_CMD`` override is authoritative even - when broken, and a missing binary means installation was never requested.""" - global _contract_repair_attempted - if contract.get("ready") or _contract_repair_attempted or os.environ.get(_CUA_DRIVER_CMD_ENV, "").strip() or not contract.get("binary"): - return contract - _contract_repair_attempted = True - logger.info("computer_use: installed cua-driver is not usable (%s); attempting automatic repair", - contract.get("reason") or "runtime contract is incomplete") - try: - from hermes_cli.tools_config import install_cua_driver - repaired = install_cua_driver(upgrade=False, show_installer_progress=False) - except Exception as exc: - logger.warning("computer_use: automatic cua-driver repair failed: %s", exc) - return contract - with contextlib.suppress(Exception): - return cua_driver_runtime_contract_status() if repaired else contract - return contract - -def _maybe_nudge_update() -> None: - """Emit an update nudge at most once per process, off-thread so the (cached, ~20h) GitHub poll never blocks - the first computer_use action.""" - global _update_checked - if _update_checked: - return - _update_checked = True - - def _run() -> None: - with contextlib.suppress(Exception): - msg = cua_driver_update_nudge() - msg and logger.info("computer_use: %s", msg) - - threading.Thread(target=_run, name="cua-driver-update-check", daemon=True).start() - - class CuaDriverBackend(_CaptureMixin, _InputMixin, ComputerUseBackend): """Default computer-use backend. Cross-platform via cua-driver MCP.""" @@ -244,8 +201,9 @@ class CuaDriverBackend(_CaptureMixin, _InputMixin, ComputerUseBackend): # Manifest: mandatory for bounded (the daemon validates it), optional for unrestricted where it still # caps what an approval-bypassed run may touch. raw = _computer_use_cfg().get("capability_manifest") + # Resolve at daemon launch, after start() reconciles the PM pin. self._embedded_daemon = _EmbeddedCuaDaemon( - resolve_cua_driver_cmd() or "", permission_mode, + "", permission_mode, capability_manifest=raw.strip() if isinstance(raw, str) and raw.strip() else None) self._bridge = _AsyncBridge() self._session = _CuaDriverSession(self._bridge, self._embedded_daemon) @@ -264,14 +222,17 @@ class CuaDriverBackend(_CaptureMixin, _InputMixin, ComputerUseBackend): self._clear_active_target() def start(self) -> None: + # Runtime acquisition is on-demand, never the explicit install command + # (which may elevate for host setup and bypass the lazy-install gate). + if not os.environ.get(_CUA_DRIVER_CMD_ENV, "").strip(): + from pm import ensure + ensure("cua-driver") contract = cua_driver_runtime_contract_status() - if not contract.get("ready"): - contract = _maybe_repair_runtime_contract(contract) if not contract.get("ready"): raise RuntimeError(f"cua-driver is not ready: {contract.get('reason') or 'runtime contract is incomplete'}. " + ("Update the binary selected by HERMES_CUA_DRIVER_CMD or remove that override." if os.environ.get(_CUA_DRIVER_CMD_ENV, "").strip() else "Run `hermes computer-use install` to repair it.")) - _maybe_nudge_update() + # The MCP client SDK (`mcp`) is an optional dependency (the # `computer-use` / `mcp` extras), not part of Hermes' minimal core. # Lazy-install it on first use — the same pattern every other optional diff --git a/tools/computer_use/cua_backend_daemon.py b/tools/computer_use/cua_backend_daemon.py index 3f9bab5dee..59f476b459 100644 --- a/tools/computer_use/cua_backend_daemon.py +++ b/tools/computer_use/cua_backend_daemon.py @@ -159,10 +159,12 @@ class _EmbeddedCuaDaemon: def start(self) -> None: if self._running: return - self._driver_cmd = self._driver_cmd or _driver.resolve_cua_driver_cmd() or "" - if not self._driver_cmd: + # Keep an explicit command fixed, but reselect managed drivers on every + # launch: PM may have acquired a new pin since construction or last stop. + driver_cmd = self._driver_cmd or _driver.resolve_cua_driver_cmd() + if not driver_cmd: raise RuntimeError(_driver.cua_driver_install_hint()) - self._command, self._mcp_args = _driver._resolve_mcp_invocation(self._driver_cmd) + self._command, self._mcp_args = _driver._resolve_mcp_invocation(driver_cmd) env = self._sanitized_env() command = _embedded_daemon_spawn_command(self._command, self._serve_args(), platform=sys.platform) self._process = subprocess.Popen(command, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, diff --git a/tools/computer_use/cua_backend_driver.py b/tools/computer_use/cua_backend_driver.py index faeb297ea2..6e11a07bf6 100644 --- a/tools/computer_use/cua_backend_driver.py +++ b/tools/computer_use/cua_backend_driver.py @@ -1,4 +1,4 @@ -"""cua-driver binary resolution, MCP-invocation discovery, the 0.20 runtime contract gate, and the update check. +"""PM-backed cua-driver selection, MCP discovery and the runtime contract gate. Config-derived policy (``_cua_no_overlay``, ``_run_driver`` ...) is looked up lazily through the facade.""" from __future__ import annotations @@ -16,8 +16,7 @@ from typing import Any, Dict, List, Optional, Tuple logger = logging.getLogger("tools.computer_use.cua_backend") -# No version *pin* knob on purpose: the upstream installer always fetches the latest release, so a pin -# var would only LOOK like it pinned. Point HERMES_CUA_DRIVER_CMD at a specific binary instead. +# PM owns the pinned binary; an explicit override remains externally owned. _CUA_DRIVER_CMD_ENV = "HERMES_CUA_DRIVER_CMD" _CUA_DRIVER_DEFAULT_CMD = "cua-driver" _CUA_DRIVER_ARGS = ["mcp"] # stdio MCP; fallback when the driver has no `manifest` verb @@ -28,19 +27,19 @@ _CUA_DRIVER_RUNTIME_CONTRACT_ARGS = { # key order feeds the "manifest is missin "stop": {"--socket"}, } _SEMVER_RE = re.compile(r"v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?") -_UPSTREAM_SCRIPTS = "https://raw.githubusercontent.com/trycua/cua/main/libs/cua-driver/scripts" + def _cb(): """Facade module (config/policy helpers), looked up lazily to avoid the import cycle.""" from tools.computer_use import cua_backend return cua_backend -def _driver_json(driver_cmd: str, *args: str, timeout: float, require_ok: bool) -> Optional[Dict[str, Any]]: +def _driver_json(driver_cmd: str, *args: str, timeout: float) -> Optional[Dict[str, Any]]: """Run a driver verb and parse its stdout as a JSON object; None on spawn failure, empty stdout (older drivers - print usage to stderr), unparseable or non-object output — and, with ``require_ok``, on a non-zero exit.""" + print usage to stderr), unparseable or non-object output, or a non-zero exit.""" proc = _cb()._run_driver(driver_cmd, *args, timeout=timeout, swallow=Exception) out = (proc.stdout or "").strip() if proc is not None else "" - return None if not out or (require_ok and proc.returncode != 0) else _json_object(out) + return None if proc is None or not out or proc.returncode != 0 else _json_object(out) def _json_object(text: str) -> Optional[Dict[str, Any]]: """``json.loads`` that yields a dict or None (unparseable / non-object).""" @@ -73,39 +72,24 @@ def _wsl_windows_path_to_posix(path: str) -> str: drive = (win.drive or "").rstrip(":").lower() return "/".join(["/mnt", drive, *win.parts[1:]]) if wsl and drive else path -def _candidate_cua_driver_commands(override: Optional[str] = None) -> List[str]: - """Candidate commands in resolution order. ``override`` / a non-empty ``HERMES_CUA_DRIVER_CMD`` is authoritative - (if wrong, report the driver missing rather than silently picking another binary). Otherwise PATH, then - canonical installer locations — Finder/Dock-launched apps inherit a narrow PATH without ``~/.local/bin``; - fresh Windows sessions inherit a stale one.""" +def resolve_cua_driver_cmd(override: Optional[str] = None) -> Optional[str]: + """Read PM's selected binary without installing; never replace an explicit override.""" configured = (override if override is not None else os.environ.get(_CUA_DRIVER_CMD_ENV, "")).strip() if configured: - return [configured] - home = os.path.expanduser("~") - if sys.platform == "win32": - local_app_data = os.environ.get("LOCALAPPDATA") or os.path.join(home, "AppData", "Local") - return [_CUA_DRIVER_DEFAULT_CMD, os.path.join(local_app_data, "Programs", "Cua", "cua-driver", "bin", "cua-driver.exe"), - os.path.join(home, ".local", "bin", "cua-driver.exe"), os.path.join(home, ".local", "bin", "cua-driver")] - return [_CUA_DRIVER_DEFAULT_CMD, os.path.join(home, ".local", "bin", "cua-driver"), - os.path.join(home, ".cargo", "bin", "cua-driver"), "/opt/homebrew/bin/cua-driver", "/usr/local/bin/cua-driver"] - -def resolve_cua_driver_cmd(override: Optional[str] = None) -> Optional[str]: - """Resolve the cua-driver executable for every runtime/status surface; an override is never silently replaced.""" - for expanded in map(os.path.expanduser, _candidate_cua_driver_commands(override)): + expanded = os.path.expanduser(configured) resolved = shutil.which(expanded) - if resolved: - return expanded if _has_path_separator(expanded) else resolved - return None + return expanded if resolved and _has_path_separator(expanded) else resolved + from pm import installed_package + + installed = installed_package("cua-driver") + return str(installed.binary) if installed and installed.binary else None def cua_driver_binary_available() -> bool: - """True if `cua-driver` resolves via env, PATH, or known install paths.""" + """True if PM or an explicit override selects a local driver.""" return resolve_cua_driver_cmd() is not None def cua_driver_install_hint() -> str: - installer = (f" irm {_UPSTREAM_SCRIPTS}/install.ps1 | iex" if sys.platform == "win32" - else f' /bin/bash -c "$(curl -fsSL {_UPSTREAM_SCRIPTS}/install.sh)"') - return ("cua-driver is not installed. Install with one of:\n hermes computer-use install\n" - f"Or run the upstream installer directly:\n{installer}\n" + return ("cua-driver is not installed. Install the pinned driver with:\n hermes computer-use install\n" "Or run `hermes tools` and enable the Computer Use toolset to install it automatically.") def _mcp_args_with_overlay_flag(args: List[str], driver_cmd: str = _CUA_DRIVER_DEFAULT_CMD) -> List[str]: @@ -138,7 +122,7 @@ def _resolve_mcp_invocation(driver_cmd: str, *, timeout: float = 6.0) -> Tuple[s indefinitely when idle (#28152, #47032). Older drivers that don't recognise the flag will reject it; callers should fall back to the no-overlay invocation on spawn failure. """ - manifest = _driver_json(driver_cmd, "manifest", timeout=timeout, require_ok=True) or {} + manifest = _driver_json(driver_cmd, "manifest", timeout=timeout) or {} invocation = manifest.get("mcp_invocation") args = _valid_mcp_args(invocation) command = invocation.get("command") if args is not None and isinstance(invocation, dict) else None @@ -192,24 +176,5 @@ def cua_driver_runtime_contract_status(binary: Optional[str] = None) -> Dict[str return {"ready": not reason, "binary": resolved, "version": version, "reason": reason} def cua_driver_update_check(*, timeout: Optional[float] = None) -> Optional[Dict[str, Any]]: - """cua-driver's native ``check-update`` verb compares the installed binary against the latest GitHub release - (cached ~20h); we prefer it over a hardcoded floor. Returns the ``check-update --json`` payload (``{current_version, latest_version, update_available, ...}``), - or ``None`` when the binary is missing, the driver predates the verb, the GitHub check failed (``error`` set) - or the output didn't parse. Never raises. ``timeout`` defaults to 8s on POSIX / 25s on Windows: first spawn of - the exe routinely eats seconds in Defender scanning, and callers treat ``None`` as indeterminate (the upgrade - path used to fall through to a full reinstall on a false timeout). - - See #1734. - """ - timeout = (25.0 if sys.platform == "win32" else 8.0) if timeout is None else timeout - driver_cmd = resolve_cua_driver_cmd() - data = _driver_json(driver_cmd, "check-update", "--json", timeout=timeout, require_ok=False) if driver_cmd else None - return None if data is None or data.get("error") else data - -def cua_driver_update_nudge() -> Optional[str]: - """One-line "an update is available" message, or ``None`` when up to date, indeterminate, or driver too old.""" - state = cua_driver_update_check() - if not state or not state.get("update_available"): - return None - return (f"cua-driver {state.get('latest_version') or '?'} is available " - f"(you have {state.get('current_version') or '?'}); update with `hermes computer-use install --upgrade`.") + """Historical import: upstream release polling is retired; PM owns the pin.""" + return None diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index c1e0f55797..dbb8e613a2 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1714,6 +1714,7 @@ export interface MemoryProviderExternalDependency { export interface MemoryProviderSetupInfo { pip_dependencies: string[]; + python_dependencies_declared?: boolean; external_dependencies: MemoryProviderExternalDependency[]; required_env: string[]; dependencies_installed: boolean; diff --git a/web/src/lib/memory-provider-setup.ts b/web/src/lib/memory-provider-setup.ts new file mode 100644 index 0000000000..d6eeb619e4 --- /dev/null +++ b/web/src/lib/memory-provider-setup.ts @@ -0,0 +1,22 @@ +import type { MemoryProviderSetupInfo } from "@/lib/api"; + +export function setupHasDetails(setup?: MemoryProviderSetupInfo): boolean { + return Boolean( + setup && ( + setup.external_dependencies?.length || + setup.python_dependencies_declared || + setup.pip_dependencies?.length || + setup.required_env?.length + ), + ); +} + +export function setupHasInstallableSteps(setup?: MemoryProviderSetupInfo): boolean { + return Boolean( + setup && ( + setup.external_dependencies?.some((dep) => dep.install) || + setup.python_dependencies_declared || + setup.pip_dependencies?.length + ), + ); +} diff --git a/web/src/pages/PluginsPage.test.ts b/web/src/pages/PluginsPage.test.ts new file mode 100644 index 0000000000..05c74579d9 --- /dev/null +++ b/web/src/pages/PluginsPage.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import type { MemoryProviderSetupInfo } from "@/lib/api"; +import { setupHasDetails, setupHasInstallableSteps } from "@/lib/memory-provider-setup"; + +describe("memory provider preparation", (): void => { + it("offers preparation for a PM member without legacy pip declarations", (): void => { + const setup: MemoryProviderSetupInfo = { + pip_dependencies: [], + python_dependencies_declared: true, + external_dependencies: [], + required_env: [], + dependencies_installed: false, + }; + expect(setupHasDetails(setup)).toBe(true); + expect(setupHasInstallableSteps(setup)).toBe(true); + expect(setupHasInstallableSteps({ ...setup, python_dependencies_declared: false })).toBe(false); + }); +}); \ No newline at end of file diff --git a/web/src/pages/PluginsPage.tsx b/web/src/pages/PluginsPage.tsx index 652f3d1681..76440b30ad 100644 --- a/web/src/pages/PluginsPage.tsx +++ b/web/src/pages/PluginsPage.tsx @@ -3,6 +3,7 @@ import { ExternalLink, RefreshCw, Trash2, Eye, EyeOff } from "lucide-react"; import type { Translations } from "@/i18n/types"; import { Link } from "react-router"; import { api } from "@/lib/api"; +import { setupHasDetails, setupHasInstallableSteps } from "@/lib/memory-provider-setup"; import type { CatalogEntry, CatalogRemovedEntry, @@ -11,7 +12,6 @@ import type { MemoryProviderConfig, MemoryProviderField, MemoryProviderInfo, - MemoryProviderSetupInfo, MemoryProviderSetupResult, PluginsHubResponse, } from "@/lib/api"; @@ -65,23 +65,6 @@ function fieldIsVisible(field: MemoryProviderField, values: Record dep.install) || - setup.pip_dependencies?.length, - ); -} - function SetupCommandBlock({ code, label }: { code: string; label: string }) { return (
diff --git a/website/docs/developer-guide/adding-platform-adapters.md b/website/docs/developer-guide/adding-platform-adapters.md index 04b2b011ed..04ddde07d0 100644 --- a/website/docs/developer-guide/adding-platform-adapters.md +++ b/website/docs/developer-guide/adding-platform-adapters.md @@ -158,7 +158,7 @@ def register(ctx): # ensure_deps_fn=ensure_requirements, validate_config=validate_config, required_env=["MY_PLATFORM_TOKEN"], - install_hint="pip install my-platform-sdk", + install_hint="Declare my-platform-sdk in this plugin's Python dependencies, then retry hermes plugins enable my-platform", # Env-driven auto-configuration — seeds PlatformConfig.extra from # env vars before adapter construction. See "Env-Driven Auto- # Configuration" section below. diff --git a/website/docs/developer-guide/adding-providers.md b/website/docs/developer-guide/adding-providers.md index 5d11383b69..24259c7f4b 100644 --- a/website/docs/developer-guide/adding-providers.md +++ b/website/docs/developer-guide/adding-providers.md @@ -343,35 +343,36 @@ For docs-only examples, the exact file set may differ. The point is to cover: - provider:model parsing - any adapter-specific message conversion -Run the targeted tests (or use `scripts/run_tests.sh`, which runs each file in its own subprocess): +Prepare the [independent test environment](/developer-guide/contributing#manual-development-and-test-environment), +then use the canonical runner, which isolates each file and scrubs credentials: ```bash -source venv/bin/activate -python -m pytest tests/hermes_cli/test_runtime_provider_resolution.py tests/cli/test_cli_provider_resolution.py tests/hermes_cli/test_setup_model_provider.py tests/run_agent/test_provider_parity.py -q +scripts/run_tests.sh tests/hermes_cli/test_runtime_provider_resolution.py tests/cli/test_cli_provider_resolution.py tests/hermes_cli/test_setup_model_provider.py tests/run_agent/test_provider_parity.py -q ``` For deeper changes, run the full suite before pushing: ```bash -source venv/bin/activate -python -m pytest tests/ -n0 -q +scripts/run_tests.sh tests/ -q ``` ## Step 9: Live verification -After tests, run a real smoke test. +After tests, run a real smoke test from the checkout using the +[PM developer workflow](/reference/package-management#developer-workflow) and +its isolated development home. Leave any test venv before PM activation. ```bash -source venv/bin/activate -python -m hermes_cli.main chat -q "Say hello" --provider your-provider --model your-model +source ./activate +python hermes chat -q "Say hello" --provider your-provider --model your-model ``` Also test the interactive flows if you changed menus: ```bash -source venv/bin/activate -python -m hermes_cli.main model -python -m hermes_cli.main setup +source ./activate +python hermes model +python hermes setup ``` For native providers, verify at least one tool call too, not just a plain text response. diff --git a/website/docs/developer-guide/extending-the-cli.md b/website/docs/developer-guide/extending-the-cli.md index c1a2cafd30..9d54ab0bf9 100644 --- a/website/docs/developer-guide/extending-the-cli.md +++ b/website/docs/developer-guide/extending-the-cli.md @@ -73,11 +73,11 @@ if __name__ == "__main__": cli.run() ``` -Run it: +Run it from your source checkout after selecting an isolated development home +with the [PM developer workflow](/reference/package-management#developer-workflow): ```bash -cd ~/.hermes/hermes-agent -source .venv/bin/activate +source ./activate python my_cli.py ``` diff --git a/website/docs/developer-guide/memory-provider-plugin.md b/website/docs/developer-guide/memory-provider-plugin.md index 99bef87a1f..12264800c2 100644 --- a/website/docs/developer-guide/memory-provider-plugin.md +++ b/website/docs/developer-guide/memory-provider-plugin.md @@ -21,7 +21,7 @@ Hermes discovers memory providers from four sources, in this precedence order: | Bundled | `plugins/memory//` | Ships with Hermes. Closed to new providers — see [CONTRIBUTING](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md). | | User | `$HERMES_HOME/plugins//` | Dropped in by the user, per profile. | | Project | `./.hermes/plugins//` | Opt-in via `HERMES_ENABLE_PROJECT_PLUGINS=1`. | -| Package | `hermes_agent.memory_providers` entry point | `pip install`, nothing to copy. | +| Package | `hermes_agent.memory_providers` entry point | Distribution supplied by the installation owner; nothing to copy. | Earlier sources win on a name collision, so a directory dropped into a working tree can never shadow a shipped provider. @@ -35,6 +35,20 @@ silently redirect the agent's memory rather than merely override a tool. Discovery only *enumerates* — it never imports a provider. Nothing runs until `memory.provider` names it. +Entry-point discovery does not install packages. Do not inject a provider into +Hermes's selected environment with pip. On PM-managed installations, ship a +directory provider with declared Python dependencies; plugin admission and +`hermes memory setup` prepare them through PM before use. Owner-managed builds +(such as Nix) can include an entry-point distribution declaratively. + +CLI and dashboard setup share candidate preparation. PM includes the provider's +`pyproject.toml` or legacy `pip_dependencies` / `python_dependencies` alongside +the active plugin union; an importable module does not bypass declared version +constraints. Dashboard readiness checks the same inputs without installing +anything. A successful preparation may require restarting Hermes before the +running process can use the selected dependency generation. External sidecar +checks and setup commands remain separate from the Python union. + ### Directory Provider A directory provider lives in `plugins/memory//` when bundled with diff --git a/website/docs/developer-guide/plugins/index.md b/website/docs/developer-guide/plugins/index.md index 15be810ee9..ebc9ba9f3f 100644 --- a/website/docs/developer-guide/plugins/index.md +++ b/website/docs/developer-guide/plugins/index.md @@ -300,7 +300,7 @@ this Hermes understands still loads with a warning. | `manifest_version` | int | Manifest **file-format** version. Absent = `1`. Current max: `2`. Independent from `api_version`. | | `api_version` | int | Runtime **plugin API generation** the plugin targets (ctx surface / hook signatures). Deliberately a separate axis from `manifest_version` — an `api_version: 1` plugin can use a v2 manifest. | | `requires_plugins` | list | Inter-plugin dependencies: `- id: other-plugin` with optional `version_range: ">=1.0,<2"`. **Advisory**: a missing dependency logs a clear warning but the plugin still loads — probe at runtime with `ctx.has_plugin("other-plugin")`. Load **order** honors these edges: when A requires B, B's `register()` runs before A's (topological sort, alphabetical tiebreak; cycles warn and fall back to alphabetical order). | -| `python_dependencies` | list of str | Declared pip requirements (e.g. `"requests>=2.0,<3"`). **Declaration seam only** — Hermes validates them, and `hermes plugins install` / `hermes plugins doctor` surface missing ones with a `pip install` hint, but Hermes **never auto-installs** them. Pin upper bounds. | +| `python_dependencies` | list of str | Declared Python requirements (e.g. `"requests>=2.0,<3"`). Installation requests consent; enabling admits the candidate through PM with the existing core, extras, and enabled-plugin union. Successful preparation publishes the environment and configuration transactionally; failure preserves the previous selection and enabled set. Declining leaves the installed plugin disabled. Pin upper bounds. | | `config_schema` | mapping | JSON-schema-ish description of keys under `plugins.entries..settings`: `api_url: {type: str, default: "", description: "...", required: false}`. Validated at load; mismatches log actionable warnings naming the key and expected type — never load failures. Types: `str`, `int`, `float`, `bool`, `list`, `dict` (plus JSON-schema aliases). | | `license` | str | SPDX-style license id (e.g. `MIT`). | | `homepage` | str | Project URL. | @@ -1696,10 +1696,13 @@ For sharing plugins publicly, add an entry point to your Python package: my-plugin = "my_plugin_package" ``` -```bash -pip install hermes-plugin-calculator -# Plugin auto-discovered on next hermes startup -``` +Entry-point discovery remains supported when the distribution is present in the +environment supplied by the installation owner (for example, a Nix derivation). +It is discovery, not permission to inject packages into a PM-selected generation. +For managed installs, distribute a directory plugin with `pyproject.toml` or +manifest Python requirements and use `hermes plugins install` / `enable` so PM +can admit it transactionally. Restart Hermes after a new environment is selected. +`hermes pm install` accepts managed tool names, not arbitrary PyPI packages. ## Distribute for NixOS diff --git a/website/docs/guides/automation-blueprints.md b/website/docs/guides/automation-blueprints.md index 7bf861e8e2..092193d6ba 100644 --- a/website/docs/guides/automation-blueprints.md +++ b/website/docs/guides/automation-blueprints.md @@ -142,9 +142,9 @@ Daily scan for known vulnerabilities in project dependencies. hermes cron create "0 6 * * *" \ "Run a dependency security audit on the hermes-agent project. -1. cd ~/.hermes/hermes-agent && source .venv/bin/activate -2. Run: pip audit --format json 2>/dev/null || pip audit 2>&1 -3. Run: npm audit --json 2>/dev/null (in website/ directory if it exists) +1. Locate the hermes-agent checkout and its pyproject.toml and uv.lock. Do not activate or mutate Hermes's dependency environment. +2. Scan uv.lock with an independently installed scanner that supports that lock format (check its --help). Preserve the complete findings and errors. If no scanner is available, report the blocker; do not install one into Hermes. +3. Run: npm audit --json in website/ if it exists. Preserve stderr and distinguish findings from a failed scan. 4. Check for any CVEs with CVSS score >= 7.0 If vulnerabilities found: diff --git a/website/docs/user-guide/features/built-in-plugins.md b/website/docs/user-guide/features/built-in-plugins.md index 2114963402..fe26c4f329 100644 --- a/website/docs/user-guide/features/built-in-plugins.md +++ b/website/docs/user-guide/features/built-in-plugins.md @@ -151,16 +151,24 @@ The plugin is fail-open: no SDK installed, no credentials, or a transient Langfu hermes tools # → Langfuse Observability → Cloud or Self-Hosted ``` -The wizard collects your keys, `pip install`s the `langfuse` SDK, and adds `observability/langfuse` to `plugins.enabled` for you. Restart Hermes and the next turn ships a trace. +The wizard collects your keys, prepares the declared `langfuse` extra through PM +when needed, and enables `observability/langfuse`. Restart Hermes and the next +turn ships a trace. If preparation fails, retry through `hermes tools`; do not +install the SDK into the selected environment with pip. **Setup (manual):** +For a source checkout, first follow the [PM developer workflow](/reference/package-management#developer-workflow) +with the intended Hermes home. Use the checkout's prepared Python: + ```bash -pip install langfuse -hermes plugins enable observability/langfuse +python -c "import pm; pm.sync_venv(['langfuse'], explicit=True)" +source ./activate +python hermes plugins enable observability/langfuse ``` -Then put the credentials in `~/.hermes/.env`: +Use `. .\activate.ps1` for PowerShell activation. Then put the credentials in +the active home's `.env` (`$HERMES_HOME/.env`, normally `~/.hermes/.env`): ```bash HERMES_LANGFUSE_PUBLIC_KEY=pk-lf-... @@ -321,7 +329,7 @@ Bundled plugins are written exactly like any other Hermes plugin — see [Build A plugin is a good candidate for bundling when: -- It has no optional dependencies (or they're already `pip install .[all]` deps) +- It has no optional dependencies (or they are already in the declared `all` extra) - The behaviour benefits most users and is opt-out rather than opt-in - The logic ties into lifecycle hooks that the agent would otherwise have to remember to invoke - It complements a core capability without expanding the model-visible tool surface diff --git a/website/docs/user-guide/features/memory-providers.md b/website/docs/user-guide/features/memory-providers.md index 8e647c5548..29b9e9b436 100644 --- a/website/docs/user-guide/features/memory-providers.md +++ b/website/docs/user-guide/features/memory-providers.md @@ -47,7 +47,7 @@ AI-native cross-session user modeling with dialectic reasoning, session-scoped c | | | |---|---| | **Best for** | Multi-agent systems with cross-session context, user-agent alignment | -| **Requires** | `pip install honcho-ai` + [API key](https://app.honcho.dev) or self-hosted instance | +| **Requires** | `hermes memory setup` prepares the Honcho SDK through PM; [API key](https://app.honcho.dev) or self-hosted instance | | **Data storage** | Honcho Cloud or self-hosted | | **Cost** | Honcho pricing (cloud) / free (self-hosted) | @@ -356,7 +356,7 @@ Server-side LLM fact extraction with semantic search, reranking, and automatic d | | | |---|---| | **Best for** | Hands-off memory management — Mem0 handles extraction automatically | -| **Requires** | `pip install mem0ai` + API key (platform), a running Mem0 server (self-hosted dashboard), or an LLM + vector store (OSS) | +| **Requires** | `hermes memory setup` prepares the Mem0 SDK through PM; API key (platform), a running Mem0 server (self-hosted dashboard), or an LLM + vector store (OSS) | | **Data storage** | Mem0 Cloud (platform), your own Mem0 server (self-hosted dashboard), or in-process (OSS) | | **Cost** | Mem0 pricing (platform) / free (self-hosted or OSS) | @@ -583,7 +583,7 @@ Semantic long-term memory with profile recall, semantic search, explicit memory | | | |---|---| | **Best for** | Semantic recall with user profiling and session-level graph building | -| **Requires** | `pip install supermemory` + [cloud API key](http://app.supermemory.ai/integrations?connect=hermes), or a [self-hosted server](https://supermemory.ai/docs/self-hosting/overview) | +| **Requires** | `hermes memory setup` prepares the Supermemory SDK through PM; [cloud API key](http://app.supermemory.ai/integrations?connect=hermes), or a [self-hosted server](https://supermemory.ai/docs/self-hosting/overview) | | **Data storage** | Supermemory Cloud or self-hosted | | **Cost** | Supermemory pricing (cloud) / free (self-hosted) | @@ -666,20 +666,33 @@ Structured long-term memory using Memori Cloud, with background completed-turn c | | | |---|---| | **Best for** | Agent-controlled recall with structured project and session attribution | -| **Requires** | `pip install hermes-memori` + `hermes-memori install` + [Memori API key](https://app.memorilabs.ai/signup) | +| **Requires** | Externally supplied `hermes-memori` CLI and provider integration + [Memori API key](https://app.memorilabs.ai/signup) | | **Data storage** | Memori Cloud | | **Cost** | Memori pricing | **Tools:** `memori_recall` (search long-term memory), `memori_recall_summary` (summarized context), `memori_quota` (usage/quota), `memori_signup` (request signup email), `memori_feedback` (send integration feedback) **Setup:** + +`hermes-memori` is an external integration, not a managed PM tool name. Follow +its publisher's instructions to install the CLI in an independent environment. +Before running its installer, confirm that it targets the intended Hermes home +and supplies a provider with declared Python dependencies. Do not let an external +installer pip-install into Hermes's selected environment. CLI availability alone +does not make the Python provider available inside Hermes; an entry-point-only +distribution needs an owner-managed build that includes it. + ```bash -pip install hermes-memori +# Run only after confirming the external installer's integration contract above. hermes-memori install hermes config set memory.provider memori hermes memory setup ``` +If the installer does not support PM-managed directory-provider admission, ask +the publisher for that integration rather than inventing a `hermes pm install` +package command. Restart Hermes after successful dependency preparation. + --- ## Provider Comparison diff --git a/website/docs/user-guide/features/web-search.md b/website/docs/user-guide/features/web-search.md index 37f21de9c3..7979b5a6d0 100644 --- a/website/docs/user-guide/features/web-search.md +++ b/website/docs/user-guide/features/web-search.md @@ -441,11 +441,13 @@ Run `hermes setup` to see which web backend is detected: ✅ Web Search & Extract (searxng) ``` -Or check via the CLI: +For a source checkout, you can also check the module after +[PM activation](/reference/package-management#developer-workflow). Use the home +whose web configuration you intend to inspect: ```bash -# Activate the venv and run the web tools module directly -source ~/.hermes/hermes-agent/.venv/bin/activate +# From the Hermes source checkout, in a clean shell +source ./activate python -m tools.web_tools ``` diff --git a/website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md b/website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md index 6e31b32dc9..09fe66c03d 100644 --- a/website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md +++ b/website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md @@ -15,7 +15,7 @@ Use, configure, theme, extend, and orchestrate Hermes Agent. | | | |---|---| | Source | Bundled (installed by default) | -| Path | `skills/autonomous-ai-agents\hermes-agent` | +| Path | `skills/autonomous-ai-agents/hermes-agent` | | Version | `3.2.0` | | Author | Hermes Agent + Teknium | | License | MIT | @@ -62,7 +62,7 @@ Never answer "Hermes can't do that" from memory. Hermes ships far more than this ## Quick Start ```bash -# Install (shell installer — sets up uv, Python, the venv, and the launcher) +# Install (shell installer — bootstraps PM, Python, dependencies, and the launcher) curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash # Interactive chat (default surface; set display.interface: tui to launch the Ink TUI instead) diff --git a/website/docs/user-guide/skills/bundled/media/media-youtube-content.md b/website/docs/user-guide/skills/bundled/media/media-youtube-content.md index 6324aca041..94aed9d177 100644 --- a/website/docs/user-guide/skills/bundled/media/media-youtube-content.md +++ b/website/docs/user-guide/skills/bundled/media/media-youtube-content.md @@ -15,7 +15,7 @@ YouTube transcripts to summaries, threads, blogs. | | | |---|---| | Source | Bundled (installed by default) | -| Path | `skills/media\youtube-content` | +| Path | `skills/media/youtube-content` | | Version | `1.0.0` | | Author | Teknium (teknium1), Hermes Agent | | License | MIT | @@ -38,29 +38,42 @@ Extract transcripts from YouTube videos and convert them into useful formats. ## Setup -Use `uv` so the dependency is installed into the same Hermes-managed environment -that runs the helper script: +Use `terminal` with the Python from a PM-prepared Hermes source checkout. The +`youtube` extra declares the helper's dependency; do not install packages into +Hermes with raw pip or project-discovering `uv run`. + +From that checkout, first follow the isolated development-home setup in +[Package Management](https://hermes-agent.nousresearch.com/docs/reference/package-management#developer-workflow), +then prepare the extra and reactivate before running the helper: ```bash -uv pip install youtube-transcript-api +source ./activate +python -c "import pm; pm.sync_venv(['youtube'], explicit=True)" +source ./activate +python -c "import youtube_transcript_api; print(youtube_transcript_api.__file__)" ``` +On Windows, use `. .\activate.ps1` instead of `source ./activate`. If the terminal +runs on a different host or in a sandbox, use an explicitly isolated helper +environment there, not the agent's production environment. Run every command +below with the interpreter whose import check succeeded. + ## Helper Script `SKILL_DIR` is the directory containing this SKILL.md file. The script accepts any standard YouTube URL format, short links (youtu.be), shorts, embeds, live links, or a raw 11-character video ID. ```bash # JSON output with metadata -uv run python SKILL_DIR/scripts/fetch_transcript.py "https://youtube.com/watch?v=VIDEO_ID" +python SKILL_DIR/scripts/fetch_transcript.py "https://youtube.com/watch?v=VIDEO_ID" # Plain text (good for piping into further processing) -uv run python SKILL_DIR/scripts/fetch_transcript.py "URL" --text-only +python SKILL_DIR/scripts/fetch_transcript.py "URL" --text-only # With timestamps -uv run python SKILL_DIR/scripts/fetch_transcript.py "URL" --timestamps +python SKILL_DIR/scripts/fetch_transcript.py "URL" --timestamps # Specific language with fallback chain -uv run python SKILL_DIR/scripts/fetch_transcript.py "URL" --language tr,en +python SKILL_DIR/scripts/fetch_transcript.py "URL" --language tr,en ``` ## Output Formats @@ -86,7 +99,7 @@ After fetching the transcript, format it based on what the user asks for: ## Workflow -1. **Fetch** the transcript using the helper script with `--text-only --timestamps` via `uv run python`. +1. **Fetch** the transcript using `terminal` and the prepared Python with `--text-only --timestamps`. 2. **Validate**: confirm the output is non-empty and in the expected language. If empty, retry without `--language` to get any available transcript. If still empty, tell the user the video likely has transcripts disabled. 3. **Chunk if needed**: if the transcript exceeds ~50K characters, split into overlapping chunks (~40K with 2K overlap) and summarize each chunk before merging. 4. **Transform** into the requested output format. If the user did not specify a format, default to a summary. @@ -97,4 +110,4 @@ After fetching the transcript, format it based on what the user asks for: - **Transcript disabled**: tell the user; suggest they check if subtitles are available on the video page. - **Private/unavailable video**: relay the error and ask the user to verify the URL. - **No matching language**: retry without `--language` to fetch any available transcript, then note the actual language to the user. -- **Dependency missing**: run `uv pip install youtube-transcript-api` and retry. +- **Dependency missing**: repeat PM preparation and reactivation above, then verify the helper uses that Python. Do not repair the selected generation with pip. diff --git a/website/docs/user-guide/skills/bundled/software-development/software-development-python-debugpy.md b/website/docs/user-guide/skills/bundled/software-development/software-development-python-debugpy.md index 5ef5b30d96..cf0c329b96 100644 --- a/website/docs/user-guide/skills/bundled/software-development/software-development-python-debugpy.md +++ b/website/docs/user-guide/skills/bundled/software-development/software-development-python-debugpy.md @@ -15,7 +15,7 @@ Debug Python: pdb REPL + debugpy remote (DAP). | | | |---|---| | Source | Bundled (installed by default) | -| Path | `skills/software-development\python-debugpy` | +| Path | `skills/software-development/python-debugpy` | | Version | `1.0.0` | | Author | Hermes Agent | | License | MIT | @@ -112,24 +112,20 @@ python -m pdb path/to/script.py arg1 arg2 ## Recipe 3: Debug a pytest test -The hermes test runner and pytest both support this: +Use `terminal` and the canonical runner for noninteractive diagnostics: ```bash -# Drop to pdb on failure (or on any raised exception): -scripts/run_tests.sh tests/path/to/test_file.py::test_name --pdb - -# Drop to pdb at the START of the test: -scripts/run_tests.sh tests/path/to/test_file.py::test_name --trace - # Show locals in tracebacks without pdb: scripts/run_tests.sh tests/path/to/test_file.py --showlocals --tb=long ``` -Note: `scripts/run_tests.sh` captures each test file in a separate subprocess through `scripts/run_tests_parallel.py`. Interactive pdb needs a terminal, so use direct pytest only for the interactive debugger: +`scripts/run_tests.sh` captures each file in a separate subprocess, so `--pdb` +or `--trace` cannot provide an interactive prompt there. For an interactive +debugger only, use the independent development/test interpreter prepared in +Recipe 5 (never a production generation): ```bash -source .venv/bin/activate -python -m pytest tests/foo_test.py::test_bar --pdb +.venv/bin/python -m pytest tests/foo_test.py::test_bar --pdb ``` This bypasses the hermetic-env guarantees — fine for debugging, but re-run under the wrapper to confirm before pushing. @@ -166,11 +162,26 @@ For long-lived processes: Hermes gateway, tui_gateway, a daemon, a process that' ### Setup +For Hermes, use a separate development checkout and data home, not a live +production generation. Follow the +[PM developer workflow](https://hermes-agent.nousresearch.com/docs/reference/package-management#developer-workflow) +first. The declared `dev` extra includes debugpy. Through `terminal`, build a +fresh, caller-owned debug/test environment with the prepared checkout's Python: + ```bash -source /.venv/bin/activate -pip install debugpy +python -m pm.build_env --source . --out .venv --extra dev --group test +deactivate +source .venv/bin/activate +python -c "import debugpy; print(debugpy.__file__)" ``` +The output must not already exist. Stop its processes and intentionally remove +only that disposable environment before rebuilding. Keep the same isolated +`HERMES_HOME` for the debug target. The activation above is for this explicitly +built debug environment, not a guessed application venv. Do not add debugpy to +a running production environment; reproduce there only with an already-prepared +debug target or arrange a restart in the development environment. + ### Pattern A: Source-edit — process waits for debugger at launch Add near the top of the entry point (or inside the function you want to debug): @@ -271,9 +282,12 @@ This is fine for one-off automation but painful as an interactive UX. **Option 3: Ditch DAP, use `remote-pdb`** — usually what you actually want from a terminal agent: -```bash -pip install remote-pdb -``` +For an independently owned Python project, declare `remote-pdb` in that +project's development dependencies and prepare its debug environment through +the project's package manager. This is not a Hermes SDK install recipe. For +Hermes, prefer the declared debugpy dependency; the remote-pdb examples below +require a separately declared, freshly built debug environment, never an +in-place pip install into the selected application generation. In your code: ```python @@ -295,7 +309,8 @@ nc 127.0.0.1 4444 See Recipe 3. The wrapper captures subprocess output, so run pytest directly for interactive pdb. ### `run_agent.py` / CLI — one-shot -Easiest: add `breakpoint()` near the suspect line, then run `hermes` normally. Control returns to your terminal at the pause point. +In the prepared debug checkout, add `breakpoint()` near the suspect line, then +run `python hermes`. Control returns to your terminal at the pause point. ### `tui_gateway` subprocess (spawned by `hermes --tui`) The gateway runs as a child of the Node TUI. Options: @@ -307,7 +322,7 @@ import debugpy debugpy.listen(("127.0.0.1", 5678)) debugpy.wait_for_client() ``` -Start `hermes --tui`. The TUI will appear frozen (its backend is waiting). Attach a client; execution resumes when you `continue`. +Start `python hermes --tui` from the prepared debug checkout. The TUI will appear frozen (its backend is waiting). Attach a client; execution resumes when you `continue`. Check the child's interpreter and imports before assuming it inherited the debug environment. **B. Use `remote-pdb` at a specific handler:** ```python @@ -347,7 +362,7 @@ Long-lived. Use `remote-pdb` at a handler, or `debugpy` with `--wait-for-client` ## Verification Checklist -- [ ] After `pip install debugpy`, confirm: `python -c "import debugpy; print(debugpy.__version__)"` +- [ ] In the independently built debug environment, confirm: `python -c "import debugpy; print(debugpy.__version__); print(debugpy.__file__)"` - [ ] For remote debug, confirm the port is actually listening: `ss -tlnp | grep 5678` - [ ] First breakpoint actually hits (if it doesn't, you likely have `PYTHONBREAKPOINT=0`, you're under a parallel/capturing runner, or execution finished before attach) - [ ] `where` / `w` shows the expected call stack diff --git a/website/docs/user-guide/skills/optional/productivity/productivity-memento-flashcards.md b/website/docs/user-guide/skills/optional/productivity/productivity-memento-flashcards.md index f2d0df28d3..ebcfb9b989 100644 --- a/website/docs/user-guide/skills/optional/productivity/productivity-memento-flashcards.md +++ b/website/docs/user-guide/skills/optional/productivity/productivity-memento-flashcards.md @@ -15,7 +15,7 @@ Spaced-repetition flashcards: create, review, quiz, export. | | | |---|---| | Source | Optional — install with `hermes skills install official/productivity/memento-flashcards` | -| Path | `optional-skills/productivity\memento-flashcards` | +| Path | `optional-skills/productivity/memento-flashcards` | | Version | `1.0.0` | | Author | Memento AI | | License | MIT | @@ -218,11 +218,22 @@ python3 ~/.hermes/skills/productivity/memento-flashcards/scripts/youtube_quiz.py This returns `{"title": "...", "transcript": "..."}` or an error. -If the script reports `missing_dependency`, tell the user to install it: +If the script reports `missing_dependency`, use `terminal` with a PM-prepared +Hermes checkout to prepare the declared `youtube` extra, then reactivate: + ```bash -pip install youtube-transcript-api +python -c "import pm; pm.sync_venv(['youtube'], explicit=True)" +source ./activate +python -c "import youtube_transcript_api; print(youtube_transcript_api.__file__)" ``` +Follow the isolated development-home setup in +[Package Management](https://hermes-agent.nousresearch.com/docs/reference/package-management#developer-workflow) +before preparation. Retry `youtube_quiz.py` with that Python and the actual +skill directory returned by `skill_view`. For a remote or sandbox terminal, +prepare an independent helper environment on that host. Never pip-install into +Hermes's selected environment. + **Step 3:** Generate 5 quiz questions from the transcript. Use these rules: ``` @@ -311,7 +322,7 @@ Returns JSON with: - **Never edit `cards.json` directly** — always use the script subcommands to avoid corruption - **Transcript failures** — some YouTube videos have no English transcript or have transcripts disabled; inform the user and suggest another video -- **Optional dependency** — `youtube_quiz.py` needs `youtube-transcript-api`; if missing, tell the user to run `pip install youtube-transcript-api` +- **Optional dependency** — `youtube_quiz.py` needs `youtube-transcript-api`; use the PM preparation and interpreter check above if missing. - **Large imports** — CSV imports with thousands of rows work fine but the JSON output may be verbose; summarize the result for the user - **Video ID extraction** — support both `youtube.com/watch?v=ID` and `youtu.be/ID` URL formats @@ -328,7 +339,7 @@ python3 ~/.hermes/skills/productivity/memento-flashcards/scripts/memento_cards.p If you are testing from the repo checkout, run: ```bash -pytest tests/skills/test_memento_cards.py tests/skills/test_youtube_quiz.py -q +scripts/run_tests.sh tests/skills/test_memento_cards.py tests/skills/test_youtube_quiz.py -q ``` Agent-level verification: diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/adding-platform-adapters.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/adding-platform-adapters.md index 0a50ce8afa..c005676f9f 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/adding-platform-adapters.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/adding-platform-adapters.md @@ -122,7 +122,7 @@ def register(ctx): check_fn=check_requirements, validate_config=validate_config, required_env=["MY_PLATFORM_TOKEN"], - install_hint="pip install my-platform-sdk", + install_hint="Declare my-platform-sdk in this plugin's Python dependencies, then retry hermes plugins enable my-platform", # 环境变量驱动的自动配置 — 在适配器构建前从环境变量 # 填充 PlatformConfig.extra。参见下方"环境变量驱动的自动配置"章节。 env_enablement_fn=_env_enablement, diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/adding-providers.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/adding-providers.md index 638f47df2e..1b1df51b9c 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/adding-providers.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/adding-providers.md @@ -338,35 +338,35 @@ Prompt(提示词)缓存和 provider 专属的调节项很容易出现回归 - `provider:model` 解析 - 任何适配器专属的消息转换 -运行目标测试(或使用 `scripts/run_tests.sh`,它在独立子进程中运行每个文件): +先准备[独立测试环境](/developer-guide/contributing#manual-development-and-test-environment), +再使用标准运行器;它会隔离每个测试文件并清除凭据环境变量: ```bash -source venv/bin/activate -python -m pytest tests/test_runtime_provider_resolution.py tests/test_cli_provider_resolution.py tests/test_cli_model_command.py tests/test_setup_model_selection.py -q +scripts/run_tests.sh tests/hermes_cli/test_runtime_provider_resolution.py tests/cli/test_cli_provider_resolution.py tests/hermes_cli/test_setup_model_provider.py tests/run_agent/test_provider_parity.py -q ``` 对于更深层的修改,在推送前运行完整测试套件: ```bash -source venv/bin/activate -python -m pytest tests/ -n0 -q +scripts/run_tests.sh tests/ -q ``` ## 第 9 步:实时验证 -测试通过后,运行真实的冒烟测试。 +测试通过后,按照 [PM 开发流程](/reference/package-management#developer-workflow) +使用独立开发数据目录,从源码检出运行真实冒烟测试。先退出测试 venv,再激活 PM。 ```bash -source venv/bin/activate -python -m hermes_cli.main chat -q "Say hello" --provider your-provider --model your-model +source ./activate +python hermes chat -q "Say hello" --provider your-provider --model your-model ``` 如果你修改了菜单,也测试交互式流程: ```bash -source venv/bin/activate -python -m hermes_cli.main model -python -m hermes_cli.main setup +source ./activate +python hermes model +python hermes setup ``` 对于原生 provider,至少也验证一次工具调用,而不仅仅是纯文本响应。 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/extending-the-cli.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/extending-the-cli.md index dd29129e02..7019725e45 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/extending-the-cli.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/extending-the-cli.md @@ -73,11 +73,11 @@ if __name__ == "__main__": cli.run() ``` -运行: +按照 [PM 开发流程](/reference/package-management#developer-workflow)选择独立开发数据目录, +然后从源码检出运行: ```bash -cd ~/.hermes/hermes-agent -source .venv/bin/activate +source ./activate python my_cli.py ``` diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/plugins/index.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/plugins/index.md index 039295d27a..dc40f6018f 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/plugins/index.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/plugins/index.md @@ -484,6 +484,8 @@ def my_tool_handler(args, **kwargs): 目录插件通过 `pyproject.toml` 的 `[project].dependencies` 声明自己的依赖。 `plugin.yaml` 中的旧式 `pip_dependencies` 和 `python_dependencies` 列表也会加入 PM 工作区。 PM 在启用插件前统一准备核心依赖和插件依赖,不改写已发布的源码或锁文件。 +安装流程会请求依赖安装许可;拒绝时保留已安装但未启用的插件。 +成功准备后,环境选择与启用配置通过同一准入事务发布;失败保留原选择和启用列表。 解析冲突会拒绝准入并保留原环境,不会自动禁用其他插件。 手动 pip 安装不等于持久的 PM 依赖声明,后续环境替换不保证保留它们。 详见[包管理](/reference/package-management)。 @@ -1068,10 +1070,11 @@ tts: my-plugin = "my_plugin_package" ``` -```bash -pip install hermes-plugin-calculator -# 下次 hermes 启动时自动发现插件 -``` +当安装所有者提供的环境中包含该发行包时(例如 Nix 派生),entry-point 发现仍受支持。 +发现机制不代表可以向 PM 选中的环境直接注入 pip 包。对于 PM 管理的安装, +请分发带有 `pyproject.toml` 或清单 Python 依赖声明的目录插件,并使用 +`hermes plugins install` / `enable` 进行事务式准入。新环境选定后重启 Hermes。 +`hermes pm install` 接受托管工具名称,不接受任意 PyPI 包名。 ## 为 NixOS 分发 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/automation-blueprints.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/automation-blueprints.md index 0c0afba47d..a49d192dc2 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/automation-blueprints.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/automation-blueprints.md @@ -142,9 +142,9 @@ Report any gaps where code changed but docs didn't. If everything is in sync, re hermes cron create "0 6 * * *" \ "Run a dependency security audit on the hermes-agent project. -1. cd ~/.hermes/hermes-agent && source .venv/bin/activate -2. Run: pip audit --format json 2>/dev/null || pip audit 2>&1 -3. Run: npm audit --json 2>/dev/null (in website/ directory if it exists) +1. Locate the hermes-agent checkout and its pyproject.toml and uv.lock. Do not activate or mutate Hermes's dependency environment. +2. Scan uv.lock with an independently installed scanner that supports that lock format (check its --help). Preserve the complete findings and errors. If no scanner is available, report the blocker; do not install one into Hermes. +3. Run: npm audit --json in website/ if it exists. Preserve stderr and distinguish findings from a failed scan. 4. Check for any CVEs with CVSS score >= 7.0 If vulnerabilities found: diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/built-in-plugins.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/built-in-plugins.md index 5ac19fae8f..490b634bc1 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/built-in-plugins.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/built-in-plugins.md @@ -124,11 +124,23 @@ hermes plugins disable disk-cleanup **设置:** ```bash -pip install langfuse -hermes plugins enable observability/langfuse +hermes tools # → Langfuse Observability → Cloud 或 Self-Hosted ``` -或在交互式 `hermes plugins` UI 中勾选复选框。然后将凭据写入 `~/.hermes/.env`: +向导收集凭据,按需通过 PM 准备已声明的 `langfuse` extra,并启用插件。 +完成后重启 Hermes;准备失败时通过 `hermes tools` 重试,不要直接 pip 安装到选中的环境。 + +源码检出的手动设置:先按照 [PM 开发流程](/reference/package-management#developer-workflow) +激活目标检出并选择正确的 Hermes 数据目录,然后执行: + +```bash +python -c "import pm; pm.sync_venv(['langfuse'], explicit=True)" +source ./activate +python hermes plugins enable observability/langfuse +``` + +PowerShell 使用 `. .\activate.ps1` 激活。将凭据写入活动数据目录的 `.env` +(`$HERMES_HOME/.env`,通常为 `~/.hermes/.env`): ```bash HERMES_LANGFUSE_PUBLIC_KEY=pk-lf-... @@ -261,7 +273,7 @@ agent 会启动会议加入流程,在通话进行时将转录内容流式传 以下情况适合将插件纳入内置: -- 没有可选依赖项(或它们已经是 `pip install .[all]` 的依赖) +- 没有可选依赖项(或已包含在声明的 `all` extra 中) - 该行为对大多数用户有益,且是默认启用、需要主动关闭的 - 逻辑与生命周期 hook 紧密结合,否则 agent 需要记住手动调用 - 在不扩展模型可见工具接口的前提下补充核心能力 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/memory-providers.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/memory-providers.md index 8612472c8a..c0b089d19d 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/memory-providers.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/memory-providers.md @@ -47,7 +47,7 @@ AI 原生的跨会话用户建模,具备辩证推理、会话范围上下文 | | | |---|---| | **适合场景** | 具有跨会话上下文的多 Agent 系统、用户-Agent 对齐 | -| **依赖** | `pip install honcho-ai` + [API key](https://app.honcho.dev) 或自托管实例 | +| **依赖** | `hermes memory setup` 通过 PM 准备 Honcho SDK;[API key](https://app.honcho.dev) 或自托管实例 | | **数据存储** | Honcho Cloud 或自托管 | | **费用** | Honcho 定价(云端)/ 免费(自托管) | @@ -267,7 +267,7 @@ hermes honcho sync | | | |---|---| | **适合场景** | 具有结构化浏览功能的自托管知识管理 | -| **依赖** | `pip install openviking` + 运行中的服务器 | +| **依赖** | 独立部署的 OpenViking 服务器;通过 `hermes memory setup` 准备 Hermes 端依赖 | | **数据存储** | 自托管(本地或云端) | | **费用** | 免费(开源,AGPL-3.0) | @@ -275,8 +275,9 @@ hermes honcho sync **安装:** ```bash -# 先启动 OpenViking 服务器 -pip install openviking +# 使用独立部署的 OpenViking 服务器,不要安装到 Hermes 的依赖环境 +openviking-server init +openviking-server doctor openviking-server # 然后配置 Hermes @@ -300,7 +301,7 @@ echo "OPENVIKING_ENDPOINT=http://localhost:1933" >> ~/.hermes/.env | | | |---|---| | **适合场景** | 免维护的记忆管理——Mem0 自动处理提取 | -| **依赖** | `pip install mem0ai` + API key | +| **依赖** | `hermes memory setup` 通过 PM 准备 Mem0 SDK;API key 或自托管/OSS 服务配置 | | **数据存储** | Mem0 Cloud | | **费用** | Mem0 定价 | @@ -470,7 +471,7 @@ hermes config set memory.provider byterover | | | |---|---| | **适合场景** | 带用户 profile 和会话级图谱构建的语义召回 | -| **依赖** | `pip install supermemory` + [云端 API key](http://app.supermemory.ai/integrations?connect=hermes),或[自托管服务器](https://supermemory.ai/docs/self-hosting/overview) | +| **依赖** | `hermes memory setup` 通过 PM 准备 Supermemory SDK;[云端 API key](http://app.supermemory.ai/integrations?connect=hermes),或[自托管服务器](https://supermemory.ai/docs/self-hosting/overview) | | **数据存储** | Supermemory 云端或自托管 | | **费用** | 云端按 Supermemory 定价 / 自托管免费 | diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/web-search.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/web-search.md index a0376c80ad..3f87423580 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/web-search.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/web-search.md @@ -358,11 +358,12 @@ xAI Web Search **不在**自动检测链中——设置了 `XAI_API_KEY`(或 ✅ Web Search & Extract (searxng) ``` -或通过 CLI 检查: +源码检出也可以在 [PM 激活](/reference/package-management#developer-workflow)后检查模块。 +使用你打算检查网页配置的 Hermes 数据目录: ```bash -# 激活 venv 并直接运行网页工具模块 -source ~/.hermes/hermes-agent/.venv/bin/activate +# 从 Hermes 源码目录的干净 shell 运行 +source ./activate python -m tools.web_tools ``` diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md index b92eca6522..4a54675706 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md @@ -1,947 +1,230 @@ --- -title: "Hermes Agent — 配置、扩展或贡献 Hermes Agent" +title: "Hermes Agent — 使用、配置与扩展 Hermes Agent" sidebar_label: "Hermes Agent" -description: "配置、扩展或贡献 Hermes Agent" +description: "Use, configure, theme, extend, and orchestrate Hermes Agent" --- {/* This page is auto-generated from the skill's SKILL.md by website/scripts/generate-skill-docs.py. Edit the source SKILL.md, not this page. */} # Hermes Agent -配置、扩展或贡献 Hermes Agent。 +Use, configure, theme, extend, and orchestrate Hermes Agent. -## Skill 元数据 +## Skill metadata | | | |---|---| -| 来源 | 内置(默认安装) | -| 路径 | `skills/autonomous-ai-agents/hermes-agent` | -| 版本 | `2.1.0` | -| 作者 | Hermes Agent + Teknium | -| 许可证 | MIT | -| 平台 | linux, macos, windows | -| 标签 | `hermes`, `setup`, `configuration`, `multi-agent`, `spawning`, `cli`, `gateway`, `development` | -| 相关 skill | [`claude-code`](/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-claude-code), [`codex`](/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-codex), [`opencode`](/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-opencode) | +| Source | Bundled (installed by default) | +| Path | `skills/autonomous-ai-agents/hermes-agent` | +| Version | `3.2.0` | +| Author | Hermes Agent + Teknium | +| License | MIT | +| Platforms | linux, macos, windows | +| Tags | `hermes`, `setup`, `configuration`, `multi-agent`, `spawning`, `cli`, `gateway`, `bots`, `bot-mode`, `features`, `themes`, `skins`, `desktop-plugins`, `tui-widgets`, `petdex`, `development` | +| Related skills | [`claude-code`](/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-claude-code), [`codex`](/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-codex), [`opencode`](/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-opencode) | -## 参考:完整 SKILL.md +## 参考:当前 SKILL.md 英文原文 :::info -以下是 Hermes 在触发此 skill 时加载的完整 skill 定义。这是 agent 在 skill 激活时看到的指令内容。 +以下定义由当前 SKILL.md 生成,保留英文原文以与 agent 实际加载的指令一致。旧版译文中的 pip 安装和 Windows 测试绕过方案已移除;开发与测试请按原文链接读取当前 contributor-guide 和 windows-quirks 参考。 ::: # Hermes Agent -Hermes Agent 是 Nous Research 开发的开源 AI agent 框架,可在终端、消息平台和 IDE 中运行。它与 Claude Code(Anthropic)、Codex(OpenAI)和 OpenClaw 同属一类——使用工具调用(tool calling)与系统交互的自主编码和任务执行 agent。Hermes 支持任意 LLM 提供商(OpenRouter、Anthropic、OpenAI、DeepSeek、本地模型及 15+ 其他提供商),可在 Linux、macOS 和 WSL 上运行。 +Hermes Agent is an open-source AI agent framework by Nous Research that runs in your terminal, a native desktop app, messaging platforms, and IDEs. It's in the same category as Claude Code (Anthropic), Codex (OpenAI), and OpenClaw — autonomous coding and task-execution agents that use tool calling to interact with your system. Hermes works with any LLM provider (OpenRouter, Anthropic, OpenAI, Google, DeepSeek, xAI, local models, and 20+ others) and runs on Linux, macOS, Windows, and WSL. -Hermes 的差异化特性: +What makes Hermes different: -- **通过 skill 自我提升** — Hermes 通过将可复用流程保存为 skill 来从经验中学习。当它解决复杂问题、发现工作流或被纠正时,可以将该知识持久化为 skill 文档,加载到未来的会话中。skill 随时间积累,使 agent 在你的特定任务和环境中表现越来越好。 -- **跨会话持久记忆** — 记住你是谁、你的偏好、环境细节和经验教训。可插拔的记忆后端(内置、Honcho、Mem0 等)让你选择记忆的工作方式。 -- **多平台 gateway** — 同一个 agent 在 Telegram、Discord、Slack、WhatsApp、Signal、Matrix、Email 及 10+ 其他平台上运行,具备完整工具访问权限,而不仅仅是聊天。 -- **提供商无关** — 在工作流中途切换模型和提供商,无需更改其他任何内容。凭证池自动轮换多个 API key。 -- **Profiles(配置文件)** — 运行多个独立的 Hermes 实例,各自拥有隔离的配置、会话、skill 和记忆。 -- **可扩展** — 插件、MCP 服务器、自定义工具、webhook 触发器、cron 调度以及完整的 Python 生态系统。 +- **Self-improving through skills** — Hermes learns from experience by saving reusable procedures as skills that load into future sessions. +- **Persistent memory across sessions** — remembers who you are, your preferences, environment details, and lessons learned. Pluggable memory backends. +- **Multi-platform gateway** — the same agent runs on Telegram, Discord, Slack, WhatsApp, iMessage, Signal, Matrix, Teams, Email, and a dozen more platforms with full tool access, not just chat. +- **Many surfaces** — the same agent core drives the CLI, the Ink TUI, a native Electron desktop app, a web dashboard, and an ACP server for IDEs (VS Code / Zed / JetBrains). +- **Provider-agnostic** — swap models and providers mid-workflow; credential pools rotate across multiple API keys automatically. +- **Profiles** — run multiple independent Hermes instances with isolated configs, sessions, skills, and memory. +- **Extensible & themeable** — plugins, MCP servers, custom tools, webhook triggers, cron scheduling, skins that theme every surface, desktop UI plugins, TUI widgets, and pet mascots. -人们将 Hermes 用于软件开发、研究、系统管理、数据分析、内容创作、家庭自动化,以及任何受益于具有持久上下文和完整系统访问权限的 AI agent 的场景。 +**This skill is a hub.** The body covers identity, quick start, spawning/orchestration, and hard invariants. Everything else lives in reference files — **load the matching reference (below) before answering**; do not answer detail questions from the body alone. -**此 skill 帮助你高效使用 Hermes Agent** — 包括设置、配置功能、生成额外的 agent 实例、排查问题、找到正确的命令和设置,以及在需要扩展或贡献时理解系统的工作原理。 +**Docs:** https://hermes-agent.nousresearch.com/docs/ -**文档:** https://hermes-agent.nousresearch.com/docs/ +## Scope & Verification -## 快速开始 +This skill is a concise operating guide, not the complete source of truth for every Hermes feature. If a Hermes feature, command, or setting is not mentioned here or in a reference, do not treat that absence as evidence that it does not exist. Check the live repository and official docs before giving a negative answer. + +Good verification targets, cheapest first: + +- **Every shipped feature, one line each: https://hermes-agent.nousresearch.com/docs/llms.txt.** Start here for any "can Hermes do X?" or "how do I do X?" — it indexes the entire documentation set with a link to the page that answers. It is generated from the docs tree on every build, so it is never behind the product. Fetch it with `web_extract`, or `curl -s https://hermes-agent.nousresearch.com/docs/llms.txt` when web tools are off. The whole documentation set in one file is at `/docs/llms-full.txt`. +- CLI commands: `hermes --help`, `hermes --help`, and `hermes_cli/main.py` +- Source tree: https://github.com/NousResearch/hermes-agent + +Never answer "Hermes can't do that" from memory. Hermes ships far more than this skill body describes, and the index exists so a negative answer is always checkable. + +## Quick Start ```bash -# 安装 +# Install (shell installer — bootstraps PM, Python, dependencies, and the launcher) curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash -# 交互式聊天(默认) +# Interactive chat (default surface; set display.interface: tui to launch the Ink TUI instead) hermes -# 单次查询 +# Single query hermes chat -q "What is the capital of France?" -# 设置向导 +# Setup wizard / pick model+provider / health check hermes setup - -# 更改模型/提供商 hermes model - -# 健康检查 hermes doctor + +# Other surfaces +hermes desktop # launch the native desktop app (alias: hermes gui) +hermes dashboard # web admin panel + embedded chat +hermes proxy # OpenAI-compatible local proxy backed by your OAuth provider ``` ---- - -## CLI 参考 - -### 全局标志 +## Key Paths ``` -hermes [flags] [command] - - --version, -V Show version - --resume, -r SESSION Resume session by ID or title - --continue, -c [NAME] Resume by name, or most recent session - --worktree, -w Isolated git worktree mode (parallel agents) - --skills, -s SKILL Preload skills (comma-separate or repeat) - --profile, -p NAME Use a named profile - --yolo Skip dangerous command approval - --pass-session-id Include session ID in system prompt -``` - -无子命令时默认为 `chat`。 - -### Chat - -``` -hermes chat [flags] - -q, --query TEXT Single query, non-interactive - -m, --model MODEL Model (e.g. anthropic/claude-sonnet-4) - -t, --toolsets LIST Comma-separated toolsets - --provider PROVIDER Force provider (openrouter, anthropic, nous, etc.) - -v, --verbose Verbose output - -Q, --quiet Suppress banner, spinner, tool previews - --checkpoints Enable filesystem checkpoints (/rollback) - --source TAG Session source tag (default: cli) -``` - -### 配置 - -``` -hermes setup [section] Interactive wizard (model|terminal|gateway|tools|agent) -hermes model Interactive model/provider picker -hermes config View current config -hermes config edit Open config.yaml in $EDITOR -hermes config set KEY VAL Set a config value -hermes config path Print config.yaml path -hermes config env-path Print .env path -hermes config check Check for missing/outdated config -hermes config migrate Update config with new options -hermes auth 交互式凭据管理器 -hermes auth add PROVIDER 添加 OAuth 或 API key 凭据(例如 nous、openai-codex、qwen-oauth) -hermes auth list 列出已存储的凭据 -hermes auth remove PROVIDER 移除已存储的凭据 -hermes doctor [--fix] Check dependencies and config -hermes status [--all] Show component status -``` - -### 工具与 Skill - -``` -hermes tools Interactive tool enable/disable (curses UI) -hermes tools list Show all tools and status -hermes tools enable NAME Enable a toolset -hermes tools disable NAME Disable a toolset - -hermes skills list List installed skills -hermes skills search QUERY Search the skills hub -hermes skills install ID Install a skill (ID can be a hub identifier OR a direct https://…/SKILL.md URL; pass --name to override when frontmatter has no name) -hermes skills inspect ID Preview without installing -hermes skills config Enable/disable skills per platform -hermes skills check Check for updates -hermes skills update Update outdated skills -hermes skills uninstall N Remove a hub skill -hermes skills publish PATH Publish to registry -hermes skills browse Browse all available skills -hermes skills tap add REPO Add a GitHub repo as skill source -``` - -### MCP 服务器 - -``` -hermes mcp serve Run Hermes as an MCP server -hermes mcp add NAME Add an MCP server (--url or --command) -hermes mcp remove NAME Remove an MCP server -hermes mcp list List configured servers -hermes mcp test NAME Test connection -hermes mcp configure NAME Toggle tool selection -``` - -### Gateway(消息平台) - -``` -hermes gateway run Start gateway foreground -hermes gateway install Install as background service -hermes gateway start/stop Control the service -hermes gateway restart Restart the service -hermes gateway status Check status -hermes gateway setup Configure platforms -``` - -支持的平台:Telegram、Discord、Slack、WhatsApp、Signal、Email、SMS、Matrix、Mattermost、Home Assistant、DingTalk、Feishu、WeCom、BlueBubbles(iMessage)、Weixin(WeChat)、API Server、Webhooks。Open WebUI 通过 API Server 适配器连接。 - -平台文档:https://hermes-agent.nousresearch.com/docs/user-guide/messaging/ - -### 会话 - -``` -hermes sessions list List recent sessions -hermes sessions browse Interactive picker -hermes sessions export OUT Export to JSONL -hermes sessions rename ID T Rename a session -hermes sessions delete ID Delete a session -hermes sessions prune Clean up old sessions (--older-than N days) -hermes sessions stats Session store statistics -``` - -### Cron 任务 - -``` -hermes cron list List jobs (--all for disabled) -hermes cron create SCHED Create: '30m', 'every 2h', '0 9 * * *' -hermes cron edit ID Edit schedule, prompt, delivery -hermes cron pause/resume ID Control job state -hermes cron run ID Trigger on next tick -hermes cron remove ID Delete a job -hermes cron status Scheduler status -``` - -### Webhook - -``` -hermes webhook subscribe N Create route at /webhooks/ -hermes webhook list List subscriptions -hermes webhook remove NAME Remove a subscription -hermes webhook test NAME Send a test POST -``` - -### Profiles - -``` -hermes profile list List all profiles -hermes profile create NAME Create (--clone, --clone-all, --clone-from) -hermes profile use NAME Set sticky default -hermes profile delete NAME Delete a profile -hermes profile show NAME Show details -hermes profile alias NAME Manage wrapper scripts -hermes profile rename A B Rename a profile -hermes profile export NAME Export to tar.gz -hermes profile import FILE Import from archive -``` - -### 凭证池 - -``` -hermes auth add Interactive credential wizard -hermes auth list [PROVIDER] List pooled credentials -hermes auth remove P INDEX Remove by provider + index -hermes auth reset PROVIDER Clear exhaustion status -``` - -### 其他 - -``` -hermes insights [--days N] Usage analytics -hermes update Update to latest version -hermes pairing list/approve/revoke DM authorization -hermes plugins list/install/remove Plugin management -hermes honcho setup/status Honcho memory integration (requires honcho plugin) -hermes memory setup/status/off Memory provider config -hermes completion bash|zsh Shell completions -hermes acp ACP server (IDE integration) -hermes claw migrate Migrate from OpenClaw -hermes uninstall Uninstall Hermes -``` - ---- - -## 斜杠命令(会话内) - -在交互式聊天会话中输入这些命令。新命令会不定期上线;如果以下内容看起来过时,请在会话内运行 `/help` 获取权威列表,或查看[实时斜杠命令参考](https://hermes-agent.nousresearch.com/docs/reference/slash-commands)。命令注册表的权威来源是 `hermes_cli/commands.py` — 每个消费方(自动补全、Telegram 菜单、Slack 映射、`/help`)均从中派生。 - -### 会话控制 -``` -/new (/reset) Fresh session -/clear Clear screen + new session (CLI) -/retry Resend last message -/undo Remove last exchange -/title [name] Name the session -/compress Manually compress context -/stop Kill background processes -/rollback [N] Restore filesystem checkpoint -/snapshot [sub] Create or restore state snapshots of Hermes config/state (CLI) -/bg Run prompt in background -/queue Queue for next turn -/steer Inject a message after the next tool call without interrupting -/agents (/tasks) Show active agents and running tasks -/resume [name] Resume a named session -/goal [text|sub] Set a standing goal Hermes works on across turns until achieved - (subcommands: status, pause, resume, clear) -/redraw Force a full UI repaint (CLI) -``` - -### 配置 -``` -/config Show config (CLI) -/model [name] Show or change model -/personality [name] Set personality -/reasoning [level] Set reasoning (none|minimal|low|medium|high|xhigh|max|ultra|show|hide) -/verbose Cycle: off → new → all → verbose -/voice [on|off|tts] Voice mode -/yolo Toggle approval bypass -/busy [sub] Control how messages behave while Hermes works (CLI + gateway) - (subcommands: queue, steer, interrupt, status) -/indicator [style] Pick the TUI busy-indicator style (CLI) - (styles: kaomoji, emoji, unicode, ascii) -/footer [on|off] Toggle gateway runtime-metadata footer on final replies -/skin [name] Change theme (CLI) -/statusbar Toggle status bar (CLI) -``` - -### 工具与 Skill -``` -/tools Manage tools (CLI) -/toolsets List toolsets (CLI) -/skills Search/install skills (CLI) -/skill Load a skill into session -/reload-skills Re-scan ~/.hermes/skills/ for added/removed skills -/reload Reload .env variables into the running session (CLI) -/reload-mcp Reload MCP servers -/cron Manage cron jobs (CLI) -/curator [sub] Background skill maintenance (status, run, pin, archive, …) -/kanban [sub] Multi-profile collaboration board (tasks, links, comments) -/plugins List plugins (CLI) -``` - -### Gateway -``` -/approve Approve a pending command (gateway) -/deny Deny a pending command (gateway) -/restart Restart gateway (gateway) -/sethome Set current chat as home channel (gateway) -/update Update Hermes to latest (gateway) -/topic [sub] Enable or inspect Telegram DM topic sessions (gateway) -/platforms (/gateway) Show platform connection status (gateway) -``` - -### 实用工具 -``` -/branch (/fork) Branch the current session -/fast Toggle priority/fast processing -/browser Open CDP browser connection -/history Show conversation history (CLI) -/save Save conversation to file (CLI) -/copy [N] Copy the last assistant response to clipboard (CLI) -/paste Attach clipboard image (CLI) -/image Attach local image file (CLI) -``` - -### 信息 -``` -/help Show commands -/commands [page] Browse all commands (gateway) -/usage Token usage -/insights [days] Usage analytics -/status Session info (gateway) -/profile Active profile info -/debug Upload debug report (system info + logs) and get shareable links -``` - -### 退出 -``` -/quit (/exit, /q) Exit CLI -``` - ---- - -## 关键路径与配置 - -``` -~/.hermes/config.yaml Main configuration -~/.hermes/.env API keys and secrets +~/.hermes/config.yaml Main configuration (settings — never secrets) +~/.hermes/.env API keys and secrets ONLY (under $HERMES_HOME if set) $HERMES_HOME/skills/ Installed skills -~/.hermes/sessions/ Session transcripts +~/.hermes/skins/ Custom themes (see references/themes.md) +~/.hermes/desktop-plugins/ Desktop app UI plugins (see references/desktop-plugins.md) +~/.hermes/tui-widgets/ TUI widget apps (see references/tui-widgets.md) +~/.hermes/pets/ Installed pet mascots (see references/petdex.md) +~/.hermes/state.db Canonical session store (SQLite + FTS5) +~/.hermes/sessions/ Gateway routing index, request dumps, *.jsonl transcripts ~/.hermes/logs/ Gateway and error logs ~/.hermes/auth.json OAuth tokens and credential pools ~/.hermes/hermes-agent/ Source code (if git-installed) ``` -Profiles 使用 `~/.hermes/profiles//`,布局相同。 +Profiles use `~/.hermes/profiles//` with the same layout. When a profile is active, resolve the real home from `$HERMES_HOME` — never hardcode `~/.hermes`. -### 配置节 +## Routing Table — load the reference for the task -使用 `hermes config edit` 或 `hermes config set section.key value` 编辑。 +| User wants... | Load | +|---|---| +| **Anything not listed below — "can Hermes do X?", "how do I set up X?"** | **https://hermes-agent.nousresearch.com/docs/llms.txt** | +| Bots that chat, run routines, or message each other; the Bots tab | docs: `/user-guide/bot-mode` | +| CLI commands, subcommands, flags, "how do I run X" | `references/cli-reference.md` | +| In-session slash commands | `references/slash-commands.md` | +| Provider setup, API keys, OAuth | `references/providers-and-models.md` | +| config.yaml sections, toolsets, voice/STT/TTS | `references/configuration.md` | +| AGENTS.md / .hermes.md / CLAUDE.md project rules | `references/project-context-files.md` | +| Secret redaction, PII, approval modes, "reset permissions" | `references/security-privacy.md` | +| Delegation, cron, curator, kanban | `references/background-systems.md` | +| MCP servers (add, catalog, `hermes mcp`) | `references/native-mcp.md` | +| Webhook routes and event-driven runs | `references/webhooks.md` | +| A custom theme/skin ("synthwave theme", "change the gold ●") | `references/themes.md` + `templates/skin.yaml` | +| A desktop app UI element (pane, widget, ⌘K command, page) | `references/desktop-plugins.md` + `templates/plugin.js` | +| A live TUI panel or modal widget (ticker, clock, dashboard) | `references/tui-widgets.md` + `templates/clock.mjs` | +| Pet mascots — install, select, scale, diagnose | `references/petdex.md` | +| Windows-specific issues (keybinds, WinError 10106, BOM) | `references/windows-quirks.md` | +| Debugging: voice, tools missing, gateway, aux models | `references/troubleshooting.md` | +| Contributing code: adding tools, slash commands, tests | `references/contributor-guide.md` | +| delegate_task "capped at N" reports | `references/delegate-task-concurrency-diagnosis.md` | +| "Can app X use my Nous Portal subscription/OAuth?" | `references/portal-auth-for-third-party-apps.md` | +| Connecting a messaging platform (Telegram, Discord, Slack, WhatsApp, …) | docs: `/user-guide/messaging` | -| 节 | 键选项 | -|---------|-------------| -| `model` | `default`, `provider`, `base_url`, `api_key`, `context_length` | -| `agent` | `max_turns` (90), `tool_use_enforcement` | -| `terminal` | `backend` (local/docker/ssh/modal), `cwd`, `timeout` (180) | -| `compression` | `enabled`, `threshold` (0.50), `target_ratio` (0.20) | -| `display` | `skin`, `tool_progress`, `show_reasoning`, `show_cost` | -| `stt` | `enabled`, `provider` (local/groq/openai/mistral) | -| `tts` | `provider` (edge/elevenlabs/openai/minimax/mistral/neutts) | -| `memory` | `memory_enabled`, `user_profile_enabled`, `provider` | -| `security` | `tirith_enabled`, `website_blocklist` | -| `delegation` | `model`, `provider`, `base_url`, `api_key`, `max_iterations` (50), `reasoning_effort` | -| `checkpoints` | `enabled`, `max_snapshots` (50) | +The reference list above is not the feature list — it is the set of topics that +need more than their docs page. For everything else Hermes ships, fetch +`llms.txt` and it maps the question to the page that answers it. -完整配置参考:https://hermes-agent.nousresearch.com/docs/user-guide/configuration +Two theming rules that hold even without loading the reference: **you apply skins yourself** (`hermes config set display.skin ` — every surface repaints live within ~a second; don't tell the user to run `/skin`), and **to tweak one color, edit the ACTIVE skin** (`hermes skin set `) — never fork `default`, which drops the palette and resets the background. -### 提供商 +## Spawning Additional Hermes Instances -支持 20+ 个提供商。通过 `hermes model` 或 `hermes setup` 设置。 +Run additional Hermes processes as fully independent subprocesses — separate sessions, tools, and environments. -| 提供商 | 认证方式 | Key 环境变量 | -|----------|------|-------------| -| OpenRouter | API key | `OPENROUTER_API_KEY` | -| Anthropic | API key | `ANTHROPIC_API_KEY` | -| Nous Portal | OAuth | `hermes auth` | -| OpenAI Codex | OAuth | `hermes auth` | -| GitHub Copilot | Token | `COPILOT_GITHUB_TOKEN` | -| Google Gemini | API key | `GOOGLE_API_KEY` 或 `GEMINI_API_KEY` | -| DeepSeek | API key | `DEEPSEEK_API_KEY` | -| xAI / Grok | API key | `XAI_API_KEY` | -| Hugging Face | Token | `HF_TOKEN` | -| Z.AI / GLM | API key | `GLM_API_KEY` | -| MiniMax | API key | `MINIMAX_API_KEY` | -| MiniMax CN | API key | `MINIMAX_CN_API_KEY` | -| Kimi / Moonshot | API key | `KIMI_API_KEY` | -| Alibaba / DashScope | API key | `DASHSCOPE_API_KEY` | -| Xiaomi MiMo | API key | `XIAOMI_API_KEY` | -| Kilo Code | API key | `KILOCODE_API_KEY` | -| AI Gateway (Vercel) | API key | `AI_GATEWAY_API_KEY` | -| OpenCode Zen | API key | `OPENCODE_ZEN_API_KEY` | -| OpenCode Go | API key | `OPENCODE_GO_API_KEY` | -| Qwen OAuth | OAuth | `hermes auth add qwen-oauth` | -| 自定义端点 | 配置 | `config.yaml` 中的 `model.base_url` + `model.api_key` | -| GitHub Copilot ACP | 外部 | `COPILOT_CLI_PATH` 或 Copilot CLI | +### When to Use This vs delegate_task -完整提供商文档:https://hermes-agent.nousresearch.com/docs/integrations/providers - -### Toolset - -通过 `hermes tools`(交互式)或 `hermes tools enable/disable NAME` 启用/禁用。 - -| Toolset | 提供的功能 | -|---------|-----------------| -| `web` | 网页搜索和内容提取 | -| `search` | 仅网页搜索(`web` 的子集) | -| `browser` | 浏览器自动化(Browserbase、Camofox 或本地 Chromium) | -| `terminal` | Shell 命令和进程管理 | -| `file` | 文件读/写/搜索/补丁 | -| `code_execution` | 沙箱 Python 执行 | -| `vision` | 图像分析 | -| `image_gen` | AI 图像生成 | -| `video` | 视频分析和生成 | -| `tts` | 文字转语音 | -| `skills` | Skill 浏览和管理 | -| `memory` | 跨会话持久记忆 | -| `session_search` | 搜索历史对话 | -| `delegation` | 子 agent 任务委派 | -| `cronjob` | 定时任务管理 | -| `clarify` | 向用户提问澄清 | -| `messaging` | 跨平台消息发送 | -| `todo` | 会话内任务规划和跟踪 | -| `kanban` | 多 agent 工作队列工具(仅限 worker) | -| `debugging` | 额外的内省/调试工具(默认关闭) | -| `safe` | 最小化、低风险工具集,用于受限会话 | -| `spotify` | Spotify 播放和播放列表控制 | -| `homeassistant` | 智能家居控制(默认关闭) | -| `discord` | Discord 集成工具 | -| `discord_admin` | Discord 管理/审核工具 | -| `feishu_doc` | 飞书文档工具 | -| `feishu_drive` | 飞书云盘工具 | -| `yuanbao` | 元宝集成工具 | -| `rl` | 强化学习工具(默认关闭) | -| `moa` | Mixture of Agents(默认关闭) | - -完整枚举位于 `toolsets.py` 的 `TOOLSETS` 字典中;`_HERMES_CORE_TOOLS` 是大多数平台继承的默认工具包。 - -工具变更在 `/reset`(新会话)后生效。为保留 prompt 缓存,变更**不会**在对话中途生效。 - ---- - -## 安全与隐私开关 - -常见的"为什么 Hermes 对我的输出/工具调用/命令做了 X?"开关——以及更改它们的确切命令。其中大多数需要新会话(聊天中的 `/reset`,或启动新的 `hermes` 调用),因为它们在启动时只读取一次。 - -### 工具输出中的密钥脱敏 - -密钥脱敏**默认关闭** — 工具输出(终端 stdout、`read_file`、网页内容、子 agent 摘要等)不经修改直接传递。如果用户希望 Hermes 在 API key、token 和密钥进入对话上下文和日志之前自动屏蔽它们: - -```bash -hermes config set security.redact_secrets true # 全局启用 -``` - -**需要重启。** `security.redact_secrets` 在导入时快照 — 在会话中途切换(例如通过工具调用执行 `export HERMES_REDACT_SECRETS=true`)对正在运行的进程**不会**生效。告知用户在终端运行 `hermes config set security.redact_secrets true`,然后启动新会话。这是有意为之——防止 LLM 在任务中途自行切换该开关。 - -再次禁用: -```bash -hermes config set security.redact_secrets false -``` - -### Gateway 消息中的 PII 脱敏 - -与密钥脱敏分开。启用后,gateway 在上下文到达模型之前对用户 ID 进行哈希处理并从会话上下文中去除电话号码: - -```bash -hermes config set privacy.redact_pii true # 启用 -hermes config set privacy.redact_pii false # 禁用(默认) -``` - -### 命令审批提示 - -默认情况下(`approvals.mode: smart`),Hermes 会让辅助 LLM 评估被标记为破坏性的 shell 命令(`rm -rf`、`git reset --hard` 等)。模式如下: - -- `smart` — 低风险命令仅批准一次,高风险命令拒绝,不确定时提示(默认) -- `manual` — 始终提示 -- `off` — 跳过所有审批提示(等同于 `--yolo`) - -```bash -hermes config set approvals.mode smart # 推荐的折中方案 -hermes config set approvals.mode off # 绕过一切(不推荐) -``` - -单次调用绕过(不更改配置): -- `hermes --yolo …` -- `export HERMES_YOLO_MODE=1` - -注意:YOLO / `approvals.mode: off` **不会**关闭密钥脱敏。两者相互独立。 - -### Shell hook 允许列表 - -某些 shell hook 集成在触发前需要明确加入允许列表。通过 `~/.hermes/shell-hooks-allowlist.json` 管理——在 hook 首次尝试运行时以交互方式提示。 - -### 禁用 web/browser/image-gen 工具 - -要完全阻止模型访问网络或媒体工具,打开 `hermes tools` 并按平台切换。在下次会话(`/reset`)后生效。参见上方的工具与 Skill 部分。 - ---- - -## 语音与转录 - -### STT(语音 → 文字) - -来自消息平台的语音消息会自动转录。 - -提供商优先级(自动检测): -1. **本地 faster-whisper** — 免费,无需 API key:`pip install faster-whisper` -2. **Groq Whisper** — 免费套餐:设置 `GROQ_API_KEY` -3. **OpenAI Whisper** — 付费:设置 `VOICE_TOOLS_OPENAI_KEY` -4. **Mistral Voxtral** — 设置 `MISTRAL_API_KEY` - -配置: -```yaml -stt: - enabled: true - provider: local # local, groq, openai, mistral - local: - model: base # tiny, base, small, medium, large-v3 -``` - -### TTS(文字 → 语音) - -| 提供商 | 环境变量 | 免费? | -|----------|---------|-------| -| Edge TTS | 无 | 是(默认) | -| ElevenLabs | `ELEVENLABS_API_KEY` | 免费套餐 | -| OpenAI | `VOICE_TOOLS_OPENAI_KEY` | 付费 | -| MiniMax | `MINIMAX_API_KEY` | 付费 | -| Mistral (Voxtral) | `MISTRAL_API_KEY` | 付费 | -| NeuTTS(本地) | 无(`pip install neutts[all]` + `espeak-ng`) | 免费 | - -语音命令:`/voice on`(语音对语音)、`/voice tts`(始终语音)、`/voice off`。 - ---- - -## 生成额外的 Hermes 实例 - -将额外的 Hermes 进程作为完全独立的子进程运行——拥有独立的会话、工具和环境。 - -### 何时使用此方式 vs delegate_task - -| | `delegate_task` | 生成 `hermes` 进程 | +| | `delegate_task` | Spawning `hermes` process | |-|-----------------|--------------------------| -| 隔离性 | 独立对话,共享进程 | 完全独立进程 | -| 持续时间 | 分钟级(受父循环限制) | 小时/天 | -| 工具访问 | 父工具的子集 | 完整工具访问 | -| 交互性 | 否 | 是(PTY 模式) | -| 使用场景 | 快速并行子任务 | 长时间自主任务 | +| Isolation | Separate conversation, shared process | Fully independent process | +| Duration | Minutes (bounded by parent loop) | Hours/days | +| Tool access | Subset of parent's tools | Full tool access | +| Interactive | No | Yes (PTY mode) | +| Use case | Quick parallel subtasks | Long autonomous missions | -### 单次模式 +### One-Shot Mode ``` terminal(command="hermes chat -q 'Research GRPO papers and write summary to ~/research/grpo.md'", timeout=300) -# 长任务后台运行: +# Background for long tasks: terminal(command="hermes chat -q 'Set up CI/CD for ~/myapp'", background=true) ``` -### 交互式 PTY 模式(通过 tmux) +### Interactive PTY Mode (via tmux) -Hermes 使用 prompt_toolkit,需要真实终端。使用 tmux 进行交互式生成: +Hermes uses prompt_toolkit, which requires a real terminal. Use tmux for interactive spawning: ``` -# 启动 +# Start terminal(command="tmux new-session -d -s agent1 -x 120 -y 40 'hermes'", timeout=10) -# 等待启动,然后发送消息 +# Wait for startup, then send a message terminal(command="sleep 8 && tmux send-keys -t agent1 'Build a FastAPI auth service' Enter", timeout=15) -# 读取输出 +# Read output terminal(command="sleep 20 && tmux capture-pane -t agent1 -p", timeout=5) -# 发送后续消息 +# Send follow-up terminal(command="tmux send-keys -t agent1 'Add rate limiting middleware' Enter", timeout=5) -# 退出 +# Exit terminal(command="tmux send-keys -t agent1 '/exit' Enter && sleep 2 && tmux kill-session -t agent1", timeout=10) ``` -### 多 Agent 协调 +### Multi-Agent Coordination ``` -# Agent A:后端 +# Agent A: backend terminal(command="tmux new-session -d -s backend -x 120 -y 40 'hermes -w'", timeout=10) terminal(command="sleep 8 && tmux send-keys -t backend 'Build REST API for user management' Enter", timeout=15) -# Agent B:前端 +# Agent B: frontend terminal(command="tmux new-session -d -s frontend -x 120 -y 40 'hermes -w'", timeout=10) terminal(command="sleep 8 && tmux send-keys -t frontend 'Build React dashboard for user management' Enter", timeout=15) -# 检查进度,在两者之间传递上下文 +# Check progress, relay context between them terminal(command="tmux capture-pane -t backend -p | tail -30", timeout=5) terminal(command="tmux send-keys -t frontend 'Here is the API schema from the backend agent: ...' Enter", timeout=5) ``` -### 会话恢复 +### Session Resume ``` -# 恢复最近的会话 +# Resume most recent session terminal(command="tmux new-session -d -s resumed 'hermes --continue'", timeout=10) -# 恢复特定会话 +# Resume specific session terminal(command="tmux new-session -d -s resumed 'hermes --resume 20260225_143052_a1b2c3'", timeout=10) ``` -### 提示 - -- **快速子任务优先使用 `delegate_task`** — 比生成完整进程开销更小 -- **生成编辑代码的 agent 时使用 `-w`(worktree 模式)** — 防止 git 冲突 -- **为单次模式设置超时** — 复杂任务可能需要 5-10 分钟 -- **fire-and-forget 使用 `hermes chat -q`** — 无需 PTY -- **交互式会话使用 tmux** — 原始 PTY 模式与 prompt_toolkit 存在 `\r` vs `\n` 问题 -- **定时任务使用 `cronjob` 工具而非生成进程** — 处理投递和重试 - ---- - -## 持久化与后台系统 - -四个系统与主对话循环并行运行。此处为快速参考;完整开发者说明位于 `AGENTS.md`,面向用户的文档位于 `website/docs/user-guide/features/`。 - -### 委派(`delegate_task`) - -同步子 agent 生成——父 agent 等待子 agent 的摘要后再继续自身循环。隔离的上下文和终端会话。 - -- **单个:** `delegate_task(goal, context)`。 -- **批量:** `delegate_task(tasks=[{goal, ...}, ...])` 并行运行子任务,上限由 `delegation.max_concurrent_children`(默认 3)控制。 -- **角色:** `leaf`(默认;不能再委派)vs `orchestrator`(可以生成自己的 worker,受 `delegation.max_spawn_depth` 限制)。 -- **非持久化。** 如果父 agent 被中断,子 agent 会被取消。对于必须在当前轮次之后继续的工作,使用 `cronjob` 或 `terminal(background=True, notify_on_complete=True)`。 - -配置:`config.yaml` 中的 `delegation.*`。 - -### Cron(定时任务) - -持久化调度器——`cron/jobs.py` + `cron/scheduler.py`。通过 `cronjob` 工具、`hermes cron` CLI(`list`、`add`、`edit`、`pause`、`resume`、`run`、`remove`)或 `/cron` 斜杠命令驱动。 - -- **调度格式:** 持续时间(`"30m"`、`"2h"`)、"every" 短语(`"every monday 9am"`)、5 字段 cron(`"0 9 * * *"`)或 ISO 时间戳。 -- **每任务选项:** `skills`、`model`/`provider` 覆盖、`script`(预运行数据收集;`no_agent=True` 使脚本成为整个任务)、`context_from`(将任务 A 的输出链接到任务 B)、`workdir`(在特定目录中运行,加载其 `AGENTS.md` / `CLAUDE.md`)、多平台投递。 -- **不变量:** 每次运行 3 分钟硬中断,`.tick.lock` 文件防止跨进程重复 tick,cron 会话默认传递 `skip_memory=True`,cron 投递使用页眉/页脚框架而非镜像到目标 gateway 会话(保持角色交替完整)。 - -用户文档:https://hermes-agent.nousresearch.com/docs/user-guide/features/cron - -### Curator(skill 生命周期) - -agent 创建的 skill 的后台维护。跟踪使用情况,将闲置 skill 标记为过时,归档过时的 skill,保留运行前的 tar.gz 备份以防数据丢失。 - -- **CLI:** `hermes curator ` — `status`、`run`、`pause`、`resume`、`pin`、`unpin`、`archive`、`restore`、`prune`、`backup`、`rollback`。 -- **斜杠命令:** `/curator ` 与 CLI 对应。 -- **范围:** 仅处理 `created_by: "agent"` 来源的 skill。内置和 hub 安装的 skill 不在范围内。**从不删除** — 最具破坏性的操作是归档。已固定的 skill 不受任何自动转换和任何 LLM 审查的影响。 -- **遥测:** `~/.hermes/skills/.usage.json` 中的 sidecar 保存每个 skill 的 `use_count`、`view_count`、`patch_count`、`last_activity_at`、`state`、`pinned`。 - -配置:`curator.*`(`enabled`、`interval_hours`、`min_idle_hours`、`stale_after_days`、`archive_after_days`、`backup.*`)。 -用户文档:https://hermes-agent.nousresearch.com/docs/user-guide/features/curator - -### Kanban(多 agent 工作队列) - -用于多 profile/多 worker 协作的持久化 SQLite 看板(kanban)。用户通过 `hermes kanban ` 驱动;调度器生成的 worker 看到由 `HERMES_KANBAN_TASK` 控制的专注 `kanban_*` toolset,orchestrator profile 可以选择加入更广泛的 `kanban` toolset。普通会话除非配置,否则没有任何 `kanban_*` schema 占用。 - -- **CLI 动词(常用):** `init`、`create`、`list`(别名 `ls`)、`show`、`assign`、`link`、`unlink`、`comment`、`complete`、`block`、`unblock`、`archive`、`tail`。不常用:`watch`、`stats`、`runs`、`log`、`dispatch`、`daemon`、`gc`。 -- **Worker/orchestrator toolset:** `kanban_show`、`kanban_complete`、`kanban_block`、`kanban_heartbeat`、`kanban_comment`、`kanban_create`、`kanban_link`;在调度器生成的任务之外显式启用 `kanban` toolset 的 profile 还可获得 `kanban_list` 和 `kanban_unblock` 用于看板路由。 -- **调度器** 默认在 gateway 内运行(`kanban.dispatch_in_gateway: true`)——回收过期认领、推进就绪任务、原子认领、生成已分配的 profile。在配置的 `kanban.failure_limit` 次连续非成功尝试后自动阻塞任务(默认:2)。 -- **隔离:** 看板是硬边界(worker 在环境中固定 `HERMES_KANBAN_BOARD`);租户是看板内用于工作区路径和记忆键隔离的软命名空间。 - -用户文档:https://hermes-agent.nousresearch.com/docs/user-guide/features/kanban - ---- - -## Windows 特有问题 - -Hermes 在 Windows 上原生运行(PowerShell、cmd、Windows Terminal、git-bash mintty、VS Code 集成终端)。大多数功能开箱即用,但 Win32 和 POSIX 之间有一些差异曾给我们带来麻烦——遇到新问题时请在此记录,以免下一个人(或下一个会话)重新踩坑。 - -### 输入/键绑定 - -**Alt+Enter 不插入换行。** Windows Terminal 在终端层拦截 Alt+Enter 以切换全屏——该按键永远不会到达 prompt_toolkit。请改用 **Ctrl+Enter**。Windows Terminal 将 Ctrl+Enter 作为 LF(`c-j`)传递,与普通 Enter(`c-m` / CR)不同,CLI 仅在 `win32` 上将 `c-j` 绑定到换行插入(参见 `_bind_prompt_submit_keys` + `cli.py` 中仅限 Windows 的 `c-j` 绑定)。副作用:在 Windows 上,原始 Ctrl+J 按键也会插入换行——这是不可避免的,因为 Windows Terminal 在 Win32 控制台 API 层将 Ctrl+Enter 和 Ctrl+J 折叠为相同的键码。Windows 上 Ctrl+J 没有冲突的绑定,因此这是无害的副作用。 - -mintty / git-bash 行为相同(Alt+Enter 全屏),除非你在选项 → 键中禁用 Alt+Fn 快捷键。直接使用 Ctrl+Enter 更简单。 - -**诊断键绑定。** 运行 `python scripts/keystroke_diagnostic.py`(仓库根目录)可查看 prompt_toolkit 在当前终端中如何识别每个按键。可回答"Shift+Enter 是否作为独立键传入?"(几乎从不——大多数终端将其折叠为普通 Enter)或"我的终端为 Ctrl+Enter 发送什么字节序列?"等问题。Ctrl+Enter = c-j 这一事实就是通过此方式确认的。 - -### 配置/文件 - -**首次运行时 HTTP 400 "No models provided"。** `config.yaml` 保存时带有 UTF-8 BOM(Windows 应用写入时常见)。重新保存为不带 BOM 的 UTF-8。`hermes config edit` 写入时不带 BOM;手动在记事本中编辑是常见原因。 - -### `execute_code` / 沙箱 - -**WinError 10106**("无法加载或初始化请求的服务提供商")来自沙箱子进程——它无法创建 `AF_INET` socket,因此回退的 loopback-TCP RPC 在 `connect()` 之前失败。根本原因通常**不是**损坏的 Winsock LSP;而是 Hermes 自身的环境清理器从子进程环境中删除了 `SYSTEMROOT` / `WINDIR` / `COMSPEC`。Python 的 `socket` 模块需要 `SYSTEMROOT` 来定位 `mswsock.dll`。通过 `tools/code_execution_tool.py` 中的 `_WINDOWS_ESSENTIAL_ENV_VARS` 允许列表修复。如果仍然遇到此问题,在 `execute_code` 块内 echo `os.environ` 以确认 `SYSTEMROOT` 已设置。完整诊断方案见 `references/execute-code-sandbox-env-windows.md`。 - -### 测试/贡献 - -**`scripts/run_tests.sh` 在 Windows 上无法直接使用** — 它查找 POSIX venv 布局(`.venv/bin/activate`)。Hermes 安装的 venv 位于 `venv/Scripts/`,也没有 pip 或 pytest(为减小安装体积而精简)。解决方案:将 `pytest + ruamel.yaml` 安装到系统 Python 3.11 用户站点,然后设置 `PYTHONPATH` 直接调用 pytest: - -```bash -"/c/Program Files/Python311/python" -m pip install --user pytest ruamel.yaml==0.18.17 -export PYTHONPATH="$(pwd)" -"/c/Program Files/Python311/python" -m pytest tests/foo/test_bar.py -v --tb=short -``` - -仓库已不再使用 pytest-xdist——规范 runner 通过 `run_tests_parallel.py` 做按文件子进程隔离,但该 wrapper 仅支持 POSIX,其 CI 一致性保证不适用于非 POSIX 环境。 - -**仅 POSIX 的测试需要跳过守卫。** 代码库中已有的常见标记: -- 符号链接——Windows 上需要提升权限 -- `0o600` 文件模式——POSIX 模式位在 NTFS 上默认不强制执行 -- `signal.SIGALRM`——仅 Unix(每测试超时不再直接使用它;参见 `tests/conftest.py::pytest_configure` 中的 win32 timeout-method shim) -- Winsock / Windows 特有回归——`@pytest.mark.skipif(sys.platform != "win32", ...)` - -使用现有的跳过模式风格(`sys.platform == "win32"` 或 `sys.platform.startswith("win")`)以与测试套件其余部分保持一致。 - -### 路径/文件系统 - -**行尾。** Git 可能警告 `LF will be replaced by CRLF the next time Git touches it`。这是外观问题——仓库的 `.gitattributes` 会规范化。不要让编辑器自动将已提交的 POSIX 换行文件转换为 CRLF。 - -**正斜杠几乎在所有地方都有效。** `C:/Users/...` 被每个 Hermes 工具和大多数 Windows API 接受。在代码和日志中优先使用正斜杠——避免在 bash 中转义反斜杠。 - ---- - -## 故障排查 - -### 语音不工作 -1. 检查 `config.yaml` 中 `stt.enabled: true` -2. 验证提供商:`pip install faster-whisper` 或设置 API key -3. 在 gateway 中:`/restart`。在 CLI 中:退出并重新启动。 - -### 工具不可用 -1. `hermes tools` — 检查 toolset 是否为你的平台启用 -2. 某些工具需要环境变量(检查 `.env`) -3. 启用工具后执行 `/reset` - -### 模型/提供商问题 -1. `hermes doctor` — 检查配置和依赖 -2. `hermes auth` — 重新认证 OAuth 提供商(或 `hermes auth add `) -3. 检查 `.env` 中是否有正确的 API key -4. **Copilot 403**:`gh auth login` 的 token **不适用于** Copilot API。必须通过 `hermes model` → GitHub Copilot 使用 Copilot 专用 OAuth 设备码流程。 - -### 变更未生效 -- **工具/skill:** `/reset` 以更新后的 toolset 启动新会话 -- **配置变更:** 在 gateway 中:`/restart`。在 CLI 中:退出并重新启动。 -- **代码变更:** 重启 CLI 或 gateway 进程 - -### Skill 未显示 -1. `hermes skills list` — 验证已安装 -2. `hermes skills config` — 检查平台启用状态 -3. 显式加载:`/skill name` 或 `hermes -s name` - -### Gateway 问题 -首先检查日志: -```bash -grep -i "failed to send\|error" ~/.hermes/logs/gateway.log | tail -20 -``` - -常见 gateway 问题: -- **SSH 注销后 gateway 停止**:启用 linger:`sudo loginctl enable-linger $USER` -- **WSL2 关闭后 gateway 停止**:WSL2 需要 `/etc/wsl.conf` 中的 `systemd=true` 才能使 systemd 服务工作。没有它,gateway 回退到 `nohup`(会话关闭时停止)。 -- **Gateway 崩溃循环**:重置失败状态:`systemctl --user reset-failed hermes-gateway` - -### 平台特定问题 -- **Discord bot 静默**:必须在 Bot → Privileged Gateway Intents 中启用 **Message Content Intent**。 -- **Slack bot 仅在私信中工作**:必须订阅 `message.channels` 事件。没有它,bot 会忽略公共频道。 -- **Windows 特有问题**(`Alt+Enter` 换行、WinError 10106、UTF-8 BOM 配置、测试套件、行尾):参见上方专门的 **Windows 特有问题** 部分。 - -### 辅助模型不工作 -如果 `auxiliary` 任务(视觉、压缩)静默失败,`auto` 提供商找不到后端。请设置 `OPENROUTER_API_KEY` 或 `GOOGLE_API_KEY`,或显式配置每个辅助任务的提供商: -```bash -hermes config set auxiliary.vision.provider -hermes config set auxiliary.vision.model -``` - ---- - -## 查找资源 - -| 查找内容... | 位置 | -|----------------|----------| -| 配置选项 | `hermes config edit` 或[配置文档](https://hermes-agent.nousresearch.com/docs/user-guide/configuration) | -| 可用工具 | `hermes tools list` 或[工具参考](https://hermes-agent.nousresearch.com/docs/reference/tools-reference) | -| 斜杠命令 | 会话内 `/help` 或[斜杠命令参考](https://hermes-agent.nousresearch.com/docs/reference/slash-commands) | -| Skill 目录 | `hermes skills browse` 或[Skill 目录](https://hermes-agent.nousresearch.com/docs/reference/skills-catalog) | -| 提供商设置 | `hermes model` 或[提供商指南](https://hermes-agent.nousresearch.com/docs/integrations/providers) | -| 平台设置 | `hermes gateway setup` 或[消息文档](https://hermes-agent.nousresearch.com/docs/user-guide/messaging/) | -| MCP 服务器 | `hermes mcp list` 或[MCP 指南](https://hermes-agent.nousresearch.com/docs/user-guide/features/mcp) | -| Profiles | `hermes profile list` 或[Profiles 文档](https://hermes-agent.nousresearch.com/docs/user-guide/profiles) | -| Cron 任务 | `hermes cron list` 或[Cron 文档](https://hermes-agent.nousresearch.com/docs/user-guide/features/cron) | -| 记忆 | `hermes memory status` 或[记忆文档](https://hermes-agent.nousresearch.com/docs/user-guide/features/memory) | -| 环境变量 | `hermes config env-path` 或[环境变量参考](https://hermes-agent.nousresearch.com/docs/reference/environment-variables) | -| CLI 命令 | `hermes --help` 或[CLI 参考](https://hermes-agent.nousresearch.com/docs/reference/cli-commands) | -| Gateway 日志 | `~/.hermes/logs/gateway.log` | -| 会话文件 | `~/.hermes/sessions/` 或 `hermes sessions browse` | -| 源代码 | `~/.hermes/hermes-agent/` | - ---- - -## 贡献者快速参考 - -面向偶尔贡献者和 PR 作者。完整开发者文档:https://hermes-agent.nousresearch.com/docs/developer-guide/ - -### 项目结构 - - -``` -hermes-agent/ -├── run_agent.py # AIAgent — core conversation loop -├── model_tools.py # Tool discovery and dispatch -├── toolsets.py # Toolset definitions -├── cli.py # Interactive CLI (HermesCLI) -├── hermes_state.py # SQLite session store -├── agent/ # Prompt builder, context compression, memory, model routing, credential pooling, skill dispatch -├── hermes_cli/ # CLI subcommands, config, setup, commands -│ ├── commands.py # Slash command registry (CommandDef) -│ ├── config.py # DEFAULT_CONFIG, env var definitions -│ └── main.py # CLI entry point and argparse -├── tools/ # One file per tool -│ └── registry.py # Central tool registry -├── gateway/ # Messaging gateway -│ └── platforms/ # Platform adapters (telegram, discord, etc.) -├── cron/ # Job scheduler -├── tests/ # ~3000 pytest tests -└── website/ # Docusaurus docs site -``` - - -配置:`~/.hermes/config.yaml`(设置)、`~/.hermes/.env`(API key)。 - -### 添加工具(3 个文件) - -**1. 创建 `tools/your_tool.py`:** -```python -import json, os -from tools.registry import registry - -def check_requirements() -> bool: - return bool(os.getenv("EXAMPLE_API_KEY")) - -def example_tool(param: str, task_id: str = None) -> str: - return json.dumps({"success": True, "data": "..."}) - -registry.register( - name="example_tool", - toolset="example", - schema={"name": "example_tool", "description": "...", "parameters": {...}}, - handler=lambda args, **kw: example_tool( - param=args.get("param", ""), task_id=kw.get("task_id")), - check_fn=check_requirements, - requires_env=["EXAMPLE_API_KEY"], -) -``` - -**2. 添加到 `toolsets.py`** → `_HERMES_CORE_TOOLS` 列表。 - -自动发现:任何包含顶层 `registry.register()` 调用的 `tools/*.py` 文件都会自动导入——无需手动列出。 - -所有处理器必须返回 JSON 字符串。路径使用 `get_hermes_home()`,永远不要硬编码 `~/.hermes`。 - -### 添加斜杠命令 - -1. 在 `hermes_cli/commands.py` 的 `COMMAND_REGISTRY` 中添加 `CommandDef` -2. 在 `cli.py` → `process_command()` 中添加处理器 -3. (可选)在 `gateway/run.py` 中添加 gateway 处理器 - -所有消费方(帮助文本、自动补全、Telegram 菜单、Slack 映射)均自动从中央注册表派生。 - -### Agent 循环(高层概述) - -``` -run_conversation(): - 1. Build system prompt - 2. Loop while iterations < max: - a. Call LLM (OpenAI-format messages + tool schemas) - b. If tool_calls → dispatch each via handle_function_call() → append results → continue - c. If text response → return - 3. Context compression triggers automatically near token limit -``` - -### 测试 - -```bash -python -m pytest tests/ -o 'addopts=' -q # 完整套件 -python -m pytest tests/tools/ -q # 特定区域 -``` - -- 测试自动将 `HERMES_HOME` 重定向到临时目录——永远不会触及真实的 `~/.hermes/` -- 推送任何变更前运行完整套件 -- 使用 `-o 'addopts='` 清除任何内置的 pytest 标志 - -**Windows 贡献者:** `scripts/run_tests.sh` 目前查找 POSIX venv(`.venv/bin/activate` / `venv/bin/activate`),在 Windows 上会报错,因为布局是 `venv/Scripts/activate` + `python.exe`。Hermes 安装的 venv 位于 `venv/Scripts/`,也没有 `pip` 或 `pytest`——为终端用户安装体积而精简。解决方案:将 pytest + ruamel.yaml 安装到系统 Python 3.11 用户站点(`/c/Program Files/Python311/python -m pip install --user pytest ruamel.yaml==0.18.17`),然后直接运行测试: - -```bash -export PYTHONPATH="$(pwd)" -"/c/Program Files/Python311/python" -m pytest tests/tools/test_foo.py -v --tb=short -``` - -仓库已不再使用 pytest-xdist——规范 runner 通过 `run_tests_parallel.py` 做按文件子进程隔离,但该 wrapper 仅支持 POSIX,其 CI 一致性保证不适用于非 POSIX 环境。 - -**跨平台测试守卫:** 使用仅 POSIX 系统调用的测试需要跳过标记。代码库中已有的常见标记: -- 符号链接创建 → `@pytest.mark.skipif(sys.platform == "win32", reason="Symlinks require elevated privileges on Windows")`(参见 `tests/cron/test_cron_script.py`) -- POSIX 文件模式(0o600 等)→ `@pytest.mark.skipif(sys.platform.startswith("win"), reason="POSIX mode bits not enforced on Windows")`(参见 `tests/hermes_cli/test_auth_toctou_file_modes.py`) -- `signal.SIGALRM` → 仅 Unix(每测试超时不再直接使用它;参见 `tests/conftest.py::pytest_configure` 中的 win32 timeout-method shim) -- 实时 Winsock / Windows 特有回归测试 → `@pytest.mark.skipif(sys.platform != "win32", reason="Windows-specific regression")` - -**仅 monkeypatch `sys.platform` 是不够的**,当被测代码还调用 `platform.system()` / `platform.release()` / `platform.mac_ver()` 时。这些函数独立重新读取真实 OS,因此在 Windows runner 上将 `sys.platform = "linux"` 的测试仍会看到 `platform.system() == "Windows"` 并走 Windows 分支。需要同时 patch 三者: - -```python -monkeypatch.setattr(sys, "platform", "linux") -monkeypatch.setattr(platform, "system", lambda: "Linux") -monkeypatch.setattr(platform, "release", lambda: "6.8.0-generic") -``` - -参见 `tests/agent/test_prompt_builder.py::TestEnvironmentHints` 中的完整示例。 - -### 扩展系统 prompt 的执行环境块 - -关于宿主 OS、用户 home、cwd、终端后端和 shell(Windows 上的 bash vs PowerShell)的事实性指导从 `agent/prompt_builder.py::build_environment_hints()` 输出。WSL 提示和每个后端的探测逻辑也在此处。约定: - -- **本地终端后端** → 输出宿主信息(OS、`$HOME`、cwd)+ Windows 特有说明(hostname ≠ username,`terminal` 使用 bash 而非 PowerShell)。 -- **远程终端后端**(`_REMOTE_TERMINAL_BACKENDS` 中的任何内容:`docker, singularity, modal, daytona, ssh, vercel_sandbox, managed_modal`)→ **完全抑制**宿主信息,仅描述后端。通过 `tools.environments.get_environment(...).execute(...)` 在后端内运行实时 `uname`/`whoami`/`pwd` 探测,每进程缓存在 `_BACKEND_PROBE_CACHE` 中,探测超时时使用静态回退。 -- **prompt 编写的关键事实:** 当 `TERMINAL_ENV != "local"` 时,*每个*文件工具(`read_file`、`write_file`、`patch`、`search_files`)都在后端容器内运行,而非宿主上。在这种情况下,系统 prompt 绝不能描述宿主——agent 无法访问它。 - -完整设计说明、确切输出字符串和测试陷阱:`references/prompt-builder-environment-hints.md`。 - -**重构安全模式(POSIX 等价守卫):** 当你将内联逻辑提取到添加 Windows/平台特定行为的辅助函数时,在测试文件中保留一个 `_legacy_` oracle 函数,它是旧代码的逐字副本,然后对其进行参数化差异比较。示例:`tests/tools/test_code_execution_windows_env.py::TestPosixEquivalence`。这锁定了 POSIX 行为逐位相同的不变量,并使任何未来的偏差以清晰的差异明显失败。 - -### 提交约定 - -``` -type: concise subject line - -Optional body. -``` - -类型:`fix:`、`feat:`、`refactor:`、`docs:`、`chore:` - -### 关键规则 - -- **永远不要破坏 prompt 缓存** — 不要在对话中途更改上下文、工具或系统 prompt -- **消息角色交替** — 永远不要连续出现两条 assistant 或两条 user 消息 -- 所有路径使用 `hermes_constants` 中的 `get_hermes_home()`(profile 安全) -- 配置值放入 `config.yaml`,密钥放入 `.env` -- 新工具需要 `check_fn`,以便仅在满足要求时才显示 +### Tips + +- **Prefer `delegate_task` for quick subtasks** — less overhead than spawning a full process +- **Use `-w` (worktree mode)** when spawning agents that edit code — prevents git conflicts +- **Set timeouts** for one-shot mode — complex tasks can take 5-10 minutes +- **Use `hermes chat -q` for fire-and-forget** — no PTY needed +- **Use tmux for interactive sessions** — raw PTY mode has `\r` vs `\n` issues with prompt_toolkit +- **For scheduled tasks**, use the `cronjob` tool instead of spawning — handles delivery and retry +- **"delegate_task is capped at N" reports** — see `references/delegate-task-concurrency-diagnosis.md`. Three real cap paths in Hermes; if none fired, the model is self-limiting and rationalising it as "the runtime caps." +- **"Can $external_app use my Nous Portal subscription / OAuth?"** — see `references/portal-auth-for-third-party-apps.md`. Walk the user through three layers (plugin-vs-app, what Portal actually exposes, local-broker-proxy option). + +## Surfaces (quick orientation) + +- **Desktop app** (`hermes desktop` / `hermes gui`) — native Electron app for macOS/Linux/Windows: streaming chat, session list, Cmd+K palette, drag-and-drop files, native notifications, per-profile remote-gateway login. Extend it with UI plugins — `references/desktop-plugins.md`. +- **Web dashboard** (`hermes dashboard`) — full admin panel: messaging channels, MCP catalog, webhooks, memory, profile builder, plus an embedded `hermes --tui` chat. Secured behind an OAuth/token gate. +- **Ink TUI** (`hermes --tui` or `display.interface: tui`) — terminal UI with docked widget apps — `references/tui-widgets.md`. +- **OpenAI-compatible proxy** (`hermes proxy`) — a local OpenAI API backed by whichever OAuth provider you're signed into. Point Codex CLI, Aider, Cline, or any script at it — no API key. + +## Hard Invariants (never violate, regardless of what you loaded) + +- **Never break prompt caching** — don't change past context, toolsets, or the system prompt mid-conversation. The only exception is context compression. +- **Message role alternation** — never two assistant or two user messages in a row; only `tool` results can repeat. +- **Secrets in `.env`, settings in `config.yaml`** — never tell a user to put a non-credential setting in `.env`. +- **Profile-safe paths** — `get_hermes_home()` in code, `$HERMES_HOME` when resolving paths in a session. +- **Never hand-edit `config.yaml` for the user** — use `hermes config set KEY VAL`; a stray indent can corrupt the file and break the live gateway. diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/media/media-youtube-content.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/media/media-youtube-content.md index 49a9fd2023..aca3765e82 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/media/media-youtube-content.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/media/media-youtube-content.md @@ -34,26 +34,38 @@ YouTube 视频转文字摘要、推文、博客。 ## 安装 +通过 `terminal` 使用 PM 准备的 Hermes 源码检出中的 Python。`youtube` extra +声明了此辅助脚本的依赖;不要用 pip 或会自动发现项目的 `uv run` 修改 Hermes 环境。 +先按照[包管理](https://hermes-agent.nousresearch.com/docs/reference/package-management#developer-workflow) +选择独立开发数据目录,再准备依赖并重新激活: + ```bash -pip install youtube-transcript-api +source ./activate +python -c "import pm; pm.sync_venv(['youtube'], explicit=True)" +source ./activate +python -c "import youtube_transcript_api; print(youtube_transcript_api.__file__)" ``` +Windows 使用 `. .\activate.ps1` 激活。终端在另一台主机或沙箱中时, +请在该环境内准备独立的辅助脚本环境,不要修改 agent 的生产环境。 +下面所有命令都使用导入检查成功的 Python。 + ## 辅助脚本 `SKILL_DIR` 是包含此 SKILL.md 文件的目录。该脚本接受任何标准 YouTube URL 格式、短链接(youtu.be)、Shorts、嵌入链接、直播链接,或原始 11 位视频 ID。 ```bash # JSON 输出(含元数据) -python3 SKILL_DIR/scripts/fetch_transcript.py "https://youtube.com/watch?v=VIDEO_ID" +python SKILL_DIR/scripts/fetch_transcript.py "https://youtube.com/watch?v=VIDEO_ID" # 纯文本输出(适合管道传递给后续处理) -python3 SKILL_DIR/scripts/fetch_transcript.py "URL" --text-only +python SKILL_DIR/scripts/fetch_transcript.py "URL" --text-only # 带时间戳 -python3 SKILL_DIR/scripts/fetch_transcript.py "URL" --timestamps +python SKILL_DIR/scripts/fetch_transcript.py "URL" --timestamps # 指定语言并设置回退链 -python3 SKILL_DIR/scripts/fetch_transcript.py "URL" --language tr,en +python SKILL_DIR/scripts/fetch_transcript.py "URL" --language tr,en ``` ## 输出格式 @@ -90,4 +102,4 @@ python3 SKILL_DIR/scripts/fetch_transcript.py "URL" --language tr,en - **文字稿已禁用**:告知用户;建议其在视频页面检查字幕是否可用。 - **视频不可用或为私密视频**:转达错误信息,请用户核实 URL。 - **无匹配语言**:去掉 `--language` 参数重试以获取任意可用文字稿,并向用户说明实际语言。 -- **缺少依赖**:执行 `pip install youtube-transcript-api` 后重试。 \ No newline at end of file +- **缺少依赖**:重复上述 PM 准备和激活步骤,确认辅助脚本使用该 Python。不要用 pip 修复选中的环境。 \ No newline at end of file diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/software-development/software-development-python-debugpy.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/software-development/software-development-python-debugpy.md index e3ea93f47b..df6783249c 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/software-development/software-development-python-debugpy.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/software-development/software-development-python-debugpy.md @@ -112,24 +112,18 @@ python -m pdb path/to/script.py arg1 arg2 ## 方案 3:调试 pytest 测试 -hermes 测试运行器和 pytest 均支持以下方式: +通过 `terminal` 使用标准测试运行器进行非交互诊断: ```bash -# 在失败时(或任何异常抛出时)进入 pdb: -scripts/run_tests.sh tests/path/to/test_file.py::test_name --pdb - -# 在测试开始时进入 pdb: -scripts/run_tests.sh tests/path/to/test_file.py::test_name --trace - # 在 traceback 中显示局部变量,不使用 pdb: scripts/run_tests.sh tests/path/to/test_file.py --showlocals --tb=long ``` -注意:`scripts/run_tests.sh` 通过 `run_tests_parallel.py` 将每个测试文件放在捕获输出的子进程中运行(不使用 xdist),因此交互式 pdb 在 wrapper 下**无法正常工作**。请直接运行 pytest 使用 `--pdb`: +`scripts/run_tests.sh` 捕获每个文件的子进程输出,无法提供交互式 `--pdb` 或 `--trace` +提示符。仅在交互调试时使用方案 5 准备的独立开发/测试解释器,不要使用生产环境: ```bash -source .venv/bin/activate -python -m pytest tests/foo_test.py::test_bar --pdb +.venv/bin/python -m pytest tests/foo_test.py::test_bar --pdb ``` 这会绕过封闭环境保证——调试时可以接受,但推送前请在 wrapper 下重新运行以确认。 @@ -166,11 +160,22 @@ sys.excepthook = excepthook ### 安装 +使用独立开发检出和数据目录,不要修改正在运行的生产环境。 +先按照 [PM 开发流程](https://hermes-agent.nousresearch.com/docs/reference/package-management#developer-workflow) +准备 Python。`dev` extra 已包含 debugpy;通过 `terminal` 构建全新的调试/测试环境: + ```bash -source /home/bb/hermes-agent/.venv/bin/activate -pip install debugpy +python -m pm.build_env --source . --out .venv --extra dev --group test +deactivate +source .venv/bin/activate +python -c "import debugpy; print(debugpy.__file__)" ``` +输出目录必须不存在。重建前停止其进程,并明确删除仅用于调试的可丢弃环境。 +调试目标使用相同的独立 `HERMES_HOME`。这里激活的是刚构建的独立环境, +不是猜测的应用 venv。不要向正在运行的生产环境安装 debugpy;请在准备好的 +调试目标复现,或安排在开发环境重启。 + ### 模式 A:修改源码——进程在启动时等待调试器 在入口点顶部附近(或要调试的函数内部)添加: @@ -264,16 +269,17 @@ send({"type": "request", "command": "configurationDone"}) "connect": { "host": "127.0.0.1", "port": 5678 }, "justMyCode": false, "pathMappings": [ - { "localRoot": "${workspaceFolder}", "remoteRoot": "/home/bb/hermes-agent" } + { "localRoot": "${workspaceFolder}", "remoteRoot": "" } ] } ``` **选项 3:放弃 DAP,使用 `remote-pdb`** — 通常这才是终端 agent 真正需要的: -```bash -pip install remote-pdb -``` +独立 Python 项目可以在其开发依赖中声明 `remote-pdb`,再用该项目的包管理器 +准备调试环境。这不是 Hermes SDK 安装方法。Hermes 优先使用已声明的 debugpy; +下面的 remote-pdb 示例需要另行声明并全新构建的调试环境,绝不能向选中的应用环境 +原地 pip 安装。 在代码中: ```python @@ -295,7 +301,8 @@ nc 127.0.0.1 4444 参见方案 3。wrapper 会捕获子进程输出,交互式 pdb 请直接运行 pytest。 ### `run_agent.py` / CLI — 一次性运行 -最简单:在可疑行附近添加 `breakpoint()`,然后正常运行 `hermes`。控制权将在暂停点返回到你的终端。 +在准备好的调试检出中,在可疑行附近添加 `breakpoint()`,然后运行 `python hermes`。 +控制权将在暂停点返回到你的终端。 ### `tui_gateway` 子进程(由 `hermes --tui` 启动) gateway 作为 Node TUI 的子进程运行。可选方案: @@ -307,7 +314,9 @@ import debugpy debugpy.listen(("127.0.0.1", 5678)) debugpy.wait_for_client() ``` -启动 `hermes --tui`。TUI 将显示为冻结状态(其后端正在等待)。附加客户端后,执行在你 `continue` 时恢复。 +从准备好的调试检出启动 `python hermes --tui`。TUI 将显示为冻结状态(其后端正在等待)。 +附加客户端后,执行在你 `continue` 时恢复。先检查子进程的解释器和导入路径, +不要假定它继承了调试环境。 **B. 在特定处理器中使用 `remote-pdb`:** ```python @@ -347,7 +356,7 @@ set_trace(host="127.0.0.1", port=4444) # 在你想捕获的 RPC 处理器中 ## 验证清单 -- [ ] `pip install debugpy` 后确认:`python -c "import debugpy; print(debugpy.__version__)"` +- [ ] 在独立构建的调试环境中确认:`python -c "import debugpy; print(debugpy.__version__); print(debugpy.__file__)"` - [ ] 对于远程调试,确认端口确实在监听:`ss -tlnp | grep 5678` - [ ] 第一个断点确实触发(如果没有,可能是 `PYTHONBREAKPOINT=0`、在并行/捕获输出的 runner 下运行,或执行在附加前已结束) - [ ] `where` / `w` 显示预期的调用栈 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/optional/productivity/productivity-memento-flashcards.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/optional/productivity/productivity-memento-flashcards.md index 81285c6f65..46f22e3a9f 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/optional/productivity/productivity-memento-flashcards.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/optional/productivity/productivity-memento-flashcards.md @@ -218,11 +218,19 @@ python3 ~/.hermes/skills/productivity/memento-flashcards/scripts/youtube_quiz.py 返回 `{"title": "...", "transcript": "..."}` 或错误信息。 -如果脚本报告 `missing_dependency`,告知用户安装: +如果脚本报告 `missing_dependency`,通过 `terminal` 使用 PM 准备好的源码检出中的 Python, +准备已声明的 `youtube` extra,再重新激活: + ```bash -pip install youtube-transcript-api +python -c "import pm; pm.sync_venv(['youtube'], explicit=True)" +source ./activate +python -c "import youtube_transcript_api; print(youtube_transcript_api.__file__)" ``` +准备前按照[包管理](https://hermes-agent.nousresearch.com/docs/reference/package-management#developer-workflow) +选择独立开发数据目录。重试时使用该 Python 和 `skill_view` 返回的实际 skill 目录。 +远程或沙箱终端需在其主机上准备独立辅助环境;不要向 Hermes 选中的环境 pip 安装。 + **第 3 步:** 从字幕生成 5 道测验题。使用以下规则: ``` @@ -311,7 +319,7 @@ python3 ~/.hermes/skills/productivity/memento-flashcards/scripts/memento_cards.p - **切勿直接编辑 `cards.json`** — 始终使用脚本子命令以避免数据损坏 - **字幕获取失败** — 部分 YouTube 视频没有英文字幕或字幕已禁用;告知用户并建议换一个视频 -- **可选依赖** — `youtube_quiz.py` 需要 `youtube-transcript-api`;如果缺失,告知用户运行 `pip install youtube-transcript-api` +- **可选依赖** — `youtube_quiz.py` 需要 `youtube-transcript-api`;缺失时使用上述 PM 准备和解释器检查。 - **大量导入** — 包含数千行的 CSV 导入可正常工作,但 JSON 输出可能较冗长;为用户总结结果 - **视频 ID 提取** — 同时支持 `youtube.com/watch?v=ID` 和 `youtu.be/ID` 两种 URL 格式 @@ -328,7 +336,7 @@ python3 ~/.hermes/skills/productivity/memento-flashcards/scripts/memento_cards.p 如果从仓库检出进行测试,运行: ```bash -pytest tests/skills/test_memento_cards.py tests/skills/test_youtube_quiz.py -q +scripts/run_tests.sh tests/skills/test_memento_cards.py tests/skills/test_youtube_quiz.py -q ``` Agent 级别验证: